diff --git a/docs/pipeline/diagrams/biergarten-weizen-theme.puml b/docs/pipeline/diagrams/biergarten-weizen-theme.puml deleted file mode 100644 index d20a6260..00000000 --- a/docs/pipeline/diagrams/biergarten-weizen-theme.puml +++ /dev/null @@ -1,56 +0,0 @@ -' skinparam shadowing false -' skinparam backgroundColor #FCFCF7 -' skinparam defaultFontName "DM Sans" -' skinparam defaultFontColor #14180C -' skinparam titleFontName "Volkhov" -' skinparam titleFontColor #14180C -' skinparam ArrowColor #656F33 -' skinparam NoteBackgroundColor #DBEEDD -' skinparam NoteFontColor #14180C - -' skinparam NoteBorderColor #4A5837 -' skinparam SwimlaneBorderColor #4A5837 -' skinparam SwimlaneBorderThickness 1 -' skinparam activityStartColor #EBECE3 -' skinparam activityEndColor #4A5837 -' skinparam activityStopColor #4A5837 -' skinparam ActivityBackgroundColor #EBECE3 -' skinparam ActivityBorderColor #4A5837 -' skinparam ActivityDiamondBackgroundColor #CBD2B5 -' skinparam ActivityDiamondBorderColor #4A5837 -' skinparam packageStyle rectangle -' skinparam packageBackgroundColor #F1F3EA -' skinparam packageBorderColor #4A5837 -' skinparam packageFontColor #14180C -' skinparam classBackgroundColor #EBECE3 -' skinparam classBorderColor #4A5837 -' skinparam classFontColor #14180C -' skinparam classAttributeFontColor #3F4724 -' skinparam classStereotypeFontColor #4A5837 -' skinparam interfaceBackgroundColor #DBEEDD -' skinparam interfaceBorderColor #4A5837 -' skinparam interfaceFontColor #14180C -' skinparam enumBackgroundColor #E4E6D8 -' skinparam enumBorderColor #4A5837 -' skinparam enumFontColor #14180C -' skinparam ParticipantBackgroundColor #EBECE3 -' skinparam ParticipantBorderColor #4A5837 -' skinparam ParticipantFontColor #14180C -' skinparam ActorBackgroundColor #DBEEDD -' skinparam ActorBorderColor #4A5837 -' skinparam ActorFontColor #14180C -' skinparam LifeLineBorderColor #4A5837 -' skinparam LifeLineBackgroundColor #F1F3EA -' skinparam DatabaseBackgroundColor #EBECE3 -' skinparam DatabaseBorderColor #4A5837 -' skinparam DatabaseFontColor #14180C -' skinparam SequenceGroupBackgroundColor #DBEEDD -' skinparam SequenceGroupBorderColor #4A5837 -' skinparam SequenceGroupFontColor #14180C -' skinparam SequenceGroupBodyBackgroundColor transparent -' skinparam SequenceDividerBackgroundColor #DBEEDD -' skinparam SequenceDividerBorderColor #4A5837 -' skinparam SequenceDividerFontColor #14180C -' skinparam SequenceBoxBackgroundColor #F1F3EA -' skinparam SequenceBoxBorderColor #4A5837 -' skinparam SequenceBoxFontColor #14180C diff --git a/docs/pipeline/diagrams/current/activity.puml b/docs/pipeline/diagrams/current/activity.puml deleted file mode 100644 index cd4803d7..00000000 --- a/docs/pipeline/diagrams/current/activity.puml +++ /dev/null @@ -1,280 +0,0 @@ -@startuml -!include ../biergarten-weizen-theme.puml -skinparam style strictuml -skinparam defaultFontSize 14 -skinparam titleFontSize 20 -skinparam ActivityBarColor #628A5B - -title The Biergarten Data Pipeline (Current — Synchronous Data Path) - -|#F2F6F0|main.cc| -start -:Create BoundedChannel log_channel; -:Spawn log dispatcher thread\n(LogDispatcher::Run()); -note right - The only concurrency in the current pipeline: - a dedicated thread drains log_channel and - forwards entries to spdlog for the entire run. - Joined during shutdown after log_channel closes. -end note -:ParseArguments(argc, argv); -if (Are arguments valid?) then (no) - :Log error / usage info; - stop -else (yes) -endif - -:Init OpenSSL global state & LlamaBackendState; -:di::make_injector(...); -note right - Binds ILogger, IEnrichmentService, DataGenerator, - IExportService, IPromptFormatter, WebClient based - on --mocked vs. model-backed mode. -end note -:injector.create>(); -:BiergartenPipelineOrchestrator::Run(); -note right - Run() itself is synchronous — sampling, enrichment, - generation, and export all happen on this thread. -end note - -|#EAF0E8|BiergartenPipelineOrchestrator| -:Initialize SQLite export; - -|#E0EAE0|SqliteExportService| -:GetUtcTimestamp() from SystemDateTimeProvider; -:Initialize(); -note right - Builds a fresh biergarten_seed_.sqlite filename - Appends a numeric suffix if the timestamp already exists - Opens DB Connection - Executes Schema DDL - Begins Transaction -end note - -|#EAF0E8|BiergartenPipelineOrchestrator| -:QueryLocations(); - -|#E2EBDC|JsonLoader| -:JsonLoader::LoadLocations("locations.json"); -:std::ranges::sample(all_locations, --location-count); -note right - --location-count defaults to 10. -end note - -|#EAF0E8|BiergartenPipelineOrchestrator| -while (For each sampled Location?) is (Remaining cities) - |#DCE8D8|IEnrichmentService| - :GetLocationContext(loc); - note right - WikipediaEnrichmentService fetches a generic - "brewing" extract and a "beer in {country}" extract - (not a city/region-specific page). MockEnrichmentService - (--mocked) returns an empty string instead. - end note - |#EAF0E8|BiergartenPipelineOrchestrator| - if (Lookup failed?) then (yes) - :Log warning, skip city; - else (no) - :Store EnrichedCity{Location, region_context}; - endif -endwhile (Done) - -|#EAF0E8|BiergartenPipelineOrchestrator| -:GenerateUsers(enriched_cities); - -|#E2EBDC|JsonLoader| -:LoadPersonas("personas.json"); -:LoadForenamesByCountry("forenames-by-country.json"); -:LoadSurnamesByCountry("surnames-by-country.json"); -note right - Each call is cached after its first parse. - MockCuratedDataService (--mocked) returns a - fixed in-memory dataset instead and skips - these three JSON files entirely. -end note - -|#EAF0E8|BiergartenPipelineOrchestrator| -while (For each EnrichedCity?) is (Remaining cities) - :SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1); - if (Names available for country?) then (no) - :Log warning, skip city; - else (yes) - :Sample UserPersona (uniform random); - - |#E5EDE1|DataGenerator| - if (Generator Mode) then (MockGenerator) - :DeterministicHash & Format; - elseif (Generator Mode) then (LlamaGenerator) - :prompt_directory_->Load("USER_GENERATION"); - note right - Resolves to USER_GENERATION.md inside --prompt-dir. - end note - repeat - :Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar); - :ValidateUserJson(raw, user); - if (Is JSON Valid?) then (yes) - break - else (no) - :Attempt++; - :Append validation error to retry prompt; - endif - repeat while (Attempt < 3?) is (yes) - else (OpenAIGenerator) - :prompt_directory_->Load("USER_GENERATION"); - note right - Same prompt file as LlamaGenerator - content - is backend-agnostic. - end note - repeat - :CallChatCompletionsApi(system_prompt, user_prompt,\nkUserJsonSchema, max_tokens); - :ValidateUserJson(raw, user); - if (Is JSON Valid?) then (yes) - break - else (no) - :Attempt++; - :Append validation error to retry prompt; - endif - repeat while (Attempt < 2?) is (yes) - note right - Structured Outputs (response_format: - json_schema, strict: true) already - guarantees schema shape, so only 2 - attempts are needed - retries here cover - transient failures / placeholder text, - not malformed JSON. - end note - endif - - |#EAF0E8|BiergartenPipelineOrchestrator| - if (Generation successful?) then (yes) - :GetRandomCoordsWithinRange(city_centre, 5km, rng); - :BuildEmail(name, used_email_local_parts); - :GenerateDateOfBirth(rng); - note right - email and date_of_birth are generated - programmatically, never LLM-authored. - Generation + coordinate jitter run inside - one try/catch per record. UserRecord.address - now carries {city, longitude, latitude}. - end note - - |#E0EAE0|SqliteExportService| - :ProcessRecord(UserRecord); - if (Location in cache?) then (yes) - :Reuse location_id; - else (no) - :Insert Location & Cache ID; - endif - :Insert User (FK: location_id); - - if (Exception caught during insert?) then (yes) - |#EAF0E8|BiergartenPipelineOrchestrator| - :Log warning "SQLite export failed"; - else (no) - endif - else (no) - :Log warning "Generation failed, skipping..."; - endif - endif -endwhile (Done) - -|#EAF0E8|BiergartenPipelineOrchestrator| -:GenerateBreweries(enriched_cities); - -|#E5EDE1|DataGenerator| -while (For each EnrichedCity?) is (Remaining cities) - if (Generator Mode) then (MockGenerator) - :DeterministicHash & Format; - elseif (Generator Mode) then (LlamaGenerator) - :PrepareRegionContext; - :prompt_directory_->Load("BREWERY_GENERATION"); - note right - Resolves to BREWERY_GENERATION.md inside --prompt-dir. - end note - repeat - :Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar); - :ValidateBreweryJson(raw, brewery); - if (Is JSON Valid?) then (yes) - break - else (no) - :Attempt++; - :Append validation error to retry prompt; - endif - repeat while (Attempt < 3?) is (yes) - note right - max_tokens is fixed across retries — - it is not raised on truncation. - end note - else (OpenAIGenerator) - :PrepareRegionContext; - :prompt_directory_->Load("BREWERY_GENERATION"); - note right - Same prompt file as LlamaGenerator - content - is backend-agnostic. - end note - repeat - :CallChatCompletionsApi(system_prompt, user_prompt,\nkBreweryJsonSchema, max_tokens); - :ValidateBreweryJson(raw, brewery); - if (Is JSON Valid?) then (yes) - break - else (no) - :Attempt++; - :Append validation error to retry prompt; - endif - repeat while (Attempt < 2?) is (yes) - note right - Structured Outputs already guarantees schema - shape, so only 2 attempts are needed - retries - here cover transient failures / placeholder - text, not malformed JSON. - end note - endif - - |#EAF0E8|BiergartenPipelineOrchestrator| - if (Generation successful?) then (yes) - :GetRandomCoordsWithinRange(city_centre, 5km, rng); - note right - Generation + coordinate jitter run inside one - try/catch per record. BreweryRecord.address - now carries {city, longitude, latitude}. - end note - - |#E0EAE0|SqliteExportService| - :ProcessRecord(GeneratedBrewery); - if (Location in cache?) then (yes) - :Reuse location_id; - else (no) - :Insert Location & Cache ID; - endif - :Insert Brewery (FK: location_id); - - if (Exception caught during insert?) then (yes) - |#EAF0E8|BiergartenPipelineOrchestrator| - :Log warning "SQLite export failed"; - note right - Data loss is prevented per-record. - The pipeline continues running. - end note - else (no) - endif - else (no) - :Log warning "Generation failed, skipping..."; - endif - |#E5EDE1|DataGenerator| -endwhile (Done) - -|#E0EAE0|SqliteExportService| -:Finalize(); -note right - Commits Transaction - Closes Database Connection -end note - -|#F2F6F0|main.cc| -:Close log_channel; -:Join log dispatcher thread; -:Return 0; -stop - -@enduml diff --git a/docs/pipeline/diagrams/current/class.puml b/docs/pipeline/diagrams/current/class.puml deleted file mode 100644 index f7a8d482..00000000 --- a/docs/pipeline/diagrams/current/class.puml +++ /dev/null @@ -1,282 +0,0 @@ -@startuml -!include ../biergarten-weizen-theme.puml -skinparam style strictuml -skinparam defaultFontSize 14 -skinparam titleFontSize 20 - -title The Biergarten Data Pipeline - Class Diagram - -class BiergartenPipelineOrchestrator { - - logger_ : std::shared_ptr - - context_service_ : std::unique_ptr - - generator_ : std::unique_ptr - - exporter_ : std::unique_ptr - - curated_data_service_ : std::unique_ptr - - application_options_ : ApplicationOptions - - generated_breweries_ : std::vector - - generated_users_ : std::vector - + Run() : bool - - QueryLocations() : std::vector - - GenerateBreweries(cities : std::span) : void - - GenerateUsers(cities : std::span) : 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 <> { - Debug - Info - Warn - Error -} - -class PipelinePhase <> { - Startup - Enrichment - UserGeneration - BreweryAndBeerGeneration - CheckinGeneration - RatingGeneration - FollowGeneration - Teardown -} - -struct LogDTO { - + level : LogLevel - + phase : PipelinePhase - + message : std::string -} - -struct LogEntry { - + timestamp : std::chrono::system_clock::time_point - + origin : std::source_location - + thread_id : std::thread::id - + level : LogLevel - + phase : PipelinePhase - + message : std::string -} - -interface ILogger <> { - + Log(payload : LogDTO) : void - - {abstract} DoLog(entry : LogEntry) : void -} - -class LogProducer { - - channel_ : BoundedChannel& - - DoLog(entry : LogEntry) : void -} - -class LogDispatcher { - - channel_ : BoundedChannel& - + Run() : void - - ToSpdlogLevel(level) : spdlog::level::level_enum -} - -interface IEnrichmentService <> { - + GetLocationContext(loc : const Location&) : std::string -} - -class MockEnrichmentService { - + GetLocationContext(loc : const Location&) : std::string -} - -class WikipediaEnrichmentService { - - client_ : std::unique_ptr - - extract_cache_ : std::unordered_map - + GetLocationContext(loc : const Location&) : std::string - - FetchExtract(query : std::string_view) : std::string -} - -interface WebClient <> { - + Get(url : const std::string&) : std::string - + UrlEncode(value : const std::string&) : std::string -} - -class HttpWebClient { - + Get(url : const std::string&) : std::string - + UrlEncode(value : const std::string&) : std::string -} - -interface DataGenerator <> { - + GenerateBrewery(city : const EnrichedCity&) : BreweryResult - + GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult -} - -class MockGenerator { - + GenerateBrewery(city : const EnrichedCity&) : BreweryResult - + GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult - - DeterministicHash(location : const Location&) : size_t - - DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t -} - -class LlamaGenerator { - - model_ : ModelHandle - - context_ : ContextHandle - - prompt_formatter_ : std::unique_ptr - - prompt_directory_ : std::unique_ptr - - rng_ : std::mt19937 - + GenerateBrewery(...) : BreweryResult - + GenerateUser(...) : UserResult - - Load(model_path : const std::string&) : void - - Infer(...) : std::string - - InferFormatted(...) : std::string -} - -class OpenAIGenerator { - - api_key_ : std::string - - model_ : std::string - - logger_ : std::shared_ptr - - prompt_directory_ : std::unique_ptr - - web_client_ : std::unique_ptr - + 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 <> { - + Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string -} - -class Gemma4JinjaPromptFormatter { - + Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string -} - -interface IPromptDirectory <> { - + Load(key : std::string_view) : std::string -} - -class PromptDirectory { - - prompt_dir_ : std::filesystem::path - - cache_ : std::unordered_map - + Load(key : std::string_view) : std::string -} - -interface ICuratedDataService <> { - + LoadLocations(filepath : const std::filesystem::path&) : const std::vector& - + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector& - + LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map& - + LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map& -} - -class JsonLoader { - - cache_ : cache - + LoadLocations(filepath : const std::filesystem::path&) : const std::vector& - + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector& - + LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map& - + LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map& -} - -note right of JsonLoader - Each Load* method memoizes its result in - cache_ on first call; later calls on the - same instance return the cached value - without re-parsing. -end note - -class MockCuratedDataService { - - locations_ : std::vector - - personas_ : std::vector - - forenames_by_country_ : std::unordered_map - - surnames_by_country_ : std::unordered_map - + LoadLocations(filepath : const std::filesystem::path&) : const std::vector& - + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector& - + LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map& - + LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map& -} - -note right of MockCuratedDataService - Fixed 4-location / 3-persona dataset - (US, DE, FR, BE) used in --mocked runs; - filepath arguments are ignored. -end note - -interface IExportService <> { - + Initialize() : void - + ProcessRecord(brewery : const BreweryRecord&) : uint64_t - + ProcessRecord(user : const UserRecord&) : uint64_t - + Finalize() : void -} - -class SqliteExportService { - - date_time_provider_ : std::unique_ptr - - run_timestamp_utc_ : std::string - - database_path_ : std::filesystem::path - - db_handle_ : SqliteDatabaseHandle - - insert_location_stmt_ : SqliteStatementHandle - - insert_brewery_stmt_ : SqliteStatementHandle - - insert_user_stmt_ : SqliteStatementHandle - - transaction_open_ : bool - - location_cache_ : std::unordered_map - + Initialize() : void - + ProcessRecord(brewery : const BreweryRecord&) : uint64_t - + ProcessRecord(user : const UserRecord&) : uint64_t - + Finalize() : void - - InitializeSchema() : void - - ResolveLocationId(location : const Location&) : sqlite3_int64 -} - -interface IDateTimeProvider <> { - + GetUtcTimestamp() : std::string -} - -class SystemDateTimeProvider { - + GetUtcTimestamp() : std::string -} - -' Structural Relationships / Dependency Injection -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 - -@enduml diff --git a/docs/pipeline/diagrams/planned/activity.puml b/docs/pipeline/diagrams/planned/activity.puml deleted file mode 100644 index 67cabae3..00000000 --- a/docs/pipeline/diagrams/planned/activity.puml +++ /dev/null @@ -1,372 +0,0 @@ -@startuml biergarten_activity -!include ../biergarten-weizen-theme.puml -skinparam defaultFontSize 13 -skinparam titleFontSize 20 - -title The Biergarten Data Pipeline — Activity Diagram - -|Main| -start -:ParseArguments(argc, argv); -if (Invalid args?) then (yes) - :spdlog::error; - stop -else (no) -endif -:Init OpenSSL global state & LlamaBackendState; -:Build DI injector; - -:Initialize SqliteExportService; -note right - Opens SQLite connection. - (Transactions are now managed - per-phase via batching). -end note - -:Create BoundedChannel log_ch; -:Spawn Log Worker thread; -note right - Log worker drains log_ch for the - entire pipeline lifetime. - All workers emit LogEntry structs - via PipelineLogger -- never spdlog directly. -end note - -:BiergartenPipelineOrchestrator::Run(); -|BiergartenPipelineOrchestrator::Run()| - -fork - :JsonLoader::LoadBeerStyles("beer-styles.json"); - :EnrichmentService::PreWarmBeerStyleCache(beer_styles); -fork again - :JsonLoader::LoadLocations("locations.json"); - :EnrichmentService::PreWarmLocationCache(sampled_locations); -end fork -fork - :JsonLoader::LoadForenamesByCountry(\n "forenames-by-country.json"); -fork again - :JsonLoader::LoadSurnamesByCountry(\n "surnames-by-country.json"); -fork again - :JsonLoader::LoadPersonas("personas.json"); -end fork -note right - Each call is memoized after its first parse - (implemented today). MockCuratedDataService - returns a fixed in-memory dataset instead - under --mocked, skipping these JSON files. -end note - -' ═══════════════════════════════════════════ -' PHASE 0 — USER GENERATION -' ═══════════════════════════════════════════ -|Orchestrator| -:RunUserPhase(sampled_locations); -:Create BoundedChannels\n(loc_ch, exp_ch); - -fork - |Orchestrator| - :Loop: Send Locations -> loc_ch; - :Close loc_ch; - note right - Producer closes loc_ch. - LLM Worker while loop - terminates on empty + closed. - end note -fork again - |LLM Worker| - while (loc_ch has items?) is (yes) - :Receive Location; - - :GetLocationContextFromCache(location); - note right - Guaranteed cache hit from startup. - end note - - :IPersonaSelectionStrategy::SelectPersona(\n personas_palette_); - note right - Guaranteed cache hit from startup. - Returns a Persona struct carrying - style_affinities, abv_range, - ibu_preference, checkin_weight. - end note - - :SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1, rng); - note right - Deterministic lookup -- no LLM involved. - Free function (implemented today in - generate_users.cc), not a class method. - Name selected from pre-keyed maps - and passed into the generation prompt. - Skips the city if either map has no - entry for iso3166_1. - end note - - :GenerateUser(enriched_city, persona, sampled_name)\nvia DataGenerator; - note right - LLM receives: EnrichedCity context + persona - description + sampled name. Generates - bio and preference signals grounded - in locale and persona. - end note - - :PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "llm"); - :Send GeneratedUser -> exp_ch; - endwhile (no) - :Close exp_ch; - note right - Producer closes exp_ch. - SQLite Worker while loop - terminates on empty + closed. - end note -fork again - |SQLite Worker| - :BEGIN TRANSACTION; - while (exp_ch has items?) is (yes) - :Receive GeneratedUser; - :ProcessUser(user); - :PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "sqlite"); - :Append -> user_pool_; - if (Batch size reached?) then (yes) - :COMMIT & BEGIN; - else (no) - endif - endwhile (no) - :COMMIT (Final); -end fork - -|Orchestrator| -:Join LLM Worker, SQLite Worker; - -' ═══════════════════════════════════════════ -' PHASE 1a — BREWERY GENERATION -' ═══════════════════════════════════════════ -:RunBreweryPhase(sampled_locations); -:Create BoundedChannels\n(loc_ch, exp_ch); - -fork - |Orchestrator| - :Loop: Sample User from user_pool_ - and pair with Location; - :Send BreweryTask(Location, User) -> loc_ch; - :Close loc_ch; -fork again - |LLM Worker| - while (loc_ch has items?) is (yes) - :Receive BreweryTask(Location, User); - - :GetLocationContextFromCache(task.location); - note right - Guaranteed cache hit from startup. - end note - - :GenerateBrewery(enriched_city, context, task.user)\nvia DataGenerator; - note right - KV cache stays warm. - Brewery is linked to the sampled owner_user_id. - end note - :PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "llm"); - :Send GeneratedBrewery -> exp_ch; - endwhile (no) - :Close exp_ch; -fork again - |SQLite Worker| - :BEGIN TRANSACTION; - while (exp_ch has items?) is (yes) - :Receive GeneratedBrewery; - :ProcessBrewery(brewery); - :PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "sqlite"); - :Append -> brewery_pool_; - if (Batch size reached?) then (yes) - :COMMIT & BEGIN; - else (no) - endif - endwhile (no) - :COMMIT (Final); -end fork - -|Orchestrator| -:Join LLM Worker, SQLite Worker; -note right - brewery_pool_ is now fully populated. - Phase 1b may begin. -end note - -' ═══════════════════════════════════════════ -' PHASE 1b — BEER GENERATION -' ═══════════════════════════════════════════ -:RunBeerPhase(); -:Create BoundedChannels\n(brew_ch, exp_ch); - -fork - |Orchestrator| - :Loop: Send Breweries -> brew_ch; - :Close brew_ch; -fork again - |LLM Worker| - while (brew_ch has items?) is (yes) - :Receive GeneratedBrewery; - :IBeerSelectionStrategy::SelectStyles(\n brewery, beer_style_palette_); - - while (For each selected BeerStyle?) is (remaining) - :GetStyleContextFromCache(style); - note right - Guaranteed cache hit from startup. - KV cache stays warm across all - beer generations -- system prompt - does not change within this phase. - end note - :GenerateBeer(brewery, style_context)\nvia DataGenerator; - :Attach GeneratedBeer to bundle; - endwhile (done) - - :PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "llm"); - :Send BeersBundle -> exp_ch; - endwhile (no) - :Close exp_ch; -fork again - |SQLite Worker| - :BEGIN TRANSACTION; - while (exp_ch has items?) is (yes) - :Receive BeersBundle; - while (For each beer in bundle?) is (remaining) - :Set beer.brewery_id from bundle; - :ProcessBeer(beer); - :Append -> beer_pool_; - endwhile (done) - :PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "sqlite"); - if (Batch size reached?) then (yes) - :COMMIT & BEGIN; - else (no) - endif - endwhile (no) - :COMMIT (Final); -end fork - -|Orchestrator| -:Join LLM Worker, SQLite Worker; -note right - Both brewery_pool_ and beer_pool_ - are now completely populated. - Checkin and Follow phases may - now run in parallel. -end note - -' ═══════════════════════════════════════════ -' PHASE 2 — CHECKIN + FOLLOW GENERATION -' (parallel — both depend only on user_pool_ -' and brewery_pool_ being fully populated) -' ═══════════════════════════════════════════ -fork - |Orchestrator| - :RunCheckinPhase(); - :ICheckinDistributionStrategy::\nAssignActivityWeights(user_pool_); - note right - Weights seeded from each user's - persona.checkin_weight. J-curve profile - emerges from persona distribution. - end note - - :BEGIN TRANSACTION; - while (For each GeneratedUser in user_pool_?) is (remaining) - :CheckinsForUser(user, brewery_pool_.size()); - while (For each checkin index?) is (remaining) - :TimestampFor(user, index); - :Select brewery from brewery_pool_; - :GenerateCheckin(user, brewery, timestamp)\nvia DataGenerator; - :ProcessCheckin(checkin); - :PipelineLogger::Log(Info, CheckinGeneration,\n nullopt, checkin_id, "sqlite"); - :Append -> checkin_pool_; - if (Batch size reached?) then (yes) - :COMMIT & BEGIN; - else (no) - endif - endwhile (done) - endwhile (done) - :COMMIT (Final); - -fork again - |Orchestrator| - :RunFollowPhase(); - :IFollowGenerationStrategy::\nAssignFollowWeights(user_pool_); - note right - For RandomFollowStrategy, weights - are uniform. For ActivityWeightedFollowStrategy, - weights derived from user.activity_weight - so high-activity users attract more followers. - end note - - :BEGIN TRANSACTION; - :IFollowGenerationStrategy::\nGenerateFollows(user_pool_); - note right - Self-follow constraint (follower_id != followed_id) - enforced here and at the DB schema level. - end note - while (For each GeneratedFollow?) is (remaining) - :ProcessFollow(follow); - :PipelineLogger::Log(Info, FollowGeneration,\n nullopt, follower_id, "sqlite"); - :Append -> follow_pool_; - if (Batch size reached?) then (yes) - :COMMIT & BEGIN; - else (no) - endif - endwhile (done) - :COMMIT (Final); - -end fork - -|Orchestrator| -:Join CheckinPhase, FollowPhase; -note right - checkin_pool_ and follow_pool_ - are now fully populated. - Rating phase may begin. -end note - -' ═══════════════════════════════════════════ -' PHASE 3 — RATING GENERATION -' ═══════════════════════════════════════════ -:RunRatingPhase(); -note right - Beer selection biased by - user.persona.style_affinities and abv_range. - Rating skew modulated per persona. -end note - -:BEGIN TRANSACTION; -while (For each GeneratedCheckin in checkin_pool_?) is (remaining) - :Match brewery_id, select beer from beer_pool_\n(same brewery_id, biased by persona affinities); - if (Beer exists for brewery?) then (yes) - :GenerateRating(user, beer, checkin_id)\nvia DataGenerator; - :ProcessRating(rating); - :PipelineLogger::Log(Info, RatingGeneration,\n nullopt, rating_id, "sqlite"); - if (Batch size reached?) then (yes) - :COMMIT & BEGIN; - else (no) - endif - else (no) - :PipelineLogger::Log(Warn, RatingGeneration,\n nullopt, brewery_id, "sqlite"); - :Skip -- brewery has no beers; - endif -endwhile (done) -:COMMIT (Final); - -' ═══════════════════════════════════════════ -' TEARDOWN -' ═══════════════════════════════════════════ -|Orchestrator| -:Finalize SqliteExportService; -note right - Safely closes the DB connection. -end note -:Close log_ch; - -|Main| -:spdlog::info "Pipeline complete in X ms"; -:Join Log Worker; -note right - Drain guarantees no LogEntry is - dropped at shutdown. -end note -stop - -@enduml diff --git a/docs/pipeline/diagrams/planned/class.puml b/docs/pipeline/diagrams/planned/class.puml deleted file mode 100644 index 68876efa..00000000 --- a/docs/pipeline/diagrams/planned/class.puml +++ /dev/null @@ -1,646 +0,0 @@ -@startuml class_diagram - -' ========================================== -' CONFIGURATION & STYLING -' ========================================== -!include ../biergarten-weizen-theme.puml -skinparam classAttributeFontSize 9 -skinparam defaultFontSize 25 -skinparam titleFontSize 30 - -title Biergarten Data Pipeline — Class Diagram - -package "Domain: Models" { - - class Location { - + city : std::string - + state_province : std::string - + iso3166_2 : std::string - + country : std::string - + iso3166_1 : std::string - + local_languages : std::vector - + latitude : double - + longitude : double - } - - class LocationContext { - + text : std::string - + completeness : Completeness - + char_count : size_t - } - - enum Completeness { - Full - Partial - Absent - } - - class EnrichedCity { - + location : Location - + context : LocationContext - } - - class BeerStyle { - + name : std::string - + description : std::string - + min_abv : float - + max_abv : float - + min_ibu : int - + max_ibu : int - } - - class BreweryResult { - + name_en : std::string - + description_en : std::string - + name_local : std::string - + description_local : std::string - } - - class BeerResult { - + name_en : std::string - + description_en : std::string - + name_local : std::string - + description_local : std::string - + style : std::string - + abv : float - + ibu : int - } - - class UserResult { - + first_name : std::string - + last_name : std::string - + gender : std::string - + username : std::string - + bio : std::string - + activity_weight : float - } - - class Name { - + first_name : std::string - + last_name : std::string - + gender : std::string - } - - class ForenameEntry { - + name : std::string - + gender : std::string - } - - note right of ForenameEntry - forename_list = std::unordered_set - surname_list = std::unordered_set - (aliases declared in curated_data_service.h). - ICuratedDataService::LoadForenamesByCountry() / - LoadSurnamesByCountry() each return a flat - std::unordered_map - or keyed by ISO - 3166-1 country code -- no NamesByCountry - wrapper class. SampleName(forenames_by_country, - surnames_by_country, iso3166_1, rng) is a free - function, not a method, so it can be called by - any per-city worker without owning the maps. - Sourced from popular-names-by-country-dataset - (CC0) -- see ETHICS-AND-KNOWN-ISSUES.md. Gender - is preserved per forename so it can be threaded - through to persona/bio generation later; surnames - carry no gender in the source data. Pairing - happens at sample time, not at fixture-authoring - time, so no information from the source dataset - is discarded up front. - end note - - ForenameEntry ..> Name : SampleName() produces - - class CheckinResult { - + checked_in_at : std::string - + note : std::string - } - - class RatingResult { - + score : float - + note : std::string - } - - class GenerationMetadata { - + generation_id : uint64_t - + generated_time : std::string - + context_provided : bool - + generated_with : std::string - } - - class GeneratedBrewery { - + brewery_id : uint64_t - + location : Location - + brewery : BreweryResult - + context_completeness : LocationContext::Completeness - + metadata : GenerationMetadata - } - - class GeneratedBeer { - + beer_id : uint64_t - + brewery_id : uint64_t - + location : Location - + style : BeerStyle - + beer : BeerResult - + metadata : GenerationMetadata - } - - class GeneratedUser { - + user_id : uint64_t - + location : Location - + user : UserResult - + email : std::string - + date_of_birth : std::string - + password : std::string - + metadata : GenerationMetadata - } - - note right of GeneratedUser - email, date_of_birth, and password are - programmatically generated by the orchestrator - (not LLM-authored) so a downstream auth-account - seeding consumer can register real accounts from - this export. See ROADMAP.md §1. - end note - - class GeneratedCheckin { - + checkin_id : uint64_t - + user_id : uint64_t - + brewery_id : uint64_t - + checkin : CheckinResult - + metadata : GenerationMetadata - } - - class GeneratedRating { - + user_id : uint64_t - + beer_id : uint64_t - + checkin_id : uint64_t - + rating : RatingResult - + metadata : GenerationMetadata - } - - class GeneratedFollow { - + follower_id : uint64_t - + followed_id : uint64_t - + metadata : GenerationMetadata - } - - class UserPersona { - + name: std::string - + description: std::string - + style_affinities: std::vector - } - - LocationContext *-- Completeness -} - -package "Domain: Application Configuration" { - class SamplingOptions { - + temperature: float = 1.0F - + top_p: float = 0.95F - + top_k: uint32_t = 64 - + n_ctx: uint32_t = 8192 - + seed: int = -1 - } - - class GeneratorOptions { - + model_path: std::filesystem::path - + use_mocked: bool = false - + sampling: std::optional - } - - class PipelineOptions { - + output_path: std::filesystem::path - + log_path: std::filesystem::path - } - - class ApplicationOptions { - + generator: GeneratorOptions - + pipeline: PipelineOptions - } - - ApplicationOptions *-- GeneratorOptions - ApplicationOptions *-- PipelineOptions - GeneratorOptions o-- SamplingOptions -} - -package "Domain: Policy" { - - interface ContextStrategy <> { - + QueriesFor(loc : const Location&) : std::vector - + MaxContextChars() : size_t - } - - class BreweryContextStrategy { - + QueriesFor(loc : const Location&) : std::vector - + MaxContextChars() : size_t - } - - class BeerContextStrategy { - + QueriesFor(loc : const Location&) : std::vector - + MaxContextChars() : size_t - } - - interface SamplingStrategy <> { - + Sample(locations : const std::vector&) : std::vector - } - - class UniformSamplingStrategy { - - sample_size_ : size_t - + Sample(locations : const std::vector&) : std::vector - } - - interface BeerSelectionStrategy <> { - + SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span) : std::vector - } - - class RandomBeerSelectionStrategy { - - rng_ : std::mt19937 - - min_beers_ : size_t - - max_beers_ : size_t - + SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span) : std::vector - } - - interface CheckinDistributionStrategy <> { - + AssignActivityWeights(users : std::vector&) : void - + CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t - + TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string - } - - class JCurveCheckinStrategy { - - rng_ : std::mt19937 - + AssignActivityWeights(users : std::vector&) : void - + CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t - + TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string - } - - class RandomCheckinStrategy { - - rng_ : std::mt19937 - - min_checkins_ : size_t - - max_checkins_ : size_t - + AssignActivityWeights(users : std::vector&) : void - + CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t - + TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string - } - - interface FollowGenerationStrategy <> { - + GenerateFollows(users : const std::vector&) : std::vector - } - - class RandomFollowStrategy { - - rng_ : std::mt19937 - - min_follows_ : size_t - - max_follows_ : size_t - + GenerateFollows(users : const std::vector&) : std::vector - } - - class ActivityWeightedFollowStrategy { - - rng_ : std::mt19937 - - min_follows_ : size_t - - max_follows_ : size_t - + GenerateFollows(users : const std::vector&) : std::vector - } -} - -package "Infrastructure: Logging" { - enum LogLevel { - Debug - Info - Warn - Error - } - - enum PipelinePhase { - Startup - UserGeneration - BreweryAndBeerGeneration - CheckinGeneration - RatingGeneration - FollowGeneration - Teardown - } - - class LogEntry { - + timestamp : std::chrono::system_clock::time_point - + level : LogLevel - + phase : PipelinePhase - + message : std::string - + worker : std::optional - } - - interface ILogger <> { - + Log(entry : const LogEntry&) : void - } - - class LogProducer { - - channel_ : BoundedChannel& - + Log(entry : const LogEntry&) : void - } - - class LogDispatcher { - - channel_ : BoundedChannel& - + Run() : void - - ToSpdlogLevel(level) : spdlog::level::level_enum - } - - LogEntry *-- LogLevel - LogEntry *-- PipelinePhase - ILogger <|.. LogProducer - LogProducer ..> LogEntry : emits - LogDispatcher ..> LogEntry : consumes -} - -package "Infrastructure: Pipeline Channel" { - - class "BoundedChannel" as BoundedChannel { - - queue_ : std::queue - - mutex_ : std::mutex - - not_full_ : std::condition_variable - - not_empty_ : std::condition_variable - - capacity_ : size_t - - closed_ : bool - + Send(item : T) : void - + Receive() : std::optional - + Close() : void - } - -} - -package "Infrastructure: Data Preloading" { - - interface ICuratedDataService <> { - + LoadLocations(filepath : const std::filesystem::path&) : const std::vector& - + LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector& - + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector& - + LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map& - + LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map& - } - - class JsonLoader { - + LoadLocations(filepath : const std::filesystem::path&) : const std::vector& - + LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector& - + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector& - + LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map& - + LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map& - } - - note right of JsonLoader - Each Load* call is memoized after its first - parse -- implemented today, ahead of the rest - of this package (LoadBeerStyles is still planned). - end note - - class MockCuratedDataService { - + LoadLocations(filepath : const std::filesystem::path&) : const std::vector& - + LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector& - + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector& - + LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map& - + LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map& - } - - note right of MockCuratedDataService - Fixed in-memory dataset used in --mocked mode, - implemented today for Locations/Personas/ - Forenames/Surnames; would need a LoadBeerStyles - fixture too once that method is implemented. - end note - -} - -package "Infrastructure: Enrichment" { - - interface EnrichmentService <> { - + GetLocationContext(loc : const Location&,\n strategy : const ContextStrategy&) : LocationContext - } - - class WikipediaService { - - client_ : std::unique_ptr - - extract_cache_ : std::unordered_map - + GetLocationContext(loc : const Location&,\n strategy : const ContextStrategy&) : LocationContext - - FetchExtract(query : std::string_view) : std::string - } - - interface WebClient <> { - + Get(url : const std::string&) : std::string - + UrlEncode(value : const std::string&) : std::string - } - - class HttpWebClient { - + Get(url : const std::string&) : std::string - + UrlEncode(value : const std::string&) : std::string - } - -} - -package "Infrastructure: Prompting" { - - interface IPromptDirectory <> { - + Load(key : std::string_view) : std::string - } - - class PromptDirectory { - - prompt_dir_ : std::filesystem::path - - cache_ : std::unordered_map - + PromptDirectory(prompt_dir : const std::filesystem::path&) - + Load(key : std::string_view) : std::string - } - - IPromptDirectory <|.. PromptDirectory -} - -package "Infrastructure: Data Generation" { - - interface DataGenerator <> { - + GenerateBrewery(location : const Location&,\n context : const LocationContext&) : BreweryResult - + GenerateBeer(brewery_id : uint64_t,\n location : const Location&,\n context : const LocationContext&,\n style : const BeerStyle&) : BeerResult - + GenerateUser(city : const EnrichedCity&,\n persona : const UserPersona&,\n name : const Name&) : UserResult - + GenerateCheckin(user : const GeneratedUser&,\n brewery : const GeneratedBrewery&,\n timestamp : const std::string&) : CheckinResult - + GenerateRating(user : const GeneratedUser&,\n beer : const GeneratedBeer&,\n checkin_id : uint64_t) : RatingResult - } - - class MockGenerator { - + GenerateBrewery(...) : BreweryResult - + GenerateBeer(...) : BeerResult - + GenerateUser(...) : UserResult - + GenerateCheckin(...) : CheckinResult - + GenerateRating(...) : RatingResult - - DeterministicHash(location : const Location&) : size_t - } - - class LlamaGenerator { - - model_ : ModelHandle - - context_ : ContextHandle - - prompt_formatter_ : std::unique_ptr - - prompt_directory_ : std::unique_ptr - - rng_ : std::mt19937 - + GenerateBrewery(...) : BreweryResult - + GenerateBeer(...) : BeerResult - + GenerateUser(...) : UserResult - + GenerateCheckin(...) : CheckinResult - + GenerateRating(...) : RatingResult - - Load(opts : const GeneratorOptions&) : void - - Infer(system_prompt, user_prompt,\n max_tokens, grammar) : std::string - - ValidateModelArchitecture() : void - } - - interface PromptFormatter <> { - + Format(system_prompt : std::string_view,\n user_prompt : std::string_view) : std::string - + ExpectedArchitecture() : std::string_view - } - - class Gemma4JinjaPromptFormatter { - + Format(...) : std::string - + ExpectedArchitecture() : std::string_view - } - -} - -package "Infrastructure: Data Export" { - - interface ExportService <> { - + Initialize() : void - + ProcessBrewery(brewery : const GeneratedBrewery&) : uint64_t - + ProcessBeer(beer : const GeneratedBeer&) : uint64_t - + ProcessUser(user : const GeneratedUser&) : uint64_t - + ProcessCheckin(checkin : const GeneratedCheckin&) : uint64_t - + ProcessRating(rating : const GeneratedRating&) : void - + ProcessFollow(follow : const GeneratedFollow&) : void - + Finalize() : void - } - - class SqliteExportService { - - date_time_provider_ : std::unique_ptr - - db_handle_ : SqliteDatabaseHandle - - 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_ : SqliteStatementHandle - - transaction_open_ : bool - - location_cache_ : std::unordered_map - - brewery_cache_ : std::unordered_map - + Initialize() : void - + ProcessRecord(brewery : const GeneratedBrewery&) : uint64_t - + ProcessRecord(beer : const GeneratedBeer&) : uint64_t - + ProcessRecord(user : const GeneratedUser&) : uint64_t - + ProcessRecord(checkin : const GeneratedCheckin&) : uint64_t - + ProcessRecord(rating : const GeneratedRating&) : void - + ProcessRecord(follow : const GeneratedFollow&) : void - + Finalize() : void - - InitializeSchema() : void - - PrepareStatements() : void - - RollbackAndCloseNoThrow() : void - - FinalizeStatements() : void - } - - interface DateTimeProvider <> { - + GetUtcTimestamp() : std::string - } - - class SystemDateTimeProvider { - + GetUtcTimestamp() : std::string - } - -} - -class BiergartenPipelineOrchestrator { - - curated_data_service_ : std::unique_ptr - - enrichment_service_ : std::unique_ptr - - generator_ : std::unique_ptr - - logger_ : std::unique_ptr - - exporter_ : std::unique_ptr - - brewery_context_strategy_ : std::unique_ptr - - sampling_strategy_ : std::unique_ptr - - beer_selection_strategy_ : std::unique_ptr - - checkin_strategy_ : std::unique_ptr - - follow_strategy_ : std::unique_ptr - - beer_style_palette_ : std::vector - - options_ : ApplicationOptions - -- - - user_pool_ : std::vector - - brewery_pool_ : std::vector - - beer_pool_ : std::vector - - checkin_pool_ : std::vector - - follow_pool_ : std::vector - -- - + Run() : bool - - RunUserPhase(locations : const std::vector&) : void - - RunBreweryAndBeerPhase(locations : const std::vector&) : void - - RunCheckinPhase() : void - - RunRatingPhase() : void - - RunFollowPhase() : void -} - -' --- Orchestration Aggregations (Services & Strategies) --- -BiergartenPipelineOrchestrator *-- ICuratedDataService -BiergartenPipelineOrchestrator *-- EnrichmentService -BiergartenPipelineOrchestrator *-- DataGenerator -BiergartenPipelineOrchestrator *-- ExportService -BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy -BiergartenPipelineOrchestrator *-- FollowGenerationStrategy -BiergartenPipelineOrchestrator *-- SamplingStrategy -BiergartenPipelineOrchestrator *-- BeerSelectionStrategy -BiergartenPipelineOrchestrator *-- ApplicationOptions -BiergartenPipelineOrchestrator *-- Logger - -' --- Orchestration Aggregations (Data Pools) --- -BiergartenPipelineOrchestrator *-- "0..*" GeneratedUser : user_pool_ -BiergartenPipelineOrchestrator *-- "0..*" GeneratedBrewery : brewery_pool_ -BiergartenPipelineOrchestrator *-- "0..*" GeneratedBeer : beer_pool_ -BiergartenPipelineOrchestrator *-- "0..*" GeneratedCheckin : checkin_pool_ -BiergartenPipelineOrchestrator *-- "0..*" GeneratedFollow : follow_pool_ - -' --- Interfaces & Implementations --- -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 - -' --- Service Compositions & Dependencies --- -WikipediaService *-- WebClient -WikipediaService ..> ContextStrategy -LlamaGenerator *-- PromptFormatter -LlamaGenerator *-- IPromptDirectory -LlamaGenerator ..> GeneratorOptions -SqliteExportService *-- DateTimeProvider - -' --- Cross-Component Aggregations (Held References) --- -PipelineLogger o-- BoundedChannel : logs to -LogWorker o-- BoundedChannel : drains from - -' --- Domain Containment --- -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 - -@enduml diff --git a/docs/website/diagrams-md/architecture.md b/docs/website/diagrams-md/architecture.md index 2b59d477..f466b2b7 100644 --- a/docs/website/diagrams-md/architecture.md +++ b/docs/website/diagrams-md/architecture.md @@ -1,84 +1,120 @@ # The Biergarten App — Vertical Slice Architecture +Two diagrams, split by concern: which host wires up which slice at startup, and +which projects each slice actually compiles against. Mixing DI wiring and +compile-time references in one graph is what made the old diagram unreadable. + +## Host wiring + +Each host (`API.Core`, `Database.Seed`) decides for itself which `Features.*` +assemblies to register — there's no shared bootstrap. `Database.Migrations` +doesn't wire up any slice; it applies schema directly. + ```mermaid +--- +config: + flowchart: + curve: stepAfter +--- flowchart TB - subgraph HOST["API.Core (thin host)"] - API["API.Core
ASP.NET Core Web API host"] - end + API["API.Core
(ASP.NET Core Web API)"] + Seed["Database.Seed
(console app)"] + Migrations["Database.Migrations
(console app)"] subgraph SLICES["Feature Slices"] - UsersSlice["Features.Users"] + direction LR + UsersSlice["Features.Users
(controller)"] + BrewerySlice["Features.Breweries
(controller)"] + PhotoSlice["Features.PhotoUpload
(MediatR only)"] + EmailsSlice["Features.Emails
(MediatR only)"] + LocationsSlice["Features.Locations
(plain DI service)"] + BeersSlice["Features.Beers
(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 +``` + +## Project reference dependencies + +Solid arrows are compile-time project references. Dashed arrows are the two +places slices talk to each other without a direct reference: the email +MediatR contract routed through `Shared.Application`, and the one place that +pattern is broken outright. Every DB-backed slice depends on the same three +foundation projects (`Shared.Contracts`, `Domain.Entities`, +`Database.Connection`) — drawn as one box instead of fifteen separate arrows; +see the notes for what else each of those foundation projects pulls in. + +```mermaid +--- +config: + flowchart: + curve: stepAfter +--- +flowchart TB + subgraph SLICES["Feature Slices"] + direction LR BrewerySlice["Features.Breweries"] - EmailsSlice["Features.Emails
(no controller)"] - LocationsSlice["Features.Locations
(no controller, no MediatR)"] + LocationsSlice["Features.Locations"] + BeersSlice["Features.Beers
(scaffolded)"] + PhotoSlice["Features.PhotoUpload"] + UsersSlice["Features.Users"] + EmailsSlice["Features.Emails"] end - subgraph SHARED["Shared"] + subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"] + direction LR SharedContracts["Shared.Contracts"] - SharedApp["Shared.Application"] + Domain["Domain.Entities"] + DBConn["Database.Connection"] end - subgraph INFRA["Infrastructure Layer"] - Sql["Infrastructure.Sql"] + SharedApp["Shared.Application
(email command contract)"] + + subgraph INFRA["Slice-specific Infrastructure"] + direction LR JWT["Infrastructure.Jwt"] PwdHash["Infrastructure.PasswordHashing"] + FileUpload["Infrastructure.FileUpload"] Email["Infrastructure.Email"] EmailTemplates["Infrastructure.Email.Templates"] end - subgraph DOMAIN["Domain Layer"] - Domain["Domain.Entities"] - DomainExceptions["Domain.Exceptions"] - end - Tables[("SQL Server
Tables")] - Seed["Database.Seed
(separate console app)"] - API -.->|AddApplicationPart| UsersSlice - API -.->|AddApplicationPart| BrewerySlice - API -.->|MediatR scan only| EmailsSlice + %% the one feature-to-feature reference, breaking slice isolation + UsersSlice ==>|"project ref, sends UploadPhotoCommand"| PhotoSlice - Seed -.->|AddFeaturesUsers| UsersSlice - Seed -.->|AddFeaturesBreweries| BrewerySlice - Seed -.->|AddFeaturesLocations| LocationsSlice + %% cross-slice MediatR contract, routed through Shared.Application instead + UsersSlice -.->|sends email commands| SharedApp + EmailsSlice -.->|handles email commands| SharedApp - UsersSlice --> Sql - UsersSlice --> JWT - UsersSlice --> PwdHash - UsersSlice --> SharedContracts - UsersSlice --> SharedApp + %% every DB-backed slice depends on the same foundation + BrewerySlice & LocationsSlice & BeersSlice & PhotoSlice & UsersSlice --> FOUNDATION - BrewerySlice --> Sql - BrewerySlice --> SharedContracts - BrewerySlice --> SharedApp + %% slice-specific infra, one edge per slice + UsersSlice --> JWT & PwdHash + PhotoSlice --> FileUpload + EmailsSlice --> Email & EmailTemplates - LocationsSlice --> Sql - - EmailsSlice --> Email - EmailsSlice --> EmailTemplates - EmailsSlice --> SharedApp - - UsersSlice -.->|sends SendRegistrationEmailCommand| SharedApp - EmailsSlice -.->|handles SendRegistrationEmailCommand| SharedApp - - Sql --> Domain - - UsersSlice --> Tables - BrewerySlice --> Tables - LocationsSlice --> Tables - - UsersSlice --> Domain - BrewerySlice --> Domain - LocationsSlice --> Domain - UsersSlice --> DomainExceptions + DBConn -->|Dapper SQL, no ORM| Tables ``` ## Notes -- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter. -- **Feature slices**: each owns its own controller (HTTP endpoints), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`). `Features.Emails` has no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller and no MediatR handlers — it exposes `ILocationRepository` directly as a plain DI service, not registered by `API.Core` at all; only `Database.Seed` depends on it today. -- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — the one cross-slice interaction is a MediatR command contract, not a project reference. -- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle` (schema tables exist for the last two; no repository yet). -- **Infrastructure.Sql**: SQL-first approach — each slice's own repository issues inline SQL via Dapper, no ORM, no stored procedures. -- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly — no API host, no MediatR pipeline. -- No `Features.*` project ever references another `Features.*` project directly. +- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter. Only `Features.Users` and `Features.Breweries` get `AddApplicationPart` (controllers); `Features.PhotoUpload` and `Features.Emails` are scanned for MediatR handlers only. `Features.Locations` and `Features.Beers` aren't referenced by `API.Core` at all. +- **Feature slices**: each owns its own controller (where present), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`) — internally namespaced `Features.Auth`, despite the project being named `Features.Users`. `Features.Emails` and `Features.PhotoUpload` have no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller either; it now has one MediatR handler (`GetOrCreateCityHandler`), but that's only registered by `Database.Seed`'s own MediatR pipeline — `API.Core` never scans it. `Features.Beers` is scaffolded (project + references only, no source files yet). +- **The one exception to slice isolation**: `Features.Users` takes a direct project reference to `Features.PhotoUpload` and sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` — unlike the email flow, the command type lives in the target slice itself, not in `Shared.Application`. +- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — that interaction is a MediatR command contract, not a project reference, and every slice (including `Features.Emails`) references it for the pipeline behavior alone. +- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for `BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no code). +- **Database.Connection**: the shared connection-factory project — each slice's own repository issues inline SQL via Dapper against it, no ORM, no stored procedures. Replaces the old `Infrastructure.Sql` project. +- **Database.Migrations**: separate console app (`schema.dbml` + SQL scripts) that applies DDL directly — distinct from `Database.Seed`, which loads data through the feature slices' own DI extensions. +- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly and builds its own MediatR registration — no API host, no shared pipeline with `API.Core`. +- **Omitted from the diagram**: `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) and `Infrastructure.Configuration` (the shared options-binding project referenced by `Database.Connection`, `Infrastructure.Email`, `Infrastructure.FileUpload`, and `Features.Emails` directly) — both are plumbing-level dependencies of nearly everything above, and drawing them would just add a second copy of the same fan-in. diff --git a/docs/website/diagrams-src/architecture.puml b/docs/website/diagrams-src/architecture.puml deleted file mode 100644 index 269740e2..00000000 --- a/docs/website/diagrams-src/architecture.puml +++ /dev/null @@ -1,151 +0,0 @@ -@startuml architecture_v6 -skinparam linetype ortho -skinparam nodesep 90 -skinparam ranksep 110 - -title The Biergarten App - Vertical Slice Architecture - -package "API.Core (thin host)" #F1F3EA { - [API.Core\nASP.NET Core Web API host] as API - note right of API - - Program.cs wiring only - - MediatR + AddApplicationPart - per Features.* assembly - - Swagger/OpenAPI, health checks - - JWT auth middleware - - Global exception filter - end note -} - -package "Feature Slices" #DBEEDD { - [Features.Users] as UsersSlice - [Features.Breweries] as BrewerySlice - [Features.Emails] as EmailsSlice - [Features.Locations] as LocationsSlice - note right of UsersSlice - Each slice owns its own: - - Controller (HTTP endpoints) - - Commands/Queries + Handlers - - Validators - - Repository (Dapper) - - Features.Users covers auth - (Commands/Authentication), - account management - (Commands/Account), and - profile/avatar (Commands/Profile) - end note - note right of EmailsSlice - No controller - invoked only - via MediatR commands sent - from other slices - end note - note right of LocationsSlice - No controller and no MediatR - handlers - exposes ILocationRepository - directly as a plain DI service. - Not registered by API.Core at all; - only Database.Seed depends on it today - end note -} - -package "Shared" #E4E6D8 { - [Shared.Contracts] as SharedContracts - [Shared.Application] as SharedApp - note right of SharedApp - - ValidationBehavior - (MediatR pipeline) - - Cross-slice email commands - (SendRegistrationEmailCommand, - SendResendConfirmationEmailCommand) - end note -} - -package "Infrastructure Layer" #EBECE3 { - [Infrastructure.Sql] as Sql - [Infrastructure.Jwt] as JWT - [Infrastructure.PasswordHashing] as PwdHash - [Infrastructure.Email] as Email - [Infrastructure.Email.Templates] as EmailTemplates -} - -package "Domain Layer" #CBD2B5 { - [Domain.Entities] as Domain - [Domain.Exceptions] as DomainExceptions - note right of Domain - - UserAccount, UserCredential, UserVerification - - UserProfile, UserAvatar - - BreweryPost, BreweryPostLocation - - City, StateProvince, Country - - BeerPost, BeerStyle - (schema tables exist; no repository yet) - end note -} - -database "SQL Server" { - [Tables] as Tables -} - -node "Database.Seed\n(separate console app)" as Seed -note right of Seed - Seed calls each slice's - AddFeatures*() DI extension - directly - no API host, - no MediatR pipeline -end note - -' Relationships -API ..> UsersSlice : AddApplicationPart -API ..> BrewerySlice : AddApplicationPart -API ..> EmailsSlice : MediatR scan only - -Seed ..> UsersSlice : AddFeaturesUsers() -Seed ..> BrewerySlice : AddFeaturesBreweries() -Seed ..> LocationsSlice : AddFeaturesLocations() - -UsersSlice --> Sql -UsersSlice --> JWT -UsersSlice --> PwdHash -UsersSlice --> SharedContracts -UsersSlice --> SharedApp - -BrewerySlice --> Sql -BrewerySlice --> SharedContracts -BrewerySlice --> SharedApp - -LocationsSlice --> Sql - -EmailsSlice --> Email -EmailsSlice --> EmailTemplates -EmailsSlice --> SharedApp - -' The one cross-slice interaction: a MediatR command contract, not a project reference -UsersSlice ..> SharedApp : sends\nSendRegistrationEmailCommand -EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand - -Sql --> Domain - -UsersSlice --> Tables : Dapper SQL -BrewerySlice --> Tables -LocationsSlice --> Tables - -UsersSlice --> Domain -BrewerySlice --> Domain -LocationsSlice --> Domain -UsersSlice --> DomainExceptions - -' Notes -note left of Sql - SQL-first approach - Each slice's own repository issues - inline SQL via Dapper - no ORM, - no stored procedures -end note - -note bottom of UsersSlice - No Features.* project - ever references another - Features.* project directly -end note - -@enduml diff --git a/docs/website/diagrams-src/auth/confirm-user.puml b/docs/website/diagrams-src/auth/confirm-user.puml deleted file mode 100644 index 2692fbd0..00000000 --- a/docs/website/diagrams-src/auth/confirm-user.puml +++ /dev/null @@ -1,109 +0,0 @@ -@startuml confirm-user -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Confirm User - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "ConfirmUserHandler" as ConfirmHandler -participant "UserManager" as UserMgr -participant "TokenService" as TokenSvc -end box -box "Infrastructure Layer" #EBECE3 -participant "JWT\nInfrastructure" as JWT -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -User -> API: POST /api/auth/confirm?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 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 - -@enduml diff --git a/docs/website/diagrams-src/auth/delete-account.puml b/docs/website/diagrams-src/auth/delete-account.puml deleted file mode 100644 index 90cf4370..00000000 --- a/docs/website/diagrams-src/auth/delete-account.puml +++ /dev/null @@ -1,91 +0,0 @@ -@startuml delete-account -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Delete Account - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "DeleteAccountHandler" as DeleteAccountHandler -participant "UserManager" as UserMgr -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -User -> API: DELETE /api/auth/account\n(requires Bearer access token) -activate API - -API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId())) -activate Mediator - -Mediator -> DeleteAccountHandler: Handle(command) -activate DeleteAccountHandler - -DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId) -activate UserMgr -UserMgr -> UserStore: FindByIdAsync(userAccountId) -UserStore --> UserMgr: ApplicationUser or null -UserMgr --> DeleteAccountHandler: ApplicationUser or null -deactivate UserMgr - -alt Account not found - DeleteAccountHandler -> Mediator: throw NotFoundException - Mediator -> API: propagate - API -> User: 404 Not Found -else Account found - - DeleteAccountHandler -> UserMgr: DeleteAsync(user) - activate UserMgr - UserMgr -> UserStore: DeleteAsync(user) - activate UserStore - UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id - note right of UserStore - UserCredential/UserVerification/UserAvatar - cascade on delete. Posts, comments, photos, - and follows use ON DELETE NO ACTION - if any - reference this account, the DELETE throws a - foreign key violation (SQL error 547). - end note - - alt Foreign key violation (dependent records exist) - UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords) - else Delete succeeds - UserStore --> UserMgr: IdentityResult.Success - end - deactivate UserStore - UserMgr --> DeleteAccountHandler: IdentityResult - deactivate UserMgr - - alt Account has dependent records - DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows." - Mediator -> API: propagate - API -> User: 409 Conflict - else !result.Succeeded (other failure) - DeleteAccountHandler -> Mediator: throw ConflictException - Mediator -> API: propagate - API -> User: 409 Conflict - else result.Succeeded - DeleteAccountHandler --> Mediator: (void) - deactivate DeleteAccountHandler - Mediator --> API: (void) - deactivate Mediator - API -> User: 200 OK\n{message: "Account deleted successfully."} - end -end -deactivate API - -@enduml diff --git a/docs/website/diagrams-src/auth/error-handling.puml b/docs/website/diagrams-src/auth/error-handling.puml deleted file mode 100644 index ed99d309..00000000 --- a/docs/website/diagrams-src/auth/error-handling.puml +++ /dev/null @@ -1,30 +0,0 @@ -@startuml error-handling -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Error Handling (Global Exception Filter) - -participant "API\nController" as API - -note over API - API.Core's GlobalExceptionFilter catches: - - FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property) - - ConflictException -> 409 Conflict - - NotFoundException -> 404 Not Found - - UnauthorizedException -> 401 Unauthorized - - ForbiddenException -> 403 Forbidden - - Domain.Exceptions.ValidationException -> 400 Bad Request - - Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable - - All others -> 500 Internal Server Error -end note - -note over API - Applies uniformly across every authentication - endpoint (registration, login, refresh, confirm, - resend, update-username/email/password/profile, - delete-account) - see the per-flow diagrams for - where each exception type is thrown. -end note - -@enduml diff --git a/docs/website/diagrams-src/auth/login.puml b/docs/website/diagrams-src/auth/login.puml deleted file mode 100644 index d3c2fea3..00000000 --- a/docs/website/diagrams-src/auth/login.puml +++ /dev/null @@ -1,129 +0,0 @@ -@startuml login -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Login - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "LoginHandler" as LoginHandler -participant "UserManager" as UserMgr -participant "TokenService" as TokenSvc -end box -box "Infrastructure Layer" #EBECE3 -participant "Argon2PasswordHasher" as Argon2 -participant "JWT\nInfrastructure" as JWT -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -User -> API: POST /api/auth/login\n{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 -end note - -alt Validation fails - Mediator -> API: throws FluentValidation.ValidationException - API -> User: 400 Bad Request\n{message: "Validation failed", 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 UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username - activate DB - DB --> UserStore: UserAccount row or null - deactivate DB - UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed) - UserStore --> UserMgr: ApplicationUser or null - deactivate UserStore - UserMgr --> LoginHandler: ApplicationUser or null - deactivate UserMgr - - alt User not found - LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password." - 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 UserCredential was hydrated) - without calling the hasher; otherwise - delegates to Argon2PasswordHasher. - end note - UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password) - activate Argon2 - note right of Argon2 - Delegates to IPasswordInfrastructure.Verify: - 1. Split stored hash: "salt:hash" - 2. Hash provided password with the same salt - 3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals) - end note - Argon2 --> UserMgr: Success/Failed - deactivate Argon2 - UserMgr --> LoginHandler: true/false - deactivate UserMgr - - alt 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(\n userAccountId, username,\n refreshToken, accessToken) - deactivate LoginHandler - Mediator --> API: LoginPayload - deactivate Mediator - - API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n} - end - end - end -deactivate API - -@enduml diff --git a/docs/website/diagrams-src/auth/refresh-token.puml b/docs/website/diagrams-src/auth/refresh-token.puml deleted file mode 100644 index aef6a468..00000000 --- a/docs/website/diagrams-src/auth/refresh-token.puml +++ /dev/null @@ -1,108 +0,0 @@ -@startuml refresh-token -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Refresh Token - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "RefreshTokenHandler" as RefreshHandler -participant "UserManager" as UserMgr -participant "TokenService" as TokenSvc -end box -box #EBECE3 -participant "JWT\nInfrastructure" as JWT -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -User -> API: POST /api/auth/refresh\nHeader: X-Refresh-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 - -@enduml diff --git a/docs/website/diagrams-src/auth/registration.puml b/docs/website/diagrams-src/auth/registration.puml deleted file mode 100644 index c4ef5db9..00000000 --- a/docs/website/diagrams-src/auth/registration.puml +++ /dev/null @@ -1,241 +0,0 @@ -@startuml registration -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Registration - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "RegisterUserHandler" as RegHandler -participant "UserManager" as UserMgr -participant "TokenService" as TokenSvc -end box -box "Infrastructure Layer" #EBECE3 -participant "Argon2PasswordHasher" as Argon2 -participant "JWT\nInfrastructure" as JWT -participant "Email\nProvider" as SMTP -participant "Template\nProvider" as Template -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password} -activate API - -API -> Mediator: Send(RegisterUserCommand) -activate Mediator - -note right of Mediator - ValidationBehavior runs RegisterUserValidator - (FluentValidation) before the handler: - - Username: 3-64 chars, alphanumeric + [._-] - - Email: valid format, max 128 chars - - Password: min 8 chars, uppercase,\n lowercase, number, special char - - DateOfBirth: must be 19+ years old -end note - -alt Validation fails - Mediator -> API: throws FluentValidation.ValidationException - API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}} -else Validation succeeds - Mediator -> RegHandler: Handle(command) - activate RegHandler - - RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser() - - RegHandler -> UserMgr: CreateAsync(user, password) - activate UserMgr - - UserMgr -> Argon2: HashPassword(user, password) - activate Argon2 - note right of Argon2 - Delegates to IPasswordInfrastructure.Hash: - Argon2id parameters: - - Salt: 16 bytes (128-bit) - - Memory: 64MB - - Iterations: 4 - - Parallelism: CPU count - - Hash output: 32 bytes - end note - Argon2 --> UserMgr: "base64(salt):base64(hash)" - deactivate Argon2 - - UserMgr -> UserStore: SetPasswordHashAsync(user, hash) - note right of UserStore - user.Id is still empty at this point - - only sets the in-memory hash. CreateAsync - writes it to dbo.UserCredential itself. - end note - - UserMgr -> UserStore: FindByNameAsync(username) - activate UserStore - UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username - activate DB - DB --> UserStore: null (user doesn't exist) - deactivate DB - deactivate UserStore - - UserMgr -> UserStore: FindByEmailAsync(email) - activate UserStore - UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email - activate DB - DB --> UserStore: null (email doesn't exist) - deactivate DB - deactivate UserStore - note right of UserMgr - Default UserManager validators check - uniqueness before CreateAsync is called; - DapperUserStore.CreateAsync also guards - against a genuine race via a caught - duplicate-key SqlException (2601/2627). - end note - - UserMgr -> UserStore: CreateAsync(user) - activate UserStore - - UserStore -> DB: BEGIN TRANSACTION - activate DB - UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID - DB --> UserStore: UserAccountId (GUID) - UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash) - UserStore -> DB: COMMIT TRANSACTION - deactivate DB - note right of UserStore - Both statements run via Dapper's - ExecuteScalarAsync/ExecuteAsync against - a single DbTransaction, not a stored - procedure - no manual parameter binding - end note - - UserStore --> UserMgr: IdentityResult.Success\n(user.Id set) - deactivate UserStore - - UserMgr --> RegHandler: IdentityResult - deactivate UserMgr - - alt !result.Succeeded - RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions) - Mediator -> API: propagate - API -> User: 409 Conflict\n"Username or email already exists" - else result.Succeeded - - RegHandler -> TokenSvc: GenerateAccessToken(user) - activate TokenSvc - TokenSvc -> JWT: GenerateJwt(userId, username, expiry) - activate JWT - note right of JWT - JWT Configuration: - - Algorithm: HS256 - - Expires: 1 hour - - Claims: - * sub: userId - * unique_name: username - * iat / jti - end note - JWT -> JWT: Create JWT with claims - JWT -> JWT: 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 - Refresh Token: - - Expires: 21 days (504 hours) - - Same structure as access token,\n signed with a separate secret - end note - 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 - Confirmation Token: - - Expires: 30 minutes (0.5 hours) - - Signed with a third, separate secret - end note - JWT --> TokenSvc: Confirmation Token - deactivate JWT - TokenSvc --> RegHandler: Confirmation Token - deactivate TokenSvc - - RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract) - activate Mediator - Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken) - note right of Mediator - Routed to Features.Emails' - SendRegistrationEmailHandler, - the only cross-slice call in - the backend - end note - activate EmailDispatcher - - EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink) - activate Template - note right of Template - Razor Component: - - Header with branding - - Welcome message - - Confirmation button - - Footer - end note - Template -> Template: Render Razor component\nto HTML - Template --> EmailDispatcher: HTML email content - deactivate Template - - EmailDispatcher -> SMTP: SendAsync(email, subject, body) - activate SMTP - note right of SMTP - SMTP Configuration (MailKit): - - Host/port: from env (SMTP_HOST/SMTP_PORT) - - TLS: StartTLS when SMTP_USE_SSL=true - - Auth: username/password, if configured - end note - SMTP -> SMTP: Build MIME message - SMTP -> SMTP: Connect, authenticate,\nsend, 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),\nlog to stderr,\nemailSent = false - end - - RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent) - deactivate RegHandler - Mediator --> API: RegistrationPayload - deactivate Mediator - - API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n} - end -end -deactivate API - -@enduml diff --git a/docs/website/diagrams-src/auth/resend-confirmation.puml b/docs/website/diagrams-src/auth/resend-confirmation.puml deleted file mode 100644 index 8026ff96..00000000 --- a/docs/website/diagrams-src/auth/resend-confirmation.puml +++ /dev/null @@ -1,97 +0,0 @@ -@startuml resend-confirmation -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Resend Confirmation Email - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "ResendConfirmationEmailHandler" as ResendHandler -participant "UserManager" as UserMgr -participant "TokenService" as TokenSvc -end box -box "Infrastructure Layer" #EBECE3 -participant "Email\nProvider" as SMTP -participant "Template\nProvider" as Template -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -User -> API: POST /api/auth/confirm/resend?userId=\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 - -@enduml diff --git a/docs/website/diagrams-src/auth/update-email.puml b/docs/website/diagrams-src/auth/update-email.puml deleted file mode 100644 index 7ddd2e46..00000000 --- a/docs/website/diagrams-src/auth/update-email.puml +++ /dev/null @@ -1,93 +0,0 @@ -@startuml update-email -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Update Email - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "UpdateEmailHandler" as UpdateEmailHandler -participant "UserManager" as UserMgr -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -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 - -@enduml diff --git a/docs/website/diagrams-src/auth/update-password.puml b/docs/website/diagrams-src/auth/update-password.puml deleted file mode 100644 index 615402a0..00000000 --- a/docs/website/diagrams-src/auth/update-password.puml +++ /dev/null @@ -1,111 +0,0 @@ -@startuml update-password -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Update Password - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "UpdatePasswordHandler" as UpdatePasswordHandler -participant "UserManager" as UserMgr -end box -box "Infrastructure Layer" #EBECE3 -participant "Argon2PasswordHasher" as Argon2 -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -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 - -@enduml diff --git a/docs/website/diagrams-src/auth/update-profile.puml b/docs/website/diagrams-src/auth/update-profile.puml deleted file mode 100644 index 53421b99..00000000 --- a/docs/website/diagrams-src/auth/update-profile.puml +++ /dev/null @@ -1,92 +0,0 @@ -@startuml update-profile -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Update Profile - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "UpdateProfileHandler" as UpdateProfileHandler -participant "UserManager" as UserMgr -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -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 - -@enduml diff --git a/docs/website/diagrams-src/auth/update-username.puml b/docs/website/diagrams-src/auth/update-username.puml deleted file mode 100644 index 54752d6c..00000000 --- a/docs/website/diagrams-src/auth/update-username.puml +++ /dev/null @@ -1,94 +0,0 @@ -@startuml update-username -!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Update Username - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #F1F3EA -participant "UpdateUsernameHandler" as UpdateUsernameHandler -participant "UserManager" as UserMgr -end box -box "Repository / Cross-slice" #DBEEDD -participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore -end box -database "SQL Server" as DB - -note over UserMgr - ASP.NET Core Identity's UserManager - 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). -end note - -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 - -@enduml diff --git a/docs/website/diagrams-src/database-schema.puml b/docs/website/diagrams-src/database-schema.puml deleted file mode 100644 index 2c9ae97d..00000000 --- a/docs/website/diagrams-src/database-schema.puml +++ /dev/null @@ -1,202 +0,0 @@ -@startuml database-schema -!include ../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam linetype ortho - -title Database Schema -caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet. - -package "User & Auth" { - entity "UserAccount" as User { - * UserAccountID: UNIQUEIDENTIFIER <> - -- - * Username: VARCHAR(64) <> - * FirstName: NVARCHAR(128) - * LastName: NVARCHAR(128) - * Email: VARCHAR(128) <> - * CreatedAt: DATETIME - UpdatedAt: DATETIME - * DateOfBirth: DATE - } - - entity "UserCredential" as Cred { - * UserCredentialID: UNIQUEIDENTIFIER <> - -- - * UserAccountID: UNIQUEIDENTIFIER <> - * CreatedAt: DATETIME - * Expiry: DATETIME - * Hash: NVARCHAR(256) - * IsRevoked: BIT - RevokedAt: DATETIME - } - - entity "UserVerification" as Verify { - * UserVerificationID: UNIQUEIDENTIFIER <> - -- - * UserAccountID: UNIQUEIDENTIFIER <> - * VerificationDateTime: DATETIME - } - - entity "UserProfile" as Profile { - * UserProfileID: UNIQUEIDENTIFIER <> - -- - * UserAccountID: UNIQUEIDENTIFIER <> - * Biography: NVARCHAR(MAX) - } - - entity "UserAvatar" as Avatar { - * UserAvatarID: UNIQUEIDENTIFIER <> - -- - * UserProfileID: UNIQUEIDENTIFIER <> - * PhotoID: UNIQUEIDENTIFIER <> - * ValidFrom: DATETIME2(3) - ValidTo: DATETIME2(3) - .. - At most one row per UserProfileID - has ValidTo IS NULL (active avatar) - } - - entity "UserFollow (*)" as Follow { - * UserFollowID: UNIQUEIDENTIFIER <> - -- - * UserAccountID: UNIQUEIDENTIFIER <> - * FollowingID: UNIQUEIDENTIFIER <> - * CreatedAt: DATETIME - } - - entity "Photo (*)" as Photo { - * PhotoID: UNIQUEIDENTIFIER <> - -- - Hyperlink: NVARCHAR(256) - * UploadedByID: UNIQUEIDENTIFIER <> - * UploadedAt: DATETIME - } -} - -package "Location" { - entity "Country" as Country { - * CountryID: UNIQUEIDENTIFIER <> - -- - * CountryName: NVARCHAR(100) - * ISO3166_1: CHAR(2) <> - } - - entity "StateProvince" as StateProvince { - * StateProvinceID: UNIQUEIDENTIFIER <> - -- - * StateProvinceName: NVARCHAR(100) - * ISO3166_2: CHAR(6) <> - * CountryID: UNIQUEIDENTIFIER <> - } - - entity "City" as City { - * CityID: UNIQUEIDENTIFIER <> - -- - * CityName: NVARCHAR(100) - * StateProvinceID: UNIQUEIDENTIFIER <> - } -} - -package "Brewery" { - entity "BreweryPost" as Brewery { - * BreweryPostID: UNIQUEIDENTIFIER <> - -- - * BreweryName: NVARCHAR(256) - * PostedByID: UNIQUEIDENTIFIER <> - * Description: NVARCHAR(512) - * CreatedAt: DATETIME - UpdatedAt: DATETIME - } - - entity "BreweryPostLocation" as BreweryLoc { - * BreweryPostLocationID: UNIQUEIDENTIFIER <> - -- - * BreweryPostID: UNIQUEIDENTIFIER <> - * AddressLine1: NVARCHAR(256) - AddressLine2: NVARCHAR(256) - * PostalCode: NVARCHAR(20) - * CityID: UNIQUEIDENTIFIER <> - Coordinates: GEOGRAPHY - } - - entity "BreweryPostPhoto (*)" as BreweryPhoto { - * BreweryPostPhotoID: UNIQUEIDENTIFIER <> - -- - * BreweryPostID: UNIQUEIDENTIFIER <> - * PhotoID: UNIQUEIDENTIFIER <> - * LinkedAt: DATETIME - } -} - -package "Beer (*)" { - entity "BeerStyle (*)" as BeerStyle { - * BeerStyleID: UNIQUEIDENTIFIER <> - -- - * StyleName: NVARCHAR(100) <> - Description: NVARCHAR(MAX) - } - - entity "BeerPost (*)" as Beer { - * BeerPostID: UNIQUEIDENTIFIER <> - -- - * Name: NVARCHAR(100) - * Description: NVARCHAR(MAX) - * ABV: DECIMAL(4,2) - * IBU: INT - * PostedByID: UNIQUEIDENTIFIER <> - * BeerStyleID: UNIQUEIDENTIFIER <> - * BrewedByID: UNIQUEIDENTIFIER <> - * CreatedAt: DATETIME - UpdatedAt: DATETIME - } - - entity "BeerPostPhoto (*)" as BeerPhoto { - * BeerPostPhotoID: UNIQUEIDENTIFIER <> - -- - * BeerPostID: UNIQUEIDENTIFIER <> - * PhotoID: UNIQUEIDENTIFIER <> - * LinkedAt: DATETIME - } - - entity "BeerPostComment (*)" as BeerComment { - * BeerPostCommentID: UNIQUEIDENTIFIER <> - -- - * Comment: NVARCHAR(250) - * BeerPostID: UNIQUEIDENTIFIER <> - * CommentedByID: UNIQUEIDENTIFIER <> - * Rating: INT - * CreatedAt: DATETIME - UpdatedAt: DATETIME - } -} - -' Relationships: User & Auth -User ||--o{ Cred -User ||--o| Verify -User ||--o| Profile -Profile ||--o{ Avatar -Avatar }o--|| Photo -User ||--o{ Follow -User ||--o{ Follow -User ||--o{ Photo - -' Relationships: Location -Country ||--o{ StateProvince -StateProvince ||--o{ City - -' Relationships: Brewery -User ||--o{ Brewery -Brewery ||--o| BreweryLoc -City ||--o{ BreweryLoc -Brewery ||--o{ BreweryPhoto -Photo ||--o{ BreweryPhoto - -' Relationships: Beer -User ||--o{ Beer -BeerStyle ||--o{ Beer -Brewery ||--o{ Beer -Beer ||--o{ BeerPhoto -Photo ||--o{ BeerPhoto -Beer ||--o{ BeerComment -User ||--o{ BeerComment - -@enduml diff --git a/docs/website/diagrams-src/deployment.puml b/docs/website/diagrams-src/deployment.puml deleted file mode 100644 index a212554f..00000000 --- a/docs/website/diagrams-src/deployment.puml +++ /dev/null @@ -1,330 +0,0 @@ -@startuml deployment -!include ../../pipeline/diagrams/biergarten-weizen-theme.puml -skinparam linetype ortho - -title Docker Deployment Architecture - -' External systems -actor Developer -cloud "Docker Host" as Host - -package "Development Environment\n(docker-compose.dev.yaml)" #F1F3EA { - - node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB { - database "Biergarten\nDatabase" as DevDBInner { - portin "1433" - } - note right - Environment: - - ACCEPT_EULA=Y - - SA_PASSWORD=*** - - MSSQL_PID=Developer - - Volumes: - - biergarten-dev-data - end note - } - - node "API Container\n(API.Core)" as DevAPI { - component "ASP.NET Core 10" as API1 - portin "8080:8080 (HTTP)" as DevPort1 - portin "8081:8081 (HTTPS)" as DevPort2 - - note right - Environment: - - ASPNETCORE_ENVIRONMENT=Development - - DB_SERVER=sql-server - - DB_NAME=Biergarten - - DB_USER/PASSWORD - - JWT_SECRET - - SMTP_* (10+ variables) - - Health Check: - /health endpoint - end note - } - - node "Migrations\n(run-once)" as DevMig { - component "Database.Migrations" as Mig1 - note bottom - Runs: DbUp migrations - Environment: - - CLEAR_DATABASE=false - Depends on: sql-server - end note - } - - node "Seed\n(run-once)" as DevSeed { - component "Database.Seed" as Seed1 - note bottom - Creates: - - 100 test users - - Location data (US/CA/MX) - - test.user account - Depends on: migrations - 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)" #EBECE3 { - - node "SQL Server\n(isolated instance)" as TestDB { - database "Biergarten\nTest Database" as TestDBInner { - portin "1434" - } - note right - Fresh instance each run - CLEAR_DATABASE=true - - Volumes: - - biergarten-test-data - (ephemeral) - end note - } - - node "Migrations\n(test)" as TestMig { - component "Database.Migrations" - } - - node "Seed\n(test)" as TestSeed { - component "Database.Seed" - note bottom - Minimal seed: - - test.user only - - Essential data - end note - } - - node "API.Specs\n(Integration Tests)" as Specs { - component "Reqnroll + xUnit" as SpecsComp - note right - Tests: - - Registration flow - - Login flow - - Validation rules - - 404 handling - - Uses: TestApiFactory - Mocks: Email services - end note - } - - node "unit.tests\n(Features.*.Tests, one container)" as UnitTests { - component "xUnit + Moq" as UnitComp - note right - Builds Dockerfile.tests, which - restores/builds against Core.slnx - then runs `dotnet test` for every - Features/*.Tests project in turn: - - Features.Users.Tests - - Features.Breweries.Tests - - Features.Emails.Tests - - Uses: Moq to mock each handler's - repository/service dependencies - No real database needed - end note - } - -} - -package "Production Environment\n(docker-compose.prod.yaml)" #CBD2B5 { - - 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 - Prod Network (bridge: prodnet) - Isolated from dev/test networks. - Volumes: sqlserverdata-prod, - nuget-cache-prod - end note -} - -package "Database-only Environment\n(docker-compose.db.yaml)" #E4E6D8 { - - 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)" #DBEEDD { - - node "SQL Server" as MinDB { - database "Biergarten\nDatabase" as MinDBInner { - portin "1433" - } - } - - node "Migrations\n(run-once)" as MinMig { - component "Database.Migrations" - } - - node "Mailpit" as MinMailpit { - portin "8025 / 1025" - } - - node "Seed\n(run-once)" as MinSeed { - component "Database.Seed" - } - - note bottom of MinDB - Database + mailpit only, no API - container - for frontend development - against an API.Core process run - outside Docker. - end note - - MinMig --> MinDB : 1. Run migrations - MinSeed --> MinDB : 2. Seed data - MinMig .up.> MinDB : depends_on - MinSeed .up.> MinMig : depends_on -} - -folder "test-results/\n(mounted volume)" as Results { - file "api-specs/\n results.trx" as Result1 - file "Features.Users.Tests.trx" as Result2 - file "Features.Breweries.Tests.trx" as Result3 - file "Features.Emails.Tests.trx" as Result5 - - note bottom - TRX format - Readable by: - - Visual Studio - - Azure DevOps - - GitHub Actions - end note -} - -' External access -Developer --> Host : docker compose up -Host --> DevAPI : http://localhost:8080 - -' Development dependencies -DevMig --> DevDB : 1. Run migrations -DevSeed --> DevDB : 2. Seed data -DevAPI --> DevDB : 3. Connect & serve -DevAPI ..> DevMailpit : 4. SMTP_HOST/PORT -DevMig .up.> DevDB : depends_on -DevSeed .up.> DevMig : depends_on -DevAPI .up.> DevSeed : depends_on - -' Test dependencies -TestMig --> TestDB : 1. Migrate -TestSeed --> TestDB : 2. Seed -Specs --> TestDB : 3. Integration test -UnitTests ..> TestDB : depends_on for startup\nordering only (Moq mocks,\nno real connection) - -TestMig .up.> TestDB : depends_on -TestSeed .up.> TestMig : depends_on -Specs .up.> TestSeed : depends_on -UnitTests .up.> TestSeed : depends_on - -' Test results export -Specs --> Results : Export TRX -UnitTests --> Results : Export TRX (one .trx per slice) - -' Network notes -note bottom of DevDB - Dev Network (bridge: biergarten-dev) - Internal DNS: - - sql-server (resolves to SQL container) - - api (resolves to API container) -end note - -note bottom of TestDB - Test Network (bridge: biergarten-test) - All test components isolated -end note - -' Startup sequence notes -note top of DevMig - Startup Order: - 1. SQL Server (health check) - 2. Migrations (run-once) - 3. Seed (run-once) - 4. API (long-running) -end note - -note top of Specs - Test Execution: - All tests run in parallel - Results aggregated -end note - -@enduml