extract mermaid docs

This commit is contained in:
Aaron Po
2026-08-31 21:20:51 -04:00
parent 6a061f4e28
commit 8ac1981f81
38 changed files with 267 additions and 245 deletions

View File

@@ -0,0 +1,74 @@
flowchart TD
Start([start]) --> ParseArgs["main.cc: ParseArguments(argc, argv)"]
ParseArgs --> ArgsValid{Arguments valid?}
ArgsValid -- no --> LogErr[Log error / usage info] --> Stop1([stop])
ArgsValid -- yes --> Init["main.cc: Init OpenSSL global state and LlamaBackendState"]
Init --> DI["main.cc: di::make_injector(...)<br/>binds ILogger, IEnrichmentService, DataGenerator, IExportService, IPromptFormatter, WebClient based on --mocked vs. model-backed mode"]
DI --> LogThread["main.cc: create BoundedChannel log_channel, spawn log dispatcher thread (LogDispatcher::Run)"]
LogThread --> CreateOrch["main.cc: injector.create BiergartenPipelineOrchestrator"]
CreateOrch --> Run["BiergartenPipelineOrchestrator::Run()"]
Run --> SqliteInit["SqliteExportService.Initialize()<br/>builds biergarten_seed_&lt;UTC datetime&gt;.sqlite (numeric suffix if it exists), opens DB connection, executes schema DDL, begins transaction"]
SqliteInit --> QueryLoc[Orchestrator: QueryLocations]
QueryLoc --> LoadLoc["JsonLoader.LoadLocations(locations.json)"]
LoadLoc --> SampleLoc["std::ranges::sample(all_locations, --location-count) — defaults to 10"]
SampleLoc --> EnrichLoop{For each sampled Location}
EnrichLoop --> GetContext["IEnrichmentService.GetLocationContext(loc)<br/>WikipediaEnrichmentService fetches a generic 'brewing' extract and a 'beer in {country}' extract (not city/region-specific); MockEnrichmentService (--mocked) returns an empty string"]
GetContext --> LookupFailed{Lookup failed?}
LookupFailed -- yes --> SkipCity1[Log warning, skip city] --> EnrichLoop
LookupFailed -- no --> StoreEnriched["Store EnrichedCity{Location, region_context}"] --> EnrichLoop
EnrichLoop -- done --> GenUsers[Orchestrator: GenerateUsers enriched_cities]
GenUsers --> LoadUserData["JsonLoader: LoadPersonas / LoadForenamesByCountry / LoadSurnamesByCountry<br/>each memoized after first parse; MockCuratedDataService (--mocked) returns a fixed in-memory dataset instead, skipping these JSON files"]
LoadUserData --> UserLoop{For each EnrichedCity}
UserLoop --> SampleName["SampleName(forenames_by_country, surnames_by_country, location.iso3166_1)"]
SampleName --> NamesAvail{Names available for country?}
NamesAvail -- no --> SkipCity2[Log warning, skip city] --> UserLoop
NamesAvail -- yes --> SamplePersona["Sample UserPersona (uniform random)"]
SamplePersona --> UserGenMode{Generator mode}
UserGenMode -- MockGenerator --> UserMockGen[DeterministicHash and Format]
UserGenMode -- LlamaGenerator --> UserLlamaLoad["prompt_directory_.Load(USER_GENERATION) → USER_GENERATION.md in --prompt-dir"]
UserLlamaLoad --> UserLlamaRetry["Infer(...) with kUserJsonGrammar → ValidateUserJson<br/>retry up to 3 attempts, appending validation error to the retry prompt on failure"]
UserGenMode -- OpenAIGenerator --> UserOAILoad["prompt_directory_.Load(USER_GENERATION) — same prompt file as LlamaGenerator, content is backend-agnostic"]
UserOAILoad --> UserOAIRetry["CallChatCompletionsApi(...) with kUserJsonSchema → ValidateUserJson<br/>retry up to 2 attempts — Structured Outputs (json_schema, strict) already guarantees schema shape, so retries only cover transient failures / placeholder text"]
UserMockGen --> UserGenDone{Generation successful?}
UserLlamaRetry --> UserGenDone
UserOAIRetry --> UserGenDone
UserGenDone -- yes --> UserPostProc["GetRandomCoordsWithinRange (5km jitter), BuildEmail, GenerateDateOfBirth<br/>email and date_of_birth are generated programmatically, never LLM-authored; generation + coordinate jitter run inside one try/catch per record"]
UserGenDone -- no --> UserGenFail["Log warning: generation failed, skipping"] --> UserLoop
UserPostProc --> UserExport["SqliteExportService.ProcessRecord(UserRecord)<br/>reuse cached location_id or insert Location and cache its id; insert User (FK location_id)"]
UserExport --> UserExportFail{Exception during insert?}
UserExportFail -- yes --> UserExportWarn["Log warning: SQLite export failed"] --> UserLoop
UserExportFail -- no --> UserLoop
UserLoop -- done --> GenBrew[Orchestrator: GenerateBreweries enriched_cities]
GenBrew --> BrewLoop{For each EnrichedCity}
BrewLoop --> BrewGenMode{Generator mode}
BrewGenMode -- MockGenerator --> BrewMockGen[DeterministicHash and Format]
BrewGenMode -- LlamaGenerator --> BrewLlamaLoad["PrepareRegionContext, prompt_directory_.Load(BREWERY_GENERATION) → BREWERY_GENERATION.md"]
BrewLlamaLoad --> BrewLlamaRetry["Infer(...) with kBreweryJsonGrammar → ValidateBreweryJson<br/>retry up to 3 attempts; max_tokens is fixed across retries, not raised on truncation"]
BrewGenMode -- OpenAIGenerator --> BrewOAILoad["PrepareRegionContext, prompt_directory_.Load(BREWERY_GENERATION) — same prompt file as LlamaGenerator"]
BrewOAILoad --> BrewOAIRetry["CallChatCompletionsApi(...) with kBreweryJsonSchema → ValidateBreweryJson<br/>retry up to 2 attempts — Structured Outputs already guarantees schema shape"]
BrewMockGen --> BrewGenDone{Generation successful?}
BrewLlamaRetry --> BrewGenDone
BrewOAIRetry --> BrewGenDone
BrewGenDone -- yes --> BrewPostProc["GetRandomCoordsWithinRange (5km jitter)<br/>generation + jitter run inside one try/catch per record"]
BrewGenDone -- no --> BrewGenFail["Log warning: generation failed, skipping"] --> BrewLoop
BrewPostProc --> BrewExport["SqliteExportService.ProcessRecord(GeneratedBrewery)<br/>reuse cached location_id or insert Location and cache its id; insert Brewery (FK location_id)"]
BrewExport --> BrewExportFail{Exception during insert?}
BrewExportFail -- yes --> BrewExportWarn["Log warning: SQLite export failed (data loss prevented per-record; pipeline continues)"] --> BrewLoop
BrewExportFail -- no --> BrewLoop
BrewLoop -- done --> Finalize["SqliteExportService.Finalize()<br/>commits transaction, closes database connection"]
Finalize --> CloseLog["main.cc: close log_channel, join log dispatcher thread"]
CloseLog --> Stop2(["return 0 / stop"])

View File

@@ -0,0 +1,260 @@
classDiagram
class BiergartenPipelineOrchestrator {
-shared_ptr~ILogger~ logger_
-unique_ptr~IEnrichmentService~ context_service_
-unique_ptr~DataGenerator~ generator_
-unique_ptr~IExportService~ exporter_
-unique_ptr~ICuratedDataService~ curated_data_service_
-ApplicationOptions application_options_
-vector~BreweryRecord~ generated_breweries_
-vector~UserRecord~ generated_users_
+Run() bool
-QueryLocations() vector~City~
-GenerateBreweries(cities) void
-GenerateUsers(cities) void
-LogResults() void
}
note for BiergartenPipelineOrchestrator "GenerateBreweries/GenerateUsers jitter each address via GetRandomCoordsWithinRange(), uniformly within 5km of the city centre"
class LogLevel {
<<enumeration>>
Debug
Info
Warn
Error
}
class PipelinePhase {
<<enumeration>>
Startup
Enrichment
UserGeneration
BreweryAndBeerGeneration
CheckinGeneration
RatingGeneration
FollowGeneration
Teardown
}
class LogDTO {
<<struct>>
+LogLevel level
+PipelinePhase phase
+string message
}
class LogEntry {
<<struct>>
+time_point timestamp
+source_location origin
+thread::id thread_id
+LogLevel level
+PipelinePhase phase
+string message
}
class ILogger {
<<interface>>
+Log(payload) void
-DoLog(entry)* void
}
class LogProducer {
-BoundedChannel~LogEntry~ channel_
-DoLog(entry) void
}
class LogDispatcher {
-BoundedChannel~LogEntry~ channel_
+Run() void
-ToSpdlogLevel(level) level_enum
}
class IEnrichmentService {
<<interface>>
+GetLocationContext(loc) string
}
class MockEnrichmentService {
+GetLocationContext(loc) string
}
class WikipediaEnrichmentService {
-unique_ptr~WebClient~ client_
-unordered_map~string, string~ extract_cache_
+GetLocationContext(loc) string
-FetchExtract(query) string
}
class WebClient {
<<interface>>
+Get(url) string
+UrlEncode(value) string
}
class HttpWebClient {
+Get(url) string
+UrlEncode(value) string
}
class DataGenerator {
<<interface>>
+GenerateBrewery(city) BreweryResult
+GenerateUser(city, persona, name) UserResult
}
class MockGenerator {
+GenerateBrewery(city) BreweryResult
+GenerateUser(city, persona, name) UserResult
-DeterministicHash(location) size_t
-DeterministicHash(location, persona, name) size_t
}
class LlamaGenerator {
-ModelHandle model_
-ContextHandle context_
-unique_ptr~IPromptFormatter~ prompt_formatter_
-unique_ptr~IPromptDirectory~ prompt_directory_
-mt19937 rng_
+GenerateBrewery(...) BreweryResult
+GenerateUser(...) UserResult
-Load(model_path) void
-Infer(...) string
-InferFormatted(...) string
}
class OpenAIGenerator {
-string api_key_
-string model_
-shared_ptr~ILogger~ logger_
-unique_ptr~IPromptDirectory~ prompt_directory_
-unique_ptr~WebClient~ web_client_
+GenerateBrewery(city) BreweryResult
+GenerateUser(city, persona, name) UserResult
-CallChatCompletionsApi(...) string
}
note for OpenAIGenerator "Chat Completions with Structured Outputs (json_schema, strict) guarantees schema-valid JSON, replacing llama.cpp's GBNF grammar — no IPromptFormatter needed"
class IPromptFormatter {
<<interface>>
+Format(system_prompt, user_prompt) string
}
class Gemma4JinjaPromptFormatter {
+Format(system_prompt, user_prompt) string
}
class IPromptDirectory {
<<interface>>
+Load(key) string
}
class PromptDirectory {
-path prompt_dir_
-unordered_map~string, string~ cache_
+Load(key) string
}
class ICuratedDataService {
<<interface>>
+LoadLocations(filepath) vector~Location~
+LoadPersonas(filepath) vector~UserPersona~
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
}
class JsonLoader {
-cache cache_
+LoadLocations(filepath) vector~Location~
+LoadPersonas(filepath) vector~UserPersona~
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
}
note for JsonLoader "Each Load* method memoizes its result in cache_ after its first call"
class MockCuratedDataService {
-vector~Location~ locations_
-vector~UserPersona~ personas_
-unordered_map~string, forename_list~ forenames_by_country_
-unordered_map~string, surname_list~ surnames_by_country_
+LoadLocations(filepath) vector~Location~
+LoadPersonas(filepath) vector~UserPersona~
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
}
note for MockCuratedDataService "Fixed 4-location/3-persona dataset (US, DE, FR, BE) for --mocked runs; filepath arguments are ignored"
class IExportService {
<<interface>>
+Initialize() void
+ProcessRecord(brewery) uint64_t
+ProcessRecord(user) uint64_t
+Finalize() void
}
class SqliteExportService {
-unique_ptr~IDateTimeProvider~ date_time_provider_
-string run_timestamp_utc_
-path database_path_
-SqliteDatabaseHandle db_handle_
-SqliteStatementHandle insert_location_stmt_
-SqliteStatementHandle insert_brewery_stmt_
-SqliteStatementHandle insert_user_stmt_
-bool transaction_open_
-unordered_map~string, sqlite3_int64~ location_cache_
+Initialize() void
+ProcessRecord(brewery) uint64_t
+ProcessRecord(user) uint64_t
+Finalize() void
-InitializeSchema() void
-ResolveLocationId(location) sqlite3_int64
}
class IDateTimeProvider {
<<interface>>
+GetUtcTimestamp() string
}
class SystemDateTimeProvider {
+GetUtcTimestamp() string
}
BiergartenPipelineOrchestrator *-- ILogger : owns
BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
BiergartenPipelineOrchestrator *-- DataGenerator : owns
BiergartenPipelineOrchestrator *-- IExportService : owns
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
LogEntry *-- LogLevel
LogEntry *-- PipelinePhase
LogDTO *-- LogLevel
LogDTO *-- PipelinePhase
ILogger <|.. LogProducer : implements
LogProducer ..> LogEntry : emits
LogDispatcher ..> LogEntry : consumes
IEnrichmentService <|.. WikipediaEnrichmentService : implements
IEnrichmentService <|.. MockEnrichmentService : implements
WikipediaEnrichmentService *-- WebClient : owns
WebClient <|.. HttpWebClient : implements
DataGenerator <|.. MockGenerator : implements
DataGenerator <|.. LlamaGenerator : implements
DataGenerator <|.. OpenAIGenerator : implements
LlamaGenerator *-- IPromptFormatter : uses
LlamaGenerator *-- IPromptDirectory : uses
OpenAIGenerator *-- IPromptDirectory : uses
OpenAIGenerator *-- WebClient : uses
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
IPromptDirectory <|.. PromptDirectory : implements
ICuratedDataService <|.. JsonLoader : implements
ICuratedDataService <|.. MockCuratedDataService : implements
IExportService <|.. SqliteExportService : implements
SqliteExportService *-- IDateTimeProvider : owns
IDateTimeProvider <|.. SystemDateTimeProvider : implements

View File

@@ -0,0 +1,194 @@
flowchart TD
Start([start]) --> ParseArgs["Main: ParseArguments(argc, argv)"]
ParseArgs --> InvalidArgs{Invalid args?}
InvalidArgs -- yes --> LogErr["spdlog::error"] --> Stop1([stop])
InvalidArgs -- no --> InitOpenSSL["Main: Init OpenSSL global state and LlamaBackendState"]
InitOpenSSL --> BuildDI["Main: Build DI injector"]
BuildDI --> InitSqlite["Main: Initialize SqliteExportService<br/>opens SQLite connection; transactions now managed per-phase via batching"]
InitSqlite --> LogSetup["Main: create BoundedChannel log_ch, spawn Log Worker thread<br/>drains log_ch for the pipeline lifetime; all workers emit LogEntry via PipelineLogger, never spdlog directly"]
LogSetup --> OrchRun["BiergartenPipelineOrchestrator::Run()"]
OrchRun --> PL1["JsonLoader.LoadBeerStyles → EnrichmentService.PreWarmBeerStyleCache"]
OrchRun --> PL2["JsonLoader.LoadLocations → EnrichmentService.PreWarmLocationCache"]
OrchRun --> PL3["JsonLoader.LoadForenamesByCountry"]
OrchRun --> PL4["JsonLoader.LoadSurnamesByCountry"]
OrchRun --> PL5["JsonLoader.LoadPersonas"]
PL1 & PL2 & PL3 & PL4 & PL5 --> UserPhase["Orchestrator: RunUserPhase(sampled_locations)<br/>each Load* call memoized after first parse; MockCuratedDataService (--mocked) returns a fixed in-memory dataset instead, skipping these JSON files"]
UserPhase --> UCreate["Create BoundedChannels (loc_ch, exp_ch)"]
subgraph USERGEN["Phase 0 — User Generation (concurrent)"]
direction LR
subgraph U_PROD["Orchestrator (producer)"]
UP1["Loop: send Location → loc_ch"]
UP1 --> UP2["Close loc_ch"]
end
subgraph U_LLM["LLM Worker"]
UL1{"loc_ch has items?"}
UL1 -- yes --> UL2["Receive Location"]
UL2 --> UL3["GetLocationContextFromCache(location) — guaranteed cache hit from startup"]
UL3 --> UL4["IPersonaSelectionStrategy.SelectPersona(personas_palette_) — guaranteed cache hit; returns Persona{style_affinities, abv_range, ibu_preference, checkin_weight}"]
UL4 --> UL5["SampleName(forenames_by_country, surnames_by_country, iso3166_1, rng)<br/>deterministic, no LLM; free function; skips the city if either map has no entry for iso3166_1"]
UL5 --> UL6["GenerateUser(enriched_city, persona, sampled_name) via DataGenerator<br/>LLM receives EnrichedCity context + persona description + sampled name"]
UL6 --> UL7["PipelineLogger.Log(Info, UserGeneration, city, user_id, llm)"]
UL7 --> UL8["Send GeneratedUser → exp_ch"]
UL8 --> UL1
UL1 -- no --> UL9["Close exp_ch"]
end
subgraph U_SQL["SQLite Worker"]
US1["BEGIN TRANSACTION"] --> US2{"exp_ch has items?"}
US2 -- yes --> US3["Receive GeneratedUser"]
US3 --> US4["ProcessUser(user)"]
US4 --> US5["PipelineLogger.Log(Info, UserGeneration, city, user_id, sqlite)"]
US5 --> US6["Append → user_pool_"]
US6 --> US7{"Batch size reached?"}
US7 -- yes --> US8["COMMIT and BEGIN"] --> US2
US7 -- no --> US2
US2 -- no --> US9["COMMIT (Final)"]
end
end
UCreate --> UP1
UP2 -.->|loc_ch| UL1
UL9 -.->|exp_ch| US2
UL9 & US9 --> UserJoin["Orchestrator: Join LLM Worker, SQLite Worker"]
UserJoin --> BreweryPhase["Orchestrator: RunBreweryPhase(sampled_locations)"]
BreweryPhase --> BCreate["Create BoundedChannels (loc_ch, exp_ch)"]
subgraph BREWGEN["Phase 1a — Brewery Generation (concurrent)"]
direction LR
subgraph B_PROD["Orchestrator (producer)"]
BP1["Loop: sample User from user_pool_, pair with Location"]
BP1 --> BP2["Send BreweryTask(Location, User) → loc_ch"]
BP2 --> BP3["Close loc_ch"]
end
subgraph B_LLM["LLM Worker"]
BL1{"loc_ch has items?"}
BL1 -- yes --> BL2["Receive BreweryTask(Location, User)"]
BL2 --> BL3["GetLocationContextFromCache(task.location) — guaranteed cache hit"]
BL3 --> BL4["GenerateBrewery(enriched_city, context, task.user) via DataGenerator<br/>KV cache stays warm; brewery linked to sampled owner_user_id"]
BL4 --> BL5["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, llm)"]
BL5 --> BL6["Send GeneratedBrewery → exp_ch"]
BL6 --> BL1
BL1 -- no --> BL7["Close exp_ch"]
end
subgraph B_SQL["SQLite Worker"]
BS1["BEGIN TRANSACTION"] --> BS2{"exp_ch has items?"}
BS2 -- yes --> BS3["Receive GeneratedBrewery"]
BS3 --> BS4["ProcessBrewery(brewery)"]
BS4 --> BS5["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, sqlite)"]
BS5 --> BS6["Append → brewery_pool_"]
BS6 --> BS7{"Batch size reached?"}
BS7 -- yes --> BS8["COMMIT and BEGIN"] --> BS2
BS7 -- no --> BS2
BS2 -- no --> BS9["COMMIT (Final)"]
end
end
BCreate --> BP1
BP3 -.->|loc_ch| BL1
BL7 -.->|exp_ch| BS2
BL7 & BS9 --> BrewJoin["Orchestrator: Join LLM Worker, SQLite Worker<br/>brewery_pool_ is now fully populated"]
BrewJoin --> BeerPhase["Orchestrator: RunBeerPhase()"]
BeerPhase --> BeCreate["Create BoundedChannels (brew_ch, exp_ch)"]
subgraph BEERGEN["Phase 1b — Beer Generation (concurrent)"]
direction LR
subgraph BE_PROD["Orchestrator (producer)"]
BeP1["Loop: send Breweries → brew_ch"]
BeP1 --> BeP2["Close brew_ch"]
end
subgraph BE_LLM["LLM Worker"]
BeL1{"brew_ch has items?"}
BeL1 -- yes --> BeL2["Receive GeneratedBrewery"]
BeL2 --> BeL3["IBeerSelectionStrategy.SelectStyles(brewery, beer_style_palette_)"]
BeL3 --> BeL4{"For each selected BeerStyle"}
BeL4 -- remaining --> BeL5["GetStyleContextFromCache(style) — guaranteed cache hit; KV cache stays warm across all beer generations in this phase"]
BeL5 --> BeL6["GenerateBeer(brewery, style_context) via DataGenerator"]
BeL6 --> BeL7["Attach GeneratedBeer to bundle"] --> BeL4
BeL4 -- done --> BeL8["PipelineLogger.Log(Info, BeerGeneration, city, brewery_id, llm)"]
BeL8 --> BeL9["Send BeersBundle → exp_ch"]
BeL9 --> BeL1
BeL1 -- no --> BeL10["Close exp_ch"]
end
subgraph BE_SQL["SQLite Worker"]
BeS1["BEGIN TRANSACTION"] --> BeS2{"exp_ch has items?"}
BeS2 -- yes --> BeS3["Receive BeersBundle"]
BeS3 --> BeS4{"For each beer in bundle"}
BeS4 -- remaining --> BeS5["Set beer.brewery_id from bundle"]
BeS5 --> BeS6["ProcessBeer(beer)"]
BeS6 --> BeS7["Append → beer_pool_"] --> BeS4
BeS4 -- done --> BeS8["PipelineLogger.Log(Info, BeerGeneration, city, brewery_id, sqlite)"]
BeS8 --> BeS9{"Batch size reached?"}
BeS9 -- yes --> BeS10["COMMIT and BEGIN"] --> BeS2
BeS9 -- no --> BeS2
BeS2 -- no --> BeS11["COMMIT (Final)"]
end
end
BeCreate --> BeP1
BeP2 -.->|brew_ch| BeL1
BeL10 -.->|exp_ch| BeS2
BeL10 & BeS11 --> BeerJoin["Orchestrator: Join LLM Worker, SQLite Worker<br/>brewery_pool_ and beer_pool_ are now completely populated — Checkin and Follow phases may run in parallel"]
BeerJoin --> CheckinPhase["Orchestrator: RunCheckinPhase()"]
BeerJoin --> FollowPhase["Orchestrator: RunFollowPhase()"]
subgraph CHECKIN["Checkin Generation"]
CK1["ICheckinDistributionStrategy.AssignActivityWeights(user_pool_)<br/>weights seeded from persona.checkin_weight; J-curve profile emerges from persona distribution"]
CK1 --> CK2["BEGIN TRANSACTION"]
CK2 --> CK3{"For each GeneratedUser in user_pool_"}
CK3 -- remaining --> CK4["CheckinsForUser(user, brewery_pool_.size())"]
CK4 --> CK5{"For each checkin index"}
CK5 -- remaining --> CK6["TimestampFor(user, index)"]
CK6 --> CK7["Select brewery from brewery_pool_"]
CK7 --> CK8["GenerateCheckin(user, brewery, timestamp) via DataGenerator"]
CK8 --> CK9["ProcessCheckin(checkin)"]
CK9 --> CK10["PipelineLogger.Log(Info, CheckinGeneration, nullopt, checkin_id, sqlite)"]
CK10 --> CK11["Append → checkin_pool_"]
CK11 --> CK12{"Batch size reached?"}
CK12 -- yes --> CK13["COMMIT and BEGIN"] --> CK5
CK12 -- no --> CK5
CK5 -- done --> CK3
CK3 -- done --> CK14["COMMIT (Final)"]
end
subgraph FOLLOW["Follow Generation"]
FL1["IFollowGenerationStrategy.AssignFollowWeights(user_pool_)<br/>RandomFollowStrategy: uniform weights; ActivityWeightedFollowStrategy: derived from user.activity_weight so high-activity users attract more followers"]
FL1 --> FL2["BEGIN TRANSACTION"]
FL2 --> FL3["IFollowGenerationStrategy.GenerateFollows(user_pool_)<br/>self-follow constraint (follower_id != followed_id) enforced here and at the DB schema level"]
FL3 --> FL4{"For each GeneratedFollow"}
FL4 -- remaining --> FL5["ProcessFollow(follow)"]
FL5 --> FL6["PipelineLogger.Log(Info, FollowGeneration, nullopt, follower_id, sqlite)"]
FL6 --> FL7["Append → follow_pool_"]
FL7 --> FL8{"Batch size reached?"}
FL8 -- yes --> FL9["COMMIT and BEGIN"] --> FL4
FL8 -- no --> FL4
FL4 -- done --> FL10["COMMIT (Final)"]
end
CheckinPhase --> CK1
FollowPhase --> FL1
CK14 & FL10 --> PhaseJoin["Orchestrator: Join CheckinPhase, FollowPhase<br/>checkin_pool_ and follow_pool_ are now fully populated"]
PhaseJoin --> RatingPhase["Orchestrator: RunRatingPhase()<br/>beer selection biased by user.persona.style_affinities and abv_range; rating skew modulated per persona"]
RatingPhase --> RT1["BEGIN TRANSACTION"]
RT1 --> RT2{"For each GeneratedCheckin in checkin_pool_"}
RT2 -- remaining --> RT3["Match brewery_id, select beer from beer_pool_ (same brewery_id, biased by persona affinities)"]
RT3 --> RT4{"Beer exists for brewery?"}
RT4 -- yes --> RT5["GenerateRating(user, beer, checkin_id) via DataGenerator"]
RT5 --> RT6["ProcessRating(rating)"]
RT6 --> RT7["PipelineLogger.Log(Info, RatingGeneration, nullopt, rating_id, sqlite)"]
RT7 --> RT8{"Batch size reached?"}
RT8 -- yes --> RT9["COMMIT and BEGIN"] --> RT2
RT8 -- no --> RT2
RT4 -- no --> RT10["Log warning; skip — brewery has no beers"] --> RT2
RT2 -- done --> RT11["COMMIT (Final)"]
RT11 --> Teardown["Orchestrator: Finalize SqliteExportService — safely closes the DB connection"]
Teardown --> CloseLogCh["Orchestrator: close log_ch"]
CloseLogCh --> MainDone["Main: spdlog::info 'Pipeline complete in X ms'"]
MainDone --> JoinLog["Main: Join Log Worker — drain guarantees no LogEntry is dropped at shutdown"]
JoinLog --> Stop2([stop])

View File

@@ -0,0 +1,584 @@
classDiagram
namespace DomainModels {
class Location {
+string city
+string state_province
+string iso3166_2
+string country
+string iso3166_1
+vector~string~ local_languages
+double latitude
+double longitude
}
class LocationContext {
+string text
+Completeness completeness
+size_t char_count
}
class Completeness {
<<enumeration>>
Full
Partial
Absent
}
class EnrichedCity {
+Location location
+LocationContext context
}
class BeerStyle {
+string name
+string description
+float min_abv
+float max_abv
+int min_ibu
+int max_ibu
}
class BreweryResult {
+string name_en
+string description_en
+string name_local
+string description_local
}
class BeerResult {
+string name_en
+string description_en
+string name_local
+string description_local
+string style
+float abv
+int ibu
}
class UserResult {
+string first_name
+string last_name
+string gender
+string username
+string bio
+float activity_weight
}
class Name {
+string first_name
+string last_name
+string gender
}
class ForenameEntry {
+string name
+string gender
}
class CheckinResult {
+string checked_in_at
+string note
}
class RatingResult {
+float score
+string note
}
class GenerationMetadata {
+uint64_t generation_id
+string generated_time
+bool context_provided
+string generated_with
}
class GeneratedBrewery {
+uint64_t brewery_id
+Location location
+BreweryResult brewery
+Completeness context_completeness
+GenerationMetadata metadata
}
class GeneratedBeer {
+uint64_t beer_id
+uint64_t brewery_id
+Location location
+BeerStyle style
+BeerResult beer
+GenerationMetadata metadata
}
class GeneratedUser {
+uint64_t user_id
+Location location
+UserResult user
+string email
+string date_of_birth
+string password
+GenerationMetadata metadata
}
class GeneratedCheckin {
+uint64_t checkin_id
+uint64_t user_id
+uint64_t brewery_id
+CheckinResult checkin
+GenerationMetadata metadata
}
class GeneratedRating {
+uint64_t user_id
+uint64_t beer_id
+uint64_t checkin_id
+RatingResult rating
+GenerationMetadata metadata
}
class GeneratedFollow {
+uint64_t follower_id
+uint64_t followed_id
+GenerationMetadata metadata
}
class UserPersona {
+string name
+string description
+vector~string~ style_affinities
}
}
note for ForenameEntry "forename_list/surname_list are unordered_set aliases, keyed by ISO 3166-1 country code via ICuratedDataService. SampleName() is a free function, not a method, sourced from popular-names-by-country-dataset (CC0)."
note for GeneratedUser "email, date_of_birth, and password are generated programmatically by the orchestrator (not LLM-authored) so a downstream auth-account seeding consumer can register real accounts from this export."
ForenameEntry ..> Name : SampleName() produces
LocationContext *-- Completeness
namespace DomainApplicationConfiguration {
class SamplingOptions {
+float temperature
+float top_p
+uint32_t top_k
+uint32_t n_ctx
+int seed
}
class GeneratorOptions {
+path model_path
+bool use_mocked
+optional~SamplingOptions~ sampling
}
class PipelineOptions {
+path output_path
+path log_path
}
class ApplicationOptions {
+GeneratorOptions generator
+PipelineOptions pipeline
}
}
ApplicationOptions *-- GeneratorOptions
ApplicationOptions *-- PipelineOptions
GeneratorOptions o-- SamplingOptions
namespace DomainPolicy {
class ContextStrategy {
<<interface>>
+QueriesFor(loc) vector~string~
+MaxContextChars() size_t
}
class BreweryContextStrategy {
+QueriesFor(loc) vector~string~
+MaxContextChars() size_t
}
class BeerContextStrategy {
+QueriesFor(loc) vector~string~
+MaxContextChars() size_t
}
class SamplingStrategy {
<<interface>>
+Sample(locations) vector~Location~
}
class UniformSamplingStrategy {
-size_t sample_size_
+Sample(locations) vector~Location~
}
class BeerSelectionStrategy {
<<interface>>
+SelectStyles(brewery, palette) vector~BeerStyle~
}
class RandomBeerSelectionStrategy {
-mt19937 rng_
-size_t min_beers_
-size_t max_beers_
+SelectStyles(brewery, palette) vector~BeerStyle~
}
class CheckinDistributionStrategy {
<<interface>>
+AssignActivityWeights(users) void
+CheckinsForUser(user, brewery_count) size_t
+TimestampFor(user, index) string
}
class JCurveCheckinStrategy {
-mt19937 rng_
+AssignActivityWeights(users) void
+CheckinsForUser(user, brewery_count) size_t
+TimestampFor(user, index) string
}
class RandomCheckinStrategy {
-mt19937 rng_
-size_t min_checkins_
-size_t max_checkins_
+AssignActivityWeights(users) void
+CheckinsForUser(user, brewery_count) size_t
+TimestampFor(user, index) string
}
class FollowGenerationStrategy {
<<interface>>
+GenerateFollows(users) vector~GeneratedFollow~
}
class RandomFollowStrategy {
-mt19937 rng_
-size_t min_follows_
-size_t max_follows_
+GenerateFollows(users) vector~GeneratedFollow~
}
class ActivityWeightedFollowStrategy {
-mt19937 rng_
-size_t min_follows_
-size_t max_follows_
+GenerateFollows(users) vector~GeneratedFollow~
}
}
namespace InfrastructureLogging {
class LogLevel {
<<enumeration>>
Debug
Info
Warn
Error
}
class PipelinePhase {
<<enumeration>>
Startup
UserGeneration
BreweryAndBeerGeneration
CheckinGeneration
RatingGeneration
FollowGeneration
Teardown
}
class LogEntry {
+time_point timestamp
+LogLevel level
+PipelinePhase phase
+string message
+optional~string~ worker
}
class ILogger {
<<interface>>
+Log(entry) void
}
class LogProducer {
-BoundedChannel~LogEntry~ channel_
+Log(entry) void
}
class LogDispatcher {
-BoundedChannel~LogEntry~ channel_
+Run() void
-ToSpdlogLevel(level) level_enum
}
}
LogEntry *-- LogLevel
LogEntry *-- PipelinePhase
ILogger <|.. LogProducer
LogProducer ..> LogEntry : emits
LogDispatcher ..> LogEntry : consumes
namespace InfrastructurePipelineChannel {
class BoundedChannel~T~ {
-queue~T~ queue_
-mutex mutex_
-condition_variable not_full_
-condition_variable not_empty_
-size_t capacity_
-bool closed_
+Send(item) void
+Receive() optional~T~
+Close() void
}
}
namespace InfrastructureDataPreloading {
class ICuratedDataService {
<<interface>>
+LoadLocations(filepath) vector~Location~
+LoadBeerStyles(filepath) vector~BeerStyle~
+LoadPersonas(filepath) vector~UserPersona~
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
}
class JsonLoader {
+LoadLocations(filepath) vector~Location~
+LoadBeerStyles(filepath) vector~BeerStyle~
+LoadPersonas(filepath) vector~UserPersona~
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
}
class MockCuratedDataService {
+LoadLocations(filepath) vector~Location~
+LoadBeerStyles(filepath) vector~BeerStyle~
+LoadPersonas(filepath) vector~UserPersona~
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
}
}
note for JsonLoader "Each Load* call is memoized after its first parse — implemented today, ahead of LoadBeerStyles which is still planned."
note for MockCuratedDataService "Fixed in-memory dataset for --mocked mode, implemented today for Locations/Personas/Forenames/Surnames; would need a LoadBeerStyles fixture too once that method is implemented."
namespace InfrastructureEnrichment {
class EnrichmentService {
<<interface>>
+GetLocationContext(loc, strategy) LocationContext
}
class WikipediaService {
-unique_ptr~WebClient~ client_
-unordered_map~string, string~ extract_cache_
+GetLocationContext(loc, strategy) LocationContext
-FetchExtract(query) string
}
class WebClient {
<<interface>>
+Get(url) string
+UrlEncode(value) string
}
class HttpWebClient {
+Get(url) string
+UrlEncode(value) string
}
}
namespace InfrastructurePrompting {
class IPromptDirectory {
<<interface>>
+Load(key) string
}
class PromptDirectory {
-path prompt_dir_
-unordered_map~string, string~ cache_
+PromptDirectory(prompt_dir)
+Load(key) string
}
}
IPromptDirectory <|.. PromptDirectory
namespace InfrastructureDataGeneration {
class DataGenerator {
<<interface>>
+GenerateBrewery(location, context) BreweryResult
+GenerateBeer(brewery_id, location, context, style) BeerResult
+GenerateUser(city, persona, name) UserResult
+GenerateCheckin(user, brewery, timestamp) CheckinResult
+GenerateRating(user, beer, checkin_id) RatingResult
}
class MockGenerator {
+GenerateBrewery(...) BreweryResult
+GenerateBeer(...) BeerResult
+GenerateUser(...) UserResult
+GenerateCheckin(...) CheckinResult
+GenerateRating(...) RatingResult
-DeterministicHash(location) size_t
}
class LlamaGenerator {
-ModelHandle model_
-ContextHandle context_
-unique_ptr~PromptFormatter~ prompt_formatter_
-unique_ptr~IPromptDirectory~ prompt_directory_
-mt19937 rng_
+GenerateBrewery(...) BreweryResult
+GenerateBeer(...) BeerResult
+GenerateUser(...) UserResult
+GenerateCheckin(...) CheckinResult
+GenerateRating(...) RatingResult
-Load(opts) void
-Infer(system_prompt, user_prompt, max_tokens, grammar) string
-ValidateModelArchitecture() void
}
class PromptFormatter {
<<interface>>
+Format(system_prompt, user_prompt) string
+ExpectedArchitecture() string_view
}
class Gemma4JinjaPromptFormatter {
+Format(system_prompt, user_prompt) string
+ExpectedArchitecture() string_view
}
}
namespace InfrastructureDataExport {
class ExportService {
<<interface>>
+Initialize() void
+ProcessBrewery(brewery) uint64_t
+ProcessBeer(beer) uint64_t
+ProcessUser(user) uint64_t
+ProcessCheckin(checkin) uint64_t
+ProcessRating(rating) void
+ProcessFollow(follow) void
+Finalize() void
}
class SqliteExportService {
-unique_ptr~DateTimeProvider~ date_time_provider_
-SqliteDatabaseHandle db_handle_
-SqliteStatementHandle insert_location_stmt_
-SqliteStatementHandle insert_brewery_stmt_
-SqliteStatementHandle insert_beer_stmt_
-SqliteStatementHandle insert_user_stmt_
-SqliteStatementHandle insert_checkin_stmt_
-SqliteStatementHandle insert_rating_stmt_
-SqliteStatementHandle insert_follow_stmt_
-bool transaction_open_
-unordered_map~string, uint64_t~ location_cache_
-unordered_map~string, uint64_t~ brewery_cache_
+Initialize() void
+ProcessRecord(brewery) uint64_t
+ProcessRecord(beer) uint64_t
+ProcessRecord(user) uint64_t
+ProcessRecord(checkin) uint64_t
+ProcessRecord(rating) void
+ProcessRecord(follow) void
+Finalize() void
-InitializeSchema() void
-PrepareStatements() void
-RollbackAndCloseNoThrow() void
-FinalizeStatements() void
}
class DateTimeProvider {
<<interface>>
+GetUtcTimestamp() string
}
class SystemDateTimeProvider {
+GetUtcTimestamp() string
}
}
class BiergartenPipelineOrchestrator {
-unique_ptr~ICuratedDataService~ curated_data_service_
-unique_ptr~EnrichmentService~ enrichment_service_
-unique_ptr~DataGenerator~ generator_
-unique_ptr~Logger~ logger_
-unique_ptr~ExportService~ exporter_
-unique_ptr~ContextStrategy~ brewery_context_strategy_
-unique_ptr~SamplingStrategy~ sampling_strategy_
-unique_ptr~BeerSelectionStrategy~ beer_selection_strategy_
-unique_ptr~CheckinDistributionStrategy~ checkin_strategy_
-unique_ptr~FollowGenerationStrategy~ follow_strategy_
-vector~BeerStyle~ beer_style_palette_
-ApplicationOptions options_
-vector~GeneratedUser~ user_pool_
-vector~GeneratedBrewery~ brewery_pool_
-vector~GeneratedBeer~ beer_pool_
-vector~GeneratedCheckin~ checkin_pool_
-vector~GeneratedFollow~ follow_pool_
+Run() bool
-RunUserPhase(locations) void
-RunBreweryAndBeerPhase(locations) void
-RunCheckinPhase() void
-RunRatingPhase() void
-RunFollowPhase() void
}
BiergartenPipelineOrchestrator *-- ICuratedDataService
BiergartenPipelineOrchestrator *-- EnrichmentService
BiergartenPipelineOrchestrator *-- DataGenerator
BiergartenPipelineOrchestrator *-- ExportService
BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy
BiergartenPipelineOrchestrator *-- FollowGenerationStrategy
BiergartenPipelineOrchestrator *-- SamplingStrategy
BiergartenPipelineOrchestrator *-- BeerSelectionStrategy
BiergartenPipelineOrchestrator *-- ApplicationOptions
BiergartenPipelineOrchestrator *-- Logger
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedUser : user_pool_
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedBrewery : brewery_pool_
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedBeer : beer_pool_
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedCheckin : checkin_pool_
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedFollow : follow_pool_
ICuratedDataService <|.. JsonLoader
ICuratedDataService <|.. MockCuratedDataService
Logger <|.. PipelineLogger
ContextStrategy <|.. BreweryContextStrategy
ContextStrategy <|.. BeerContextStrategy
SamplingStrategy <|.. UniformSamplingStrategy
BeerSelectionStrategy <|.. RandomBeerSelectionStrategy
CheckinDistributionStrategy <|.. JCurveCheckinStrategy
CheckinDistributionStrategy <|.. RandomCheckinStrategy
FollowGenerationStrategy <|.. RandomFollowStrategy
FollowGenerationStrategy <|.. ActivityWeightedFollowStrategy
EnrichmentService <|.. WikipediaService
WebClient <|.. HttpWebClient
DataGenerator <|.. MockGenerator
DataGenerator <|.. LlamaGenerator
PromptFormatter <|.. Gemma4JinjaPromptFormatter
ExportService <|.. SqliteExportService
DateTimeProvider <|.. SystemDateTimeProvider
WikipediaService *-- WebClient
WikipediaService ..> ContextStrategy
LlamaGenerator *-- PromptFormatter
LlamaGenerator *-- IPromptDirectory
LlamaGenerator ..> GeneratorOptions
SqliteExportService *-- DateTimeProvider
PipelineLogger o-- BoundedChannel : logs to
LogWorker o-- BoundedChannel : drains from
EnrichedCity *-- Location
EnrichedCity *-- LocationContext
GeneratedBrewery *-- Location
GeneratedBrewery *-- BreweryResult
GeneratedBrewery *-- GenerationMetadata
GeneratedBeer *-- Location
GeneratedBeer *-- BeerStyle
GeneratedBeer *-- BeerResult
GeneratedBeer *-- GenerationMetadata
GeneratedUser *-- Location
GeneratedUser *-- UserResult
GeneratedUser *-- GenerationMetadata
GeneratedCheckin *-- CheckinResult
GeneratedCheckin *-- GenerationMetadata
GeneratedRating *-- RatingResult
GeneratedRating *-- GenerationMetadata
GeneratedFollow *-- GenerationMetadata

View File

@@ -0,0 +1,59 @@
---
config:
flowchart:
curve: stepAfter
layout: elk
---
flowchart TB
API["API.Core<br/>(ASP.NET Core Web API)"]
Seed["Database.Seed<br/>(console app)"]
Migrations["Database.Migrations<br/>(console app)"]
subgraph SLICES["Feature Slices"]
direction LR
UsersSlice["Features.Users<br/>(controller)"]
BrewerySlice["Features.Breweries<br/>(controller)"]
PhotoSlice["Features.PhotoUpload<br/>(MediatR only)"]
EmailsSlice["Features.Emails<br/>(MediatR only)"]
LocationsSlice["Features.Locations<br/>(plain DI service)"]
BeersSlice["Features.Beers<br/>(scaffolded, no code yet)"]
end
API -->|AddApplicationPart + MediatR scan| UsersSlice
API -->|AddApplicationPart + MediatR scan| BrewerySlice
API -->|MediatR scan only| PhotoSlice
API -->|MediatR scan only| EmailsSlice
Seed -->|AddFeaturesUsers| UsersSlice
Seed -->|AddFeaturesBreweries| BrewerySlice
Seed -->|AddFeaturesPhotoUpload| PhotoSlice
Seed -->|AddFeaturesLocations| LocationsSlice

View File

@@ -0,0 +1,77 @@
---
config:
flowchart: {}
layout: fixed
---
flowchart TB
subgraph SLICES["Feature Slices"]
direction LR
BrewerySlice["Features.Breweries"]
LocationsSlice["Features.Locations"]
BeersSlice["Features.Beers<br>(scaffolded)"]
PhotoSlice["Features.PhotoUpload"]
UsersSlice["Features.Users"]
EmailsSlice["Features.Emails"]
end
subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"]
direction LR
SharedContracts["Shared.Contracts"]
Domain["Domain.Entities"]
DBConn["Database.Connection"]
end
subgraph INFRA["Slice-specific Infrastructure"]
direction LR
JWT["Infrastructure.Jwt"]
PwdHash["Infrastructure.PasswordHashing"]
FileUpload["Infrastructure.FileUpload"]
Email["Infrastructure.Email"]
EmailTemplates["Infrastructure.Email.Templates"]
end
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
UsersSlice -. sends email commands .-> SharedApp["Shared.Application<br>(email command contract)"]
EmailsSlice -. handles email commands .-> SharedApp
BrewerySlice --> FOUNDATION
LocationsSlice --> FOUNDATION
BeersSlice --> FOUNDATION
PhotoSlice --> FOUNDATION & FileUpload
UsersSlice --> FOUNDATION & JWT & PwdHash
EmailsSlice --> Email & EmailTemplates
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]

View File

@@ -0,0 +1,81 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant ConfirmHandler as ConfirmUserHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box rgb(235,236,227) Infrastructure Layer
participant JWT as JWT Infrastructure
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/confirm?token={token} (requires Bearer access token)
activate API
API->>Mediator: Send(ConfirmUserCommand(token))
activate Mediator
Mediator->>ConfirmHandler: Handle(command)
activate ConfirmHandler
ConfirmHandler->>TokenSvc: ValidateConfirmationTokenAsync(token)
activate TokenSvc
TokenSvc->>JWT: Validate against confirmation token secret, extract userId
activate JWT
JWT-->>TokenSvc: ValidatedToken or throws
deactivate JWT
TokenSvc-->>ConfirmHandler: ValidatedToken
deactivate TokenSvc
alt Token invalid or expired
ConfirmHandler->>Mediator: throw UnauthorizedException
Mediator->>API: propagate
API->>User: 401 Unauthorized
else Token valid
ConfirmHandler->>UserMgr: FindByIdAsync(validatedToken.UserId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>ConfirmHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ConfirmHandler->>Mediator: throw UnauthorizedException "User account not found"
Mediator->>API: propagate
API->>User: 401 Unauthorized
else User found
ConfirmHandler->>UserStore: SetEmailConfirmedAsync(user, true)
Note right of UserStore: IUserEmailStore(ApplicationUser) is injected directly into the handler (not via UserManager), setting the confirmed flag in-memory here
ConfirmHandler->>UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr->>UserStore: UpdateAsync(user)
activate UserStore
UserStore->>DB: UPDATE UserAccount SET ...
UserStore->>DB: INSERT INTO UserVerification (UserAccountId, VerificationDateTime)
Note right of UserStore: Idempotent — skipped (duplicate-key exception swallowed) if a verification row already exists
UserStore-->>UserMgr: IdentityResult.Success
deactivate UserStore
UserMgr-->>ConfirmHandler: IdentityResult
deactivate UserMgr
ConfirmHandler-->>Mediator: ConfirmationPayload(user.Id, DateTime.UtcNow)
deactivate ConfirmHandler
Mediator-->>API: ConfirmationPayload
deactivate Mediator
API->>User: 200 OK {message: "User with ID {id} is confirmed.", payload: {...}}
end
end
deactivate API

View File

@@ -0,0 +1,67 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant DeleteAccountHandler
participant UserMgr as UserManager(ApplicationUser)
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: DELETE /api/auth/account (requires Bearer access token)
activate API
API->>Mediator: Send(DeleteAccountCommand(GetAuthenticatedUserId()))
activate Mediator
Mediator->>DeleteAccountHandler: Handle(command)
activate DeleteAccountHandler
DeleteAccountHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>DeleteAccountHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
DeleteAccountHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
DeleteAccountHandler->>UserMgr: DeleteAsync(user)
activate UserMgr
UserMgr->>UserStore: DeleteAsync(user)
activate UserStore
UserStore->>DB: DELETE FROM UserAccount WHERE UserAccountID = @Id
Note right of UserStore: UserCredential/UserVerification/UserAvatar cascade on delete. Posts, comments, photos, and follows use ON DELETE NO ACTION — if any reference this account, the DELETE throws a foreign key violation (SQL error 547)
alt Foreign key violation (dependent records exist)
UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords)
else Delete succeeds
UserStore-->>UserMgr: IdentityResult.Success
end
deactivate UserStore
UserMgr-->>DeleteAccountHandler: IdentityResult
deactivate UserMgr
alt Account has dependent records
DeleteAccountHandler->>Mediator: throw ConflictException "Account cannot be deleted while it still has associated posts, comments, photos, or follows."
Mediator->>API: propagate
API->>User: 409 Conflict
else !result.Succeeded (other failure)
DeleteAccountHandler->>Mediator: throw ConflictException
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
DeleteAccountHandler-->>Mediator: (void)
deactivate DeleteAccountHandler
Mediator-->>API: (void)
deactivate Mediator
API->>User: 200 OK {message: "Account deleted successfully."}
end
end
deactivate API

View File

@@ -0,0 +1,12 @@
sequenceDiagram
participant API as API Controller
Note over API: GlobalExceptionFilter catches:
Note over API: FluentValidation.ValidationException → 400 Bad Request {message, errors} (grouped by property)
Note over API: ConflictException → 409 Conflict
Note over API: NotFoundException → 404 Not Found
Note over API: UnauthorizedException → 401 Unauthorized
Note over API: ForbiddenException → 403 Forbidden
Note over API: Domain.Exceptions.ValidationException → 400 Bad Request
Note over API: Microsoft.Data.SqlClient.SqlException → 503 Service Unavailable
Note over API: All others → 500 Internal Server Error

View File

@@ -0,0 +1,95 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant LoginHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box rgb(235,236,227) Infrastructure Layer
participant Argon2 as Argon2PasswordHasher
participant JWT as JWT Infrastructure
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/login {username, password}
activate API
API->>Mediator: Send(LoginCommand)
activate Mediator
Note right of Mediator: ValidationBehavior runs LoginValidator — username and password must both be non-empty
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request {message, errors}
else Validation succeeds
Mediator->>LoginHandler: Handle(command)
activate LoginHandler
LoginHandler->>UserMgr: FindByNameAsync(username)
activate UserMgr
UserMgr->>UserStore: FindByNameAsync(username)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
activate DB
DB-->>UserStore: UserAccount row or null
deactivate DB
UserStore->>UserStore: Hydrate (active UserCredential.Hash, UserVerification → EmailConfirmed)
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>LoginHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
LoginHandler->>Mediator: throw UnauthorizedException "Invalid username or password."
Mediator->>API: propagate
API->>User: 401 Unauthorized
else User found
LoginHandler->>UserMgr: CheckPasswordAsync(user, password)
activate UserMgr
Note right of UserMgr: HasPasswordAsync short-circuits to false (no active credential hydrated) without calling the hasher — otherwise delegates to Argon2PasswordHasher
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, password)
activate Argon2
Note right of Argon2: Split stored "salt:hash", rehash password with same salt, constant-time compare (CryptographicOperations.FixedTimeEquals)
Argon2-->>UserMgr: Success/Failed
deactivate Argon2
UserMgr-->>LoginHandler: true/false
deactivate UserMgr
alt Password invalid
LoginHandler->>Mediator: throw UnauthorizedException
Mediator->>API: propagate
API->>User: 401 Unauthorized
else Password valid
LoginHandler->>TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(...)
activate JWT
JWT-->>TokenSvc: Access Token
deactivate JWT
TokenSvc-->>LoginHandler: Access Token
deactivate TokenSvc
LoginHandler->>TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(...)
activate JWT
JWT-->>TokenSvc: Refresh Token
deactivate JWT
TokenSvc-->>LoginHandler: Refresh Token
deactivate TokenSvc
LoginHandler-->>Mediator: LoginPayload(userAccountId, username, refreshToken, accessToken)
deactivate LoginHandler
Mediator-->>API: LoginPayload
deactivate Mediator
API->>User: 200 OK {message: "Logged in successfully.", payload: {...}}
end
end
end
deactivate API

View File

@@ -0,0 +1,79 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant RefreshHandler as RefreshTokenHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box rgb(235,236,227) Infrastructure Layer
participant JWT as JWT Infrastructure
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/refresh<br/>Header: X-Refresh-Token: {token}
activate API
API->>Mediator: Send(RefreshTokenCommand(refreshToken))
activate Mediator
Note right of Mediator: ValidationBehavior runs RefreshTokenValidator — refreshToken must be non-empty. Anonymous endpoint, no Bearer token required.
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request {message, errors}
else Validation succeeds
Mediator->>RefreshHandler: Handle(command)
activate RefreshHandler
RefreshHandler->>TokenSvc: RefreshTokenAsync(refreshToken)
activate TokenSvc
TokenSvc->>JWT: ValidateRefreshTokenAsync(refreshToken)
activate JWT
Note right of JWT: Validates signature/expiry against the refresh token secret, extracts userId/username claims
JWT-->>TokenSvc: ValidatedToken or throws
deactivate JWT
alt Token invalid or expired
TokenSvc->>RefreshHandler: throw UnauthorizedException
RefreshHandler->>Mediator: propagate
Mediator->>API: propagate
API->>User: 401 Unauthorized
else Token valid
TokenSvc->>UserMgr: FindByIdAsync(validated.UserId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>TokenSvc: ApplicationUser or null
deactivate UserMgr
alt User not found
TokenSvc->>RefreshHandler: throw UnauthorizedException "User account not found"
RefreshHandler->>Mediator: propagate
Mediator->>API: propagate
API->>User: 401 Unauthorized
else User found
TokenSvc->>JWT: GenerateAccessToken(user) + GenerateRefreshToken(user)
JWT-->>TokenSvc: new access + refresh tokens
TokenSvc-->>RefreshHandler: RefreshTokenResult
deactivate TokenSvc
RefreshHandler-->>Mediator: LoginPayload(userId, username, refreshToken, accessToken)
deactivate RefreshHandler
Mediator-->>API: LoginPayload
deactivate Mediator
API->>User: 200 OK {message: "Token refreshed successfully.", payload: {...}}
end
end
end
deactivate API

View File

@@ -0,0 +1,153 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant RegHandler as RegisterUserHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box rgb(235,236,227) Infrastructure Layer
participant Argon2 as Argon2PasswordHasher
participant JWT as JWT Infrastructure
participant SMTP as Email Provider
participant Template as Template Provider
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/register {username, firstName, lastName, email, dateOfBirth, password}
activate API
API->>Mediator: Send(RegisterUserCommand)
activate Mediator
Note right of Mediator: ValidationBehavior runs RegisterUserValidator — username 3-64 chars alphanumeric+[._-], valid email ≤128 chars, password min 8 chars with upper/lower/number/special, DateOfBirth 19+ years old
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request {message, errors}
else Validation succeeds
Mediator->>RegHandler: Handle(command)
activate RegHandler
RegHandler->>RegHandler: user = new UserAccount{...}.ToApplicationUser()
RegHandler->>UserMgr: CreateAsync(user, password)
activate UserMgr
UserMgr->>Argon2: HashPassword(user, password)
activate Argon2
Note right of Argon2: Argon2id — salt 16 bytes, memory 64MB, iterations 4, parallelism = CPU count, hash output 32 bytes
Argon2-->>UserMgr: "base64(salt):base64(hash)"
deactivate Argon2
UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
Note right of UserStore: user.Id is still empty here — only sets the in-memory hash — CreateAsync writes it to UserCredential itself
UserMgr->>UserStore: FindByNameAsync(username)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
activate DB
DB-->>UserStore: null (user doesn't exist)
deactivate DB
deactivate UserStore
UserMgr->>UserStore: FindByEmailAsync(email)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE Email = @Email
activate DB
DB-->>UserStore: null (email doesn't exist)
deactivate DB
deactivate UserStore
Note right of UserMgr: Default UserManager validators check uniqueness before CreateAsync — DapperUserStore.CreateAsync also guards a genuine race via a caught duplicate-key SqlException (2601/2627)
UserMgr->>UserStore: CreateAsync(user)
activate UserStore
UserStore->>DB: BEGIN TRANSACTION
activate DB
UserStore->>DB: INSERT INTO UserAccount (...) OUTPUT INSERTED.UserAccountID
DB-->>UserStore: UserAccountId (GUID)
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
UserStore->>DB: COMMIT TRANSACTION
deactivate DB
Note right of UserStore: Both statements run via Dapper against a single DbTransaction, not a stored procedure
UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
deactivate UserStore
UserMgr-->>RegHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
RegHandler->>Mediator: throw ConflictException (DuplicateUserName/DuplicateEmail → "Username or email already exists", otherwise joined error descriptions)
Mediator->>API: propagate
API->>User: 409 Conflict "Username or email already exists"
else result.Succeeded
RegHandler->>TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
activate JWT
Note right of JWT: HS256, expires 1 hour, claims sub/unique_name/iat/jti
JWT->>JWT: Create JWT with claims, sign with secret key
JWT-->>TokenSvc: Access Token
deactivate JWT
TokenSvc-->>RegHandler: Access Token
deactivate TokenSvc
RegHandler->>TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
activate JWT
Note right of JWT: Expires 21 days (504h), same structure as access token, signed with a separate secret
JWT-->>TokenSvc: Refresh Token
deactivate JWT
TokenSvc-->>RegHandler: Refresh Token
deactivate TokenSvc
RegHandler->>TokenSvc: GenerateConfirmationToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
activate JWT
Note right of JWT: Expires 30 minutes, signed with a third, separate secret
JWT-->>TokenSvc: Confirmation Token
deactivate JWT
TokenSvc-->>RegHandler: Confirmation Token
deactivate TokenSvc
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
activate Mediator
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the only cross-slice call in the backend
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher
EmailDispatcher->>Template: RenderUserRegisteredEmailAsync(firstName, confirmationLink)
activate Template
Note right of Template: Razor component — header/branding, welcome message, confirmation button, footer
Template->>Template: Render Razor component to HTML
Template-->>EmailDispatcher: HTML email content
deactivate Template
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
activate SMTP
Note right of SMTP: MailKit — host/port from SMTP_HOST/SMTP_PORT, StartTLS when SMTP_USE_SSL=true, optional username/password auth
SMTP->>SMTP: Build MIME message, connect, authenticate, send, disconnect
SMTP-->>EmailDispatcher: Success / throws
deactivate SMTP
deactivate EmailDispatcher
deactivate Mediator
alt Email sent successfully
RegHandler->>RegHandler: emailSent = true
else mediator.Send throws
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false
end
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
deactivate RegHandler
Mediator-->>API: RegistrationPayload
deactivate Mediator
API->>User: 201 Created {message: "User registered successfully.", payload: {...}}
end
end
deactivate API

View File

@@ -0,0 +1,73 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant ResendHandler as ResendConfirmationEmailHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box rgb(235,236,227) Infrastructure Layer
participant SMTP as Email Provider
participant Template as Template Provider
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/confirm/resend?userId={id} (requires Bearer access token)
activate API
API->>Mediator: Send(ResendConfirmationEmailCommand(userId))
activate Mediator
Mediator->>ResendHandler: Handle(command)
activate ResendHandler
ResendHandler->>UserMgr: FindByIdAsync(userId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>ResendHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ResendHandler->>ResendHandler: return silently (prevents user enumeration)
else User found
ResendHandler->>UserMgr: IsEmailConfirmedAsync(user)
UserMgr-->>ResendHandler: bool
alt Already confirmed
ResendHandler->>ResendHandler: return silently (no-op)
else Not yet confirmed
ResendHandler->>TokenSvc: GenerateConfirmationToken(user)
activate TokenSvc
TokenSvc-->>ResendHandler: confirmation token
deactivate TokenSvc
ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand)
activate Mediator
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher
EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher
deactivate Mediator
end
end
ResendHandler-->>Mediator: (void)
deactivate ResendHandler
Mediator-->>API: (void)
deactivate Mediator
API->>User: 200 OK {message: "confirmation email has been resent"}
Note right of API: Always 200 OK regardless of outcome above — same user-enumeration precaution as a not-found user or an already-confirmed account
deactivate API

View File

@@ -0,0 +1,66 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant UpdateEmailHandler
participant UserMgr as UserManager(ApplicationUser)
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/email {newEmail} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdateEmailCommand(GetAuthenticatedUserId(), newEmail))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdateEmailValidator (same format rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdateEmailHandler: Handle(command)
activate UpdateEmailHandler
UpdateEmailHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore->>DB: SELECT ...
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>UpdateEmailHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateEmailHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdateEmailHandler->>UserMgr: SetEmailAsync(user, newEmail)
activate UserMgr
UserMgr->>UserStore: FindByEmailAsync(newEmail)
Note right of UserMgr: Uniqueness check. Also resets user.EmailConfirmed = false — caller must re-confirm via POST /api/auth/confirm/resend
UserMgr->>UserStore: UpdateAsync(user)
UserStore->>DB: UPDATE UserAccount SET Email = @Email, ...
UserStore->>DB: DELETE FROM UserVerification WHERE UserAccountID = @Id
UserStore-->>UserMgr: IdentityResult
UserMgr-->>UpdateEmailHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateEmailHandler->>Mediator: throw ConflictException (DuplicateEmail → "Email address already exists")
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdateEmailHandler-->>Mediator: UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed = false)
deactivate UpdateEmailHandler
Mediator-->>API: UpdateEmailPayload
deactivate Mediator
API->>User: 200 OK {message: "Email updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

@@ -0,0 +1,86 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant UpdatePasswordHandler
participant UserMgr as UserManager(ApplicationUser)
end
box rgb(235,236,227) Infrastructure Layer
participant Argon2 as Argon2PasswordHasher
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/password {currentPassword, newPassword} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdatePasswordCommand(GetAuthenticatedUserId(), currentPassword, newPassword))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdatePasswordValidator (same password strength rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdatePasswordHandler: Handle(command)
activate UpdatePasswordHandler
UpdatePasswordHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>UpdatePasswordHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdatePasswordHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdatePasswordHandler->>UserMgr: ChangePasswordAsync(user, currentPassword, newPassword)
activate UserMgr
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, currentPassword)
activate Argon2
Argon2-->>UserMgr: Success/Failed
deactivate Argon2
alt Current password mismatch
UserMgr-->>UpdatePasswordHandler: IdentityResult.Failed (PasswordMismatch)
else Current password matches
UserMgr->>Argon2: HashPassword(user, newPassword)
activate Argon2
Argon2-->>UserMgr: new hash
deactivate Argon2
UserMgr->>UserStore: SetPasswordHashAsync(user, newHash)
activate UserStore
UserStore->>DB: UPDATE UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @Id AND IsRevoked = 0
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
Note right of UserStore: Revoke-then-insert runs inside one DbTransaction (credential rotation)
UserStore-->>UserMgr: (void)
deactivate UserStore
UserMgr-->>UpdatePasswordHandler: IdentityResult.Success
end
deactivate UserMgr
alt Current password is incorrect
UpdatePasswordHandler->>Mediator: throw UnauthorizedException "Current password is incorrect"
Mediator->>API: propagate
API->>User: 401 Unauthorized
else !result.Succeeded (other failure)
UpdatePasswordHandler->>Mediator: throw ConflictException
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdatePasswordHandler-->>Mediator: UpdatePasswordPayload(user.Id, DateTime.UtcNow)
deactivate UpdatePasswordHandler
Mediator-->>API: UpdatePasswordPayload
deactivate Mediator
API->>User: 200 OK {message: "Password updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

@@ -0,0 +1,65 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant UpdateProfileHandler
participant UserMgr as UserManager(ApplicationUser)
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/profile {firstName, lastName, dateOfBirth} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdateProfileCommand(GetAuthenticatedUserId(), firstName, lastName, dateOfBirth))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdateProfileValidator (same name/DateOfBirth rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdateProfileHandler: Handle(command)
activate UpdateProfileHandler
UpdateProfileHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>UpdateProfileHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateProfileHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdateProfileHandler->>UpdateProfileHandler: user.FirstName = ...<br/>user.LastName = ...<br/>user.DateOfBirth = ...
Note right of UpdateProfileHandler: First/last name and date of birth aren't tracked by any Identity store interface — mutated directly on the ApplicationUser, then saved via UserManager.UpdateAsync
UpdateProfileHandler->>UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr->>UserStore: UpdateAsync(user)
UserStore->>DB: UPDATE UserAccount SET FirstName = @FirstName, LastName = @LastName, DateOfBirth = @DateOfBirth, ...
UserStore-->>UserMgr: IdentityResult
UserMgr-->>UpdateProfileHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateProfileHandler->>Mediator: throw ConflictException
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdateProfileHandler-->>Mediator: UpdateProfilePayload(user.Id, firstName, lastName, dateOfBirth)
deactivate UpdateProfileHandler
Mediator-->>API: UpdateProfilePayload
deactivate Mediator
API->>User: 200 OK {message: "Profile updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

@@ -0,0 +1,70 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box rgb(241,243,234) Handler Layer
participant UpdateUsernameHandler
participant UserMgr as UserManager(ApplicationUser)
end
box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/username {newUsername} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdateUsernameCommand(GetAuthenticatedUserId(), newUsername))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdateUsernameValidator (same format rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdateUsernameHandler: Handle(command)
activate UpdateUsernameHandler
UpdateUsernameHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>UpdateUsernameHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateUsernameHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdateUsernameHandler->>UserMgr: SetUserNameAsync(user, newUsername)
activate UserMgr
UserMgr->>UserStore: FindByNameAsync(newUsername)
Note right of UserMgr: Uniqueness check before the UPDATE
UserMgr->>UserStore: UpdateAsync(user)
UserStore->>DB: UPDATE UserAccount SET Username = @UserName, ...
UserStore-->>UserMgr: IdentityResult
UserMgr-->>UpdateUsernameHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateUsernameHandler->>Mediator: throw ConflictException (DuplicateUserName → "Username already exists")
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdateUsernameHandler-->>Mediator: UpdateUsernamePayload(user.Id, user.UserName)
deactivate UpdateUsernameHandler
Mediator-->>API: UpdateUsernamePayload
deactivate Mediator
API->>User: 200 OK {message: "Username updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

@@ -0,0 +1,162 @@
erDiagram
User ||--o{ Cred : "has credentials"
User ||--o| Verify : "has verification"
User ||--o| Profile : "has profile"
Profile ||--o{ Avatar : "has avatar history"
Avatar }o--|| Photo : "uses photo"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
User ||--o{ Photo : "uploads"
Country ||--o{ StateProvince : "has"
StateProvince ||--o{ City : "has"
User ||--o{ Brewery : "posts"
Brewery ||--o| BreweryLoc : "has location"
City ||--o{ BreweryLoc : "located in"
Brewery ||--o{ BreweryPhoto : "has photos"
Photo ||--o{ BreweryPhoto : "used in"
User ||--o{ Beer : "posts"
BeerStyle ||--o{ Beer : "categorizes"
Brewery ||--o{ Beer : "brews"
Beer ||--o{ BeerPhoto : "has photos"
Photo ||--o{ BeerPhoto : "used in"
Beer ||--o{ BeerComment : "has comments"
User ||--o{ BeerComment : "comments"
User {
uniqueidentifier UserAccountID PK
varchar Username UK
nvarchar FirstName
nvarchar LastName
varchar Email UK
datetime CreatedAt
datetime UpdatedAt "nullable"
date DateOfBirth
}
Cred {
uniqueidentifier UserCredentialID PK
uniqueidentifier UserAccountID FK
datetime CreatedAt
datetime Expiry
nvarchar Hash
bit IsRevoked
datetime RevokedAt "nullable"
}
Verify {
uniqueidentifier UserVerificationID PK
uniqueidentifier UserAccountID "FK, UK"
datetime VerificationDateTime
}
Profile {
uniqueidentifier UserProfileID PK
uniqueidentifier UserAccountID "FK, UK"
nvarchar Biography
}
Avatar {
uniqueidentifier UserAvatarID PK
uniqueidentifier UserProfileID FK
uniqueidentifier PhotoID FK
datetime2 ValidFrom
datetime2 ValidTo "nullable; at most one row per UserProfileID has ValidTo IS NULL (active avatar)"
}
Follow {
uniqueidentifier UserFollowID PK
uniqueidentifier UserAccountID FK
uniqueidentifier FollowingID FK
datetime CreatedAt
}
Photo {
uniqueidentifier PhotoID PK
nvarchar Hyperlink "nullable"
uniqueidentifier UploadedByID FK
datetime UploadedAt
}
Country {
uniqueidentifier CountryID PK
nvarchar CountryName
char ISO3166_1 UK
}
StateProvince {
uniqueidentifier StateProvinceID PK
nvarchar StateProvinceName
char ISO3166_2 UK
uniqueidentifier CountryID FK
}
City {
uniqueidentifier CityID PK
nvarchar CityName
uniqueidentifier StateProvinceID FK
}
Brewery {
uniqueidentifier BreweryPostID PK
nvarchar BreweryName
uniqueidentifier PostedByID FK
nvarchar Description
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BreweryLoc {
uniqueidentifier BreweryPostLocationID PK
uniqueidentifier BreweryPostID "FK, UK"
nvarchar AddressLine1
nvarchar AddressLine2 "nullable"
nvarchar PostalCode
uniqueidentifier CityID FK
geography Coordinates "nullable"
}
BreweryPhoto {
uniqueidentifier BreweryPostPhotoID PK
uniqueidentifier BreweryPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}
BeerStyle {
uniqueidentifier BeerStyleID PK
nvarchar StyleName UK
nvarchar Description "nullable"
}
Beer {
uniqueidentifier BeerPostID PK
nvarchar Name
nvarchar Description
decimal ABV
int IBU
uniqueidentifier PostedByID FK
uniqueidentifier BeerStyleID FK
uniqueidentifier BrewedByID FK
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BeerPhoto {
uniqueidentifier BeerPostPhotoID PK
uniqueidentifier BeerPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}
BeerComment {
uniqueidentifier BeerPostCommentID PK
nvarchar Comment
uniqueidentifier BeerPostID FK
uniqueidentifier CommentedByID FK
int Rating
datetime CreatedAt
datetime UpdatedAt "nullable"
}

View File

@@ -0,0 +1,76 @@
flowchart TB
Developer(["Developer"])
Host[["Docker Host"]]
Developer -->|docker compose up| Host
Host -->|http://localhost:8080| DevAPI
subgraph DEV["Development Environment (docker-compose.dev.yaml)"]
DevDB[("SQL Server 2022<br/>Biergarten Database<br/>:1433")]
DevAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
DevMig["Migrations (run-once)<br/>Database.Migrations"]
DevSeed["Seed (run-once)<br/>Database.Seed"]
DevMailpit["Mailpit<br/>:8025 Web UI / :1025 SMTP"]
end
DevMig -->|1. Run migrations| DevDB
DevSeed -->|2. Seed data| DevDB
DevAPI -->|3. Connect and serve| DevDB
DevAPI -.->|4. SMTP_HOST/PORT| DevMailpit
DevMig -.->|depends_on| DevDB
DevSeed -.->|depends_on| DevMig
DevAPI -.->|depends_on| DevSeed
subgraph TEST["Test Environment (docker-compose.test.yaml)"]
TestDB[("SQL Server, isolated instance<br/>Biergarten Test Database<br/>:1434")]
TestMig["Migrations (test)"]
TestSeed["Seed (test)<br/>minimal: test.user only"]
Specs["API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)"]
UnitTests["unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container"]
end
TestMig -->|1. Migrate| TestDB
TestSeed -->|2. Seed| TestDB
Specs -->|3. Integration test| TestDB
UnitTests -.->|depends_on for startup ordering only, no real connection| TestDB
TestMig -.->|depends_on| TestDB
TestSeed -.->|depends_on| TestMig
Specs -.->|depends_on| TestSeed
UnitTests -.->|depends_on| TestSeed
Results[["test-results/ (mounted volume)<br/>api-specs/results.trx<br/>Features.Users.Tests.trx<br/>Features.Breweries.Tests.trx<br/>Features.Emails.Tests.trx"]]
Specs -->|Export TRX| Results
UnitTests -->|Export TRX, one per slice| Results
subgraph PROD["Production Environment (docker-compose.prod.yaml)"]
ProdDB[("SQL Server 2022<br/>Biergarten Database<br/>no host port published")]
ProdMig["Migrations (run-once)"]
ProdAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
end
ProdMig -->|1. Run migrations| ProdDB
ProdAPI -->|2. Connect and serve| ProdDB
ProdMig -.->|depends_on| ProdDB
ProdAPI -.->|"depends_on: sql-server healthy (not migrations/seed)"| ProdDB
subgraph DBONLY["Database-only Environment (docker-compose.db.yaml)"]
DbOnlyDB[("SQL Server<br/>:1434")]
DbOnlyMig["Migrations (run-once)"]
DbOnlySeed["Seed (run-once)"]
end
DbOnlyMig -->|1. Run migrations| DbOnlyDB
DbOnlySeed -->|2. Seed data| DbOnlyDB
DbOnlyMig -.->|depends_on| DbOnlyDB
DbOnlySeed -.->|depends_on| DbOnlyMig
subgraph MIN["Minimal Environment (docker-compose.min.yaml)"]
MinDB[("SQL Server<br/>:1433")]
MinMig["Migrations (run-once)"]
MinMailpit["Mailpit<br/>:8025 / :1025"]
MinSeed["Seed (run-once)"]
end
MinMig -->|1. Run migrations| MinDB
MinSeed -->|2. Seed data| MinDB
MinMig -.->|depends_on| MinDB
MinSeed -.->|depends_on| MinMig