diff --git a/docs/pipeline/diagrams/biergarten-weizen-theme.puml b/docs/pipeline/diagrams/biergarten-weizen-theme.puml index b31305d6..b85f22c9 100644 --- a/docs/pipeline/diagrams/biergarten-weizen-theme.puml +++ b/docs/pipeline/diagrams/biergarten-weizen-theme.puml @@ -32,3 +32,24 @@ 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 index 998a742b..cd4803d7 100644 --- a/docs/pipeline/diagrams/current/activity.puml +++ b/docs/pipeline/diagrams/current/activity.puml @@ -1,22 +1,9 @@ @startuml +!include ../biergarten-weizen-theme.puml skinparam style strictuml -skinparam defaultFontName "DM Sans" skinparam defaultFontSize 14 -skinparam titleFontName "Volkhov" skinparam titleFontSize 20 -skinparam backgroundColor #FAFCF9 -skinparam defaultFontColor #28342A -skinparam titleFontColor #28342A -skinparam ArrowColor #628A5B -skinparam NoteBackgroundColor #EAF0E8 -skinparam NoteBorderColor #547461 -skinparam ActivityBackgroundColor #FAFCF9 -skinparam ActivityBorderColor #547461 -skinparam ActivityDiamondBackgroundColor #FAFCF9 -skinparam ActivityDiamondBorderColor #628A5B skinparam ActivityBarColor #628A5B -skinparam SwimlaneBorderColor #547461 -skinparam SwimlaneBorderThickness 0.3 title The Biergarten Data Pipeline (Current — Synchronous Data Path) diff --git a/docs/pipeline/diagrams/current/class.puml b/docs/pipeline/diagrams/current/class.puml index 935274a6..f7a8d482 100644 --- a/docs/pipeline/diagrams/current/class.puml +++ b/docs/pipeline/diagrams/current/class.puml @@ -1,27 +1,8 @@ @startuml +!include ../biergarten-weizen-theme.puml skinparam style strictuml -skinparam defaultFontName "DM Sans" skinparam defaultFontSize 14 -skinparam titleFontName "Volkhov" skinparam titleFontSize 20 -skinparam backgroundColor #FAFCF9 -skinparam defaultFontColor #28342A -skinparam titleFontColor #28342A -skinparam ArrowColor #628A5B - -skinparam class { - BackgroundColor #FAFCF9 - HeaderBackgroundColor #EAF0E8 - BorderColor #547461 - ArrowColor #628A5B - FontColor #28342A -} - -skinparam note { - BackgroundColor #EAF0E8 - BorderColor #547461 - FontColor #28342A -} title The Biergarten Data Pipeline - Class Diagram diff --git a/docs/pipeline/diagrams/current/output/activity.svg b/docs/pipeline/diagrams/current/output/activity.svg deleted file mode 100644 index 858ca813..00000000 --- a/docs/pipeline/diagrams/current/output/activity.svg +++ /dev/null @@ -1 +0,0 @@ -The Biergarten Data Pipeline (Current — Synchronous Data Path)The Biergarten Data Pipeline (Current — Synchronous Data Path)Create BoundedChannel<LogEntry> log_channelThe only concurrency in the current pipeline:a dedicated thread drains log_channel andforwards entries to spdlog for the entire run.Joined during shutdown after log_channel closes.Spawn log dispatcher thread(LogDispatcher::Run())ParseArguments(argc, argv)Log error / usage infonoAre arguments valid?yesInit OpenSSL global state & LlamaBackendStateBinds ILogger, IEnrichmentService, DataGenerator,IExportService, IPromptFormatter, WebClient basedon --mocked vs. model-backed mode.di::make_injector(...)injector.create<std::unique_ptr<BiergartenPipelineOrchestrator>>()Run() itself is synchronous — sampling, enrichment,generation, and export all happen on this thread.BiergartenPipelineOrchestrator::Run()Close log_channelJoin log dispatcher threadReturn 0Initialize SQLite exportQueryLocations()Lookup failed?yesnoLog warning, skip cityStore EnrichedCity{Location, region_context}Remaining citiesFor each sampled Location?DoneGenerateUsers(enriched_cities)SampleName(forenames_by_country,surnames_by_country, location.iso3166_1)Names available for country?noyesLog warning, skip citySample UserPersona (uniform random)Generation successful?yesnoGetRandomCoordsWithinRange(city_centre, 5km, rng)BuildEmail(name, used_email_local_parts)email and date_of_birth are generatedprogrammatically, never LLM-authored.Generation + coordinate jitter run insideone try/catch per record. UserRecord.addressnow carries {city, longitude, latitude}.GenerateDateOfBirth(rng)Log warning "SQLite export failed"Log warning "Generation failed, skipping..."Remaining citiesFor each EnrichedCity?DoneGenerateBreweries(enriched_cities)Generation successful?yesnoGeneration + coordinate jitter run inside onetry/catch per record. BreweryRecord.addressnow carries {city, longitude, latitude}.GetRandomCoordsWithinRange(city_centre, 5km, rng)Data loss is prevented per-record.The pipeline continues running.Log warning "SQLite export failed"Log warning "Generation failed, skipping..."GetUtcTimestamp() from SystemDateTimeProviderBuilds a fresh biergarten_seed_<UTC datetime>.sqlite filenameAppends a numeric suffix if the timestamp already existsOpens DB ConnectionExecutes Schema DDLBegins TransactionInitialize()ProcessRecord(UserRecord)Location in cache?yesnoReuse location_idInsert Location & Cache IDInsert User (FK: location_id)yesException caught during insert?noProcessRecord(GeneratedBrewery)Location in cache?yesnoReuse location_idInsert Location & Cache IDInsert Brewery (FK: location_id)yesException caught during insert?noCommits TransactionCloses Database ConnectionFinalize()JsonLoader::LoadLocations("locations.json")--location-count defaults to 10.std::ranges::sample(all_locations, --location-count)LoadPersonas("personas.json")LoadForenamesByCountry("forenames-by-country.json")Each call is cached after its first parse.MockCuratedDataService (--mocked) returns afixed in-memory dataset instead and skipsthese three JSON files entirely.LoadSurnamesByCountry("surnames-by-country.json")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.GetLocationContext(loc)MockGeneratorGenerator ModeDeterministicHash & FormatLlamaGeneratorGenerator ModeOpenAIGeneratorResolves to USER_GENERATION.md inside --prompt-dir.prompt_directory_->Load("USER_GENERATION")Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar)ValidateUserJson(raw, user)Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 3?yesSame prompt file as LlamaGenerator - contentis backend-agnostic.prompt_directory_->Load("USER_GENERATION")CallChatCompletionsApi(system_prompt, user_prompt,kUserJsonSchema, max_tokens)ValidateUserJson(raw, user)Structured Outputs (response_format:json_schema, strict: true) alreadyguarantees schema shape, so only 2attempts are needed - retries here covertransient failures / placeholder text,not malformed JSON.Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 2?yesMockGeneratorGenerator ModeDeterministicHash & FormatLlamaGeneratorGenerator ModeOpenAIGeneratorPrepareRegionContextResolves to BREWERY_GENERATION.md inside --prompt-dir.prompt_directory_->Load("BREWERY_GENERATION")Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar)ValidateBreweryJson(raw, brewery)max_tokens is fixed across retries —it is not raised on truncation.Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 3?yesPrepareRegionContextSame prompt file as LlamaGenerator - contentis backend-agnostic.prompt_directory_->Load("BREWERY_GENERATION")CallChatCompletionsApi(system_prompt, user_prompt,kBreweryJsonSchema, max_tokens)ValidateBreweryJson(raw, brewery)Structured Outputs already guarantees schemashape, so only 2 attempts are needed - retrieshere cover transient failures / placeholdertext, not malformed JSON.Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 2?yesRemaining citiesFor each EnrichedCity?Donemain.ccBiergartenPipelineOrchestratorSqliteExportServiceJsonLoaderIEnrichmentServiceDataGenerator \ No newline at end of file diff --git a/docs/pipeline/diagrams/current/output/class.svg b/docs/pipeline/diagrams/current/output/class.svg deleted file mode 100644 index b4751c3a..00000000 --- a/docs/pipeline/diagrams/current/output/class.svg +++ /dev/null @@ -1 +0,0 @@ -The Biergarten Data Pipeline - Class DiagramThe Biergarten Data Pipeline - Class DiagramBiergartenPipelineOrchestratorlogger_ : std::shared_ptr<ILogger>context_service_ : std::unique_ptr<IEnrichmentService>generator_ : std::unique_ptr<DataGenerator>exporter_ : std::unique_ptr<IExportService>curated_data_service_ : std::unique_ptr<ICuratedDataService>application_options_ : ApplicationOptionsgenerated_breweries_ : std::vector<BreweryRecord>generated_users_ : std::vector<UserRecord>Run() : boolQueryLocations() : std::vector<City>GenerateBreweries(cities : std::span<const EnrichedCity>) : voidGenerateUsers(cities : std::span<const EnrichedCity>) : voidLogResults() const : voidGenerateBreweries and GenerateUsers both call thefree function GetRandomCoordsWithinRange()(biergarten_pipeline_orchestrator/coords.h) to jittera coordinate pair uniformly within 5km of the citycentre for each generated record's address.«enumeration»LogLevelDebugInfoWarnError«enumeration»PipelinePhaseStartupEnrichmentUserGenerationBreweryAndBeerGenerationCheckinGenerationRatingGenerationFollowGenerationTeardownLogDTOlevel : LogLevelphase : PipelinePhasemessage : std::stringLogEntrytimestamp : std::chrono::system_clock::time_pointorigin : std::source_locationthread_id : std::thread::idlevel : LogLevelphase : PipelinePhasemessage : std::string«interface»ILoggerLog(payload : LogDTO) : voidDoLog(entry : LogEntry) : voidLogProducerchannel_ : BoundedChannel<LogEntry>&DoLog(entry : LogEntry) : voidLogDispatcherchannel_ : BoundedChannel<LogEntry>&Run() : voidToSpdlogLevel(level) : spdlog::level::level_enum«interface»IEnrichmentServiceGetLocationContext(loc : const Location&) : std::stringMockEnrichmentServiceGetLocationContext(loc : const Location&) : std::stringWikipediaEnrichmentServiceclient_ : std::unique_ptr<WebClient>extract_cache_ : std::unordered_map<std::string, std::string>GetLocationContext(loc : const Location&) : std::stringFetchExtract(query : std::string_view) : std::string«interface»WebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::stringHttpWebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::string«interface»DataGeneratorGenerateBrewery(city : const EnrichedCity&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultMockGeneratorGenerateBrewery(city : const EnrichedCity&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultDeterministicHash(location : const Location&) : size_tDeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_tLlamaGeneratormodel_ : ModelHandlecontext_ : ContextHandleprompt_formatter_ : std::unique_ptr<IPromptFormatter>prompt_directory_ : std::unique_ptr<IPromptDirectory>rng_ : std::mt19937GenerateBrewery(...) : BreweryResultGenerateUser(...) : UserResultLoad(model_path : const std::string&) : voidInfer(...) : std::stringInferFormatted(...) : std::stringOpenAIGeneratorapi_key_ : std::stringmodel_ : std::stringlogger_ : std::shared_ptr<ILogger>prompt_directory_ : std::unique_ptr<IPromptDirectory>web_client_ : std::unique_ptr<WebClient>GenerateBrewery(city : const EnrichedCity&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultCallChatCompletionsApi(...) : std::stringChat Completions with Structured Outputs(response_format: json_schema, strict: true)guarantees schema-valid JSON, replacingllama.cpp's GBNF grammar constraint -no IPromptFormatter needed.«interface»IPromptFormatterFormat(system_prompt : std::string_view, user_prompt : std::string_view) : std::stringGemma4JinjaPromptFormatterFormat(system_prompt : std::string_view, user_prompt : std::string_view) : std::string«interface»IPromptDirectoryLoad(key : std::string_view) : std::stringPromptDirectoryprompt_dir_ : std::filesystem::pathcache_ : std::unordered_map<std::string, std::string>Load(key : std::string_view) : std::string«interface»ICuratedDataServiceLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&JsonLoadercache_ : cacheLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Each Load* method memoizes its result incache_ on first call; later calls on thesame instance return the cached valuewithout re-parsing.MockCuratedDataServicelocations_ : std::vector<Location>personas_ : std::vector<UserPersona>forenames_by_country_ : std::unordered_map<std::string, forename_list>surnames_by_country_ : std::unordered_map<std::string, surname_list>LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Fixed 4-location / 3-persona dataset(US, DE, FR, BE) used in --mocked runs;filepath arguments are ignored.«interface»IExportServiceInitialize() : voidProcessRecord(brewery : const BreweryRecord&) : uint64_tProcessRecord(user : const UserRecord&) : uint64_tFinalize() : voidSqliteExportServicedate_time_provider_ : std::unique_ptr<IDateTimeProvider>run_timestamp_utc_ : std::stringdatabase_path_ : std::filesystem::pathdb_handle_ : SqliteDatabaseHandleinsert_location_stmt_ : SqliteStatementHandleinsert_brewery_stmt_ : SqliteStatementHandleinsert_user_stmt_ : SqliteStatementHandletransaction_open_ : boollocation_cache_ : std::unordered_map<std::string, sqlite3_int64>Initialize() : voidProcessRecord(brewery : const BreweryRecord&) : uint64_tProcessRecord(user : const UserRecord&) : uint64_tFinalize() : voidInitializeSchema() : voidResolveLocationId(location : const Location&) : sqlite3_int64«interface»IDateTimeProviderGetUtcTimestamp() : std::stringSystemDateTimeProviderGetUtcTimestamp() : std::stringownsownsownsownsownsimplementsemitsconsumesimplementsimplementsownsimplementsimplementsimplementsimplementsusesusesusesusesimplementsimplementsimplementsimplementsimplementsownsimplements \ No newline at end of file diff --git a/docs/pipeline/diagrams/planned/output/biergarten_activity.svg b/docs/pipeline/diagrams/planned/output/biergarten_activity.svg deleted file mode 100644 index e565241d..00000000 --- a/docs/pipeline/diagrams/planned/output/biergarten_activity.svg +++ /dev/null @@ -1 +0,0 @@ -The Biergarten Data Pipeline — Activity DiagramParseArguments(argc, argv)spdlog::erroryesInvalid args?noInit OpenSSL global state & LlamaBackendStateBuild DI injectorOpens SQLite connection.(Transactions are now managedper-phase via batching).Initialize SqliteExportServiceCreate BoundedChannel<LogEntry> log_chLog worker drains log_ch for theentire pipeline lifetime.All workers emit LogEntry structsvia PipelineLogger -- never spdlog directly.Spawn Log Worker threadBiergartenPipelineOrchestrator::Run()spdlog::info "Pipeline complete in X ms"Drain guarantees no LogEntry isdropped at shutdown.Join Log WorkerJsonLoader::LoadBeerStyles("beer-styles.json")EnrichmentService::PreWarmBeerStyleCache(beer_styles)JsonLoader::LoadLocations("locations.json")EnrichmentService::PreWarmLocationCache(sampled_locations)Each call is memoized after its first parse(implemented today). MockCuratedDataServicereturns a fixed in-memory dataset insteadunder --mocked, skipping these JSON files.JsonLoader::LoadForenamesByCountry("forenames-by-country.json")JsonLoader::LoadSurnamesByCountry("surnames-by-country.json")JsonLoader::LoadPersonas("personas.json")RunUserPhase(sampled_locations)Create BoundedChannels(loc_ch, exp_ch)Loop: Send Locations -> loc_chProducer closes loc_ch.LLM Worker while loopterminates on empty + closed.Close loc_chJoin LLM Worker, SQLite WorkerRunBreweryPhase(sampled_locations)Create BoundedChannels(loc_ch, exp_ch)Loop: Sample User from user_pool_and pair with LocationSend BreweryTask(Location, User) -> loc_chClose loc_chbrewery_pool_ is now fully populated.Phase 1b may begin.Join LLM Worker, SQLite WorkerRunBeerPhase()Create BoundedChannels(brew_ch, exp_ch)Loop: Send Breweries -> brew_chClose brew_chBoth brewery_pool_ and beer_pool_are now completely populated.Checkin and Follow phases maynow run in parallel.Join LLM Worker, SQLite WorkerRunCheckinPhase()Weights seeded from each user'spersona.checkin_weight. J-curve profileemerges from persona distribution.ICheckinDistributionStrategy::AssignActivityWeights(user_pool_)BEGIN TRANSACTIONCheckinsForUser(user, brewery_pool_.size())TimestampFor(user, index)Select brewery from brewery_pool_GenerateCheckin(user, brewery, timestamp)via DataGeneratorProcessCheckin(checkin)PipelineLogger::Log(Info, CheckinGeneration,nullopt, checkin_id, "sqlite")Append -> checkin_pool_COMMIT & BEGINyesBatch size reached?noremainingFor each checkin index?doneremainingFor each GeneratedUser in user_pool_?doneCOMMIT (Final)RunFollowPhase()For RandomFollowStrategy, weightsare uniform. For ActivityWeightedFollowStrategy,weights derived from user.activity_weightso high-activity users attract more followers.IFollowGenerationStrategy::AssignFollowWeights(user_pool_)BEGIN TRANSACTIONSelf-follow constraint (follower_id != followed_id)enforced here and at the DB schema level.IFollowGenerationStrategy::GenerateFollows(user_pool_)ProcessFollow(follow)PipelineLogger::Log(Info, FollowGeneration,nullopt, follower_id, "sqlite")Append -> follow_pool_COMMIT & BEGINyesBatch size reached?noremainingFor each GeneratedFollow?doneCOMMIT (Final)checkin_pool_ and follow_pool_are now fully populated.Rating phase may begin.Join CheckinPhase, FollowPhaseBeer selection biased byuser.persona.style_affinities and abv_range.Rating skew modulated per persona.RunRatingPhase()BEGIN TRANSACTIONMatch brewery_id, select beer from beer_pool_(same brewery_id, biased by persona affinities)Beer exists for brewery?yesnoGenerateRating(user, beer, checkin_id)via DataGeneratorProcessRating(rating)PipelineLogger::Log(Info, RatingGeneration,nullopt, rating_id, "sqlite")COMMIT & BEGINyesBatch size reached?noPipelineLogger::Log(Warn, RatingGeneration,nullopt, brewery_id, "sqlite")Skip -- brewery has no beersremainingFor each GeneratedCheckin in checkin_pool_?doneCOMMIT (Final)Safely closes the DB connection.Finalize SqliteExportServiceClose log_chReceive LocationGuaranteed cache hit from startup.GetLocationContextFromCache(location)Guaranteed cache hit from startup.Returns a Persona struct carryingstyle_affinities, abv_range,ibu_preference, checkin_weight.IPersonaSelectionStrategy::SelectPersona(personas_palette_)Deterministic lookup -- no LLM involved.Free function (implemented today ingenerate_users.cc), not a class method.Name selected from pre-keyed mapsand passed into the generation prompt.Skips the city if either map has noentry for iso3166_1.SampleName(forenames_by_country,surnames_by_country, location.iso3166_1, rng)LLM receives: EnrichedCity context + personadescription + sampled name. Generatesbio and preference signals groundedin locale and persona.GenerateUser(enriched_city, persona, sampled_name)via DataGeneratorPipelineLogger::Log(Info, UserGeneration,city, user_id, "llm")Send GeneratedUser -> exp_chyesloc_ch has items?noProducer closes exp_ch.SQLite Worker while loopterminates on empty + closed.Close exp_chReceive BreweryTask(Location, User)Guaranteed cache hit from startup.GetLocationContextFromCache(task.location)KV cache stays warm.Brewery is linked to the sampled owner_user_id.GenerateBrewery(enriched_city, context, task.user)via DataGeneratorPipelineLogger::Log(Info,BreweryGeneration,city, brewery_id, "llm")Send GeneratedBrewery -> exp_chyesloc_ch has items?noClose exp_chReceive GeneratedBreweryIBeerSelectionStrategy::SelectStyles(brewery, beer_style_palette_)Guaranteed cache hit from startup.KV cache stays warm across allbeer generations -- system promptdoes not change within this phase.GetStyleContextFromCache(style)GenerateBeer(brewery, style_context)via DataGeneratorAttach GeneratedBeer to bundleremainingFor each selected BeerStyle?donePipelineLogger::Log(Info,BeerGeneration,city, brewery_id, "llm")Send BeersBundle -> exp_chyesbrew_ch has items?noClose exp_chBEGIN TRANSACTIONReceive GeneratedUserProcessUser(user)PipelineLogger::Log(Info, UserGeneration,city, user_id, "sqlite")Append -> user_pool_COMMIT & BEGINyesBatch size reached?noyesexp_ch has items?noCOMMIT (Final)BEGIN TRANSACTIONReceive GeneratedBreweryProcessBrewery(brewery)PipelineLogger::Log(Info,BreweryGeneration,city, brewery_id, "sqlite")Append -> brewery_pool_COMMIT & BEGINyesBatch size reached?noyesexp_ch has items?noCOMMIT (Final)BEGIN TRANSACTIONReceive BeersBundleSet beer.brewery_id from bundleProcessBeer(beer)Append -> beer_pool_remainingFor each beer in bundle?donePipelineLogger::Log(Info,BeerGeneration,city, brewery_id, "sqlite")COMMIT & BEGINyesBatch size reached?noyesexp_ch has items?noCOMMIT (Final)MainBiergartenPipelineOrchestrator::Run()OrchestratorLLM WorkerSQLite Worker \ No newline at end of file diff --git a/docs/pipeline/diagrams/planned/output/class.svg b/docs/pipeline/diagrams/planned/output/class.svg deleted file mode 100644 index f796ee6e..00000000 --- a/docs/pipeline/diagrams/planned/output/class.svg +++ /dev/null @@ -1 +0,0 @@ -Biergarten Data Pipeline — Class DiagramDomain: ModelsDomain: Application ConfigurationDomain: PolicyInfrastructure: LoggingInfrastructure: Pipeline ChannelInfrastructure: Data PreloadingInfrastructure: EnrichmentInfrastructure: PromptingInfrastructure: Data GenerationInfrastructure: Data ExportLocationcity : std::stringstate_province : std::stringiso3166_2 : std::stringcountry : std::stringiso3166_1 : std::stringlocal_languages : std::vector<std::string>latitude : doublelongitude : doubleLocationContexttext : std::stringcompleteness : Completenesschar_count : size_tCompletenessFullPartialAbsentEnrichedCitylocation : Locationcontext : LocationContextBeerStylename : std::stringdescription : std::stringmin_abv : floatmax_abv : floatmin_ibu : intmax_ibu : intBreweryResultname_en : std::stringdescription_en : std::stringname_local : std::stringdescription_local : std::stringBeerResultname_en : std::stringdescription_en : std::stringname_local : std::stringdescription_local : std::stringstyle : std::stringabv : floatibu : intUserResultfirst_name : std::stringlast_name : std::stringgender : std::stringusername : std::stringbio : std::stringactivity_weight : floatNamefirst_name : std::stringlast_name : std::stringgender : std::stringForenameEntryname : std::stringgender : std::stringforename_list = std::unordered_set<ForenameEntry>surname_list = std::unordered_set<std::string>(aliases declared in curated_data_service.h).ICuratedDataService::LoadForenamesByCountry() /LoadSurnamesByCountry() each return a flatstd::unordered_map<std::string, forename_list>or <std::string, surname_list> keyed by ISO3166-1 country code -- no NamesByCountrywrapper class. SampleName(forenames_by_country,surnames_by_country, iso3166_1, rng) is a freefunction, not a method, so it can be called byany per-city worker without owning the maps.Sourced from popular-names-by-country-dataset(CC0) -- see ETHICS-AND-KNOWN-ISSUES.md. Genderis preserved per forename so it can be threadedthrough to persona/bio generation later; surnamescarry no gender in the source data. Pairinghappens at sample time, not at fixture-authoringtime, so no information from the source datasetis discarded up front.CheckinResultchecked_in_at : std::stringnote : std::stringRatingResultscore : floatnote : std::stringGenerationMetadatageneration_id : uint64_tgenerated_time : std::stringcontext_provided : boolgenerated_with : std::stringGeneratedBrewerybrewery_id : uint64_tlocation : Locationbrewery : BreweryResultcontext_completeness : LocationContext::Completenessmetadata : GenerationMetadataGeneratedBeerbeer_id : uint64_tbrewery_id : uint64_tlocation : Locationstyle : BeerStylebeer : BeerResultmetadata : GenerationMetadataGeneratedUseruser_id : uint64_tlocation : Locationuser : UserResultemail : std::stringdate_of_birth : std::stringpassword : std::stringmetadata : GenerationMetadataemail, date_of_birth, and password areprogrammatically generated by the orchestrator(not LLM-authored) so a downstream auth-accountseeding consumer can register real accounts fromthis export. See ROADMAP.md §1.GeneratedCheckincheckin_id : uint64_tuser_id : uint64_tbrewery_id : uint64_tcheckin : CheckinResultmetadata : GenerationMetadataGeneratedRatinguser_id : uint64_tbeer_id : uint64_tcheckin_id : uint64_trating : RatingResultmetadata : GenerationMetadataGeneratedFollowfollower_id : uint64_tfollowed_id : uint64_tmetadata : GenerationMetadataUserPersonaname: std::stringdescription: std::stringstyle_affinities: std::vector<std::string>SamplingOptionstemperature: float = 1.0Ftop_p: float = 0.95Ftop_k: uint32_t = 64n_ctx: uint32_t = 8192seed: int = -1GeneratorOptionsmodel_path: std::filesystem::pathuse_mocked: bool = falsesampling: std::optional<SamplingOptions>PipelineOptionsoutput_path: std::filesystem::pathlog_path: std::filesystem::pathApplicationOptionsgenerator: GeneratorOptionspipeline: PipelineOptions«interface»ContextStrategyQueriesFor(loc : const Location&) : std::vector<std::string>MaxContextChars() : size_tBreweryContextStrategyQueriesFor(loc : const Location&) : std::vector<std::string>MaxContextChars() : size_tBeerContextStrategyQueriesFor(loc : const Location&) : std::vector<std::string>MaxContextChars() : size_t«interface»SamplingStrategySample(locations : const std::vector<Location>&) : std::vector<Location>UniformSamplingStrategysample_size_ : size_tSample(locations : const std::vector<Location>&) : std::vector<Location>«interface»BeerSelectionStrategySelectStyles(brewery : const GeneratedBrewery&,palette : std::span<const BeerStyle>) : std::vector<BeerStyle>RandomBeerSelectionStrategyrng_ : std::mt19937min_beers_ : size_tmax_beers_ : size_tSelectStyles(brewery : const GeneratedBrewery&,palette : std::span<const BeerStyle>) : std::vector<BeerStyle>«interface»CheckinDistributionStrategyAssignActivityWeights(users : std::vector<GeneratedUser>&) : voidCheckinsForUser(user : const GeneratedUser&,brewery_count : size_t) : size_tTimestampFor(user : const GeneratedUser&,index : size_t) : std::stringJCurveCheckinStrategyrng_ : std::mt19937AssignActivityWeights(users : std::vector<GeneratedUser>&) : voidCheckinsForUser(user : const GeneratedUser&,brewery_count : size_t) : size_tTimestampFor(user : const GeneratedUser&,index : size_t) : std::stringRandomCheckinStrategyrng_ : std::mt19937min_checkins_ : size_tmax_checkins_ : size_tAssignActivityWeights(users : std::vector<GeneratedUser>&) : voidCheckinsForUser(user : const GeneratedUser&,brewery_count : size_t) : size_tTimestampFor(user : const GeneratedUser&,index : size_t) : std::string«interface»FollowGenerationStrategyGenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>RandomFollowStrategyrng_ : std::mt19937min_follows_ : size_tmax_follows_ : size_tGenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>ActivityWeightedFollowStrategyrng_ : std::mt19937min_follows_ : size_tmax_follows_ : size_tGenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>LogLevelDebugInfoWarnErrorPipelinePhaseStartupUserGenerationBreweryAndBeerGenerationCheckinGenerationRatingGenerationFollowGenerationTeardownLogEntrytimestamp : std::chrono::system_clock::time_pointlevel : LogLevelphase : PipelinePhasemessage : std::stringworker : std::optional<std::string>«interface»ILoggerLog(entry : const LogEntry&) : voidLogProducerchannel_ : BoundedChannel<LogEntry>&Log(entry : const LogEntry&) : voidLogDispatcherchannel_ : BoundedChannel<LogEntry>&Run() : voidToSpdlogLevel(level) : spdlog::level::level_enumBoundedChannelTqueue_ : std::queue<T>mutex_ : std::mutexnot_full_ : std::condition_variablenot_empty_ : std::condition_variablecapacity_ : size_tclosed_ : boolSend(item : T) : voidReceive() : std::optional<T>Close() : void«interface»ICuratedDataServiceLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&JsonLoaderLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Each Load* call is memoized after its firstparse -- implemented today, ahead of the restof this package (LoadBeerStyles is still planned).MockCuratedDataServiceLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Fixed in-memory dataset used in --mocked mode,implemented today for Locations/Personas/Forenames/Surnames; would need a LoadBeerStylesfixture too once that method is implemented.«interface»EnrichmentServiceGetLocationContext(loc : const Location&,strategy : const ContextStrategy&) : LocationContextWikipediaServiceclient_ : std::unique_ptr<WebClient>extract_cache_ : std::unordered_map<std::string, std::string>GetLocationContext(loc : const Location&,strategy : const ContextStrategy&) : LocationContextFetchExtract(query : std::string_view) : std::string«interface»WebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::stringHttpWebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::string«interface»IPromptDirectoryLoad(key : std::string_view) : std::stringPromptDirectoryprompt_dir_ : std::filesystem::pathcache_ : std::unordered_map<std::string, std::string>PromptDirectory(prompt_dir : const std::filesystem::path&)Load(key : std::string_view) : std::string«interface»DataGeneratorGenerateBrewery(location : const Location&,context : const LocationContext&) : BreweryResultGenerateBeer(brewery_id : uint64_t,location : const Location&,context : const LocationContext&,style : const BeerStyle&) : BeerResultGenerateUser(city : const EnrichedCity&,persona : const UserPersona&,name : const Name&) : UserResultGenerateCheckin(user : const GeneratedUser&,brewery : const GeneratedBrewery&,timestamp : const std::string&) : CheckinResultGenerateRating(user : const GeneratedUser&,beer : const GeneratedBeer&,checkin_id : uint64_t) : RatingResultMockGeneratorGenerateBrewery(...) : BreweryResultGenerateBeer(...) : BeerResultGenerateUser(...) : UserResultGenerateCheckin(...) : CheckinResultGenerateRating(...) : RatingResultDeterministicHash(location : const Location&) : size_tLlamaGeneratormodel_ : ModelHandlecontext_ : ContextHandleprompt_formatter_ : std::unique_ptr<PromptFormatter>prompt_directory_ : std::unique_ptr<IPromptDirectory>rng_ : std::mt19937GenerateBrewery(...) : BreweryResultGenerateBeer(...) : BeerResultGenerateUser(...) : UserResultGenerateCheckin(...) : CheckinResultGenerateRating(...) : RatingResultLoad(opts : const GeneratorOptions&) : voidInfer(system_prompt, user_prompt,max_tokens, grammar) : std::stringValidateModelArchitecture() : void«interface»PromptFormatterFormat(system_prompt : std::string_view,user_prompt : std::string_view) : std::stringExpectedArchitecture() : std::string_viewGemma4JinjaPromptFormatterFormat(...) : std::stringExpectedArchitecture() : std::string_view«interface»ExportServiceInitialize() : voidProcessBrewery(brewery : const GeneratedBrewery&) : uint64_tProcessBeer(beer : const GeneratedBeer&) : uint64_tProcessUser(user : const GeneratedUser&) : uint64_tProcessCheckin(checkin : const GeneratedCheckin&) : uint64_tProcessRating(rating : const GeneratedRating&) : voidProcessFollow(follow : const GeneratedFollow&) : voidFinalize() : voidSqliteExportServicedate_time_provider_ : std::unique_ptr<DateTimeProvider>db_handle_ : SqliteDatabaseHandleinsert_location_stmt_ : SqliteStatementHandleinsert_brewery_stmt_ : SqliteStatementHandleinsert_beer_stmt_ : SqliteStatementHandleinsert_user_stmt_ : SqliteStatementHandleinsert_checkin_stmt_ : SqliteStatementHandleinsert_rating_stmt_ : SqliteStatementHandleinsert_follow_stmt_ : SqliteStatementHandletransaction_open_ : boollocation_cache_ : std::unordered_map<std::string, uint64_t>brewery_cache_ : std::unordered_map<std::string, uint64_t>Initialize() : voidProcessRecord(brewery : const GeneratedBrewery&) : uint64_tProcessRecord(beer : const GeneratedBeer&) : uint64_tProcessRecord(user : const GeneratedUser&) : uint64_tProcessRecord(checkin : const GeneratedCheckin&) : uint64_tProcessRecord(rating : const GeneratedRating&) : voidProcessRecord(follow : const GeneratedFollow&) : voidFinalize() : voidInitializeSchema() : voidPrepareStatements() : voidRollbackAndCloseNoThrow() : voidFinalizeStatements() : void«interface»DateTimeProviderGetUtcTimestamp() : std::stringSystemDateTimeProviderGetUtcTimestamp() : std::stringBiergartenPipelineOrchestratorcurated_data_service_ : std::unique_ptr<ICuratedDataService>enrichment_service_ : std::unique_ptr<EnrichmentService>generator_ : std::unique_ptr<DataGenerator>logger_ : std::unique_ptr<Logger>exporter_ : std::unique_ptr<ExportService>brewery_context_strategy_ : std::unique_ptr<ContextStrategy>sampling_strategy_ : std::unique_ptr<SamplingStrategy>beer_selection_strategy_ : std::unique_ptr<BeerSelectionStrategy>checkin_strategy_ : std::unique_ptr<CheckinDistributionStrategy>follow_strategy_ : std::unique_ptr<FollowGenerationStrategy>beer_style_palette_ : std::vector<BeerStyle>options_ : ApplicationOptionsuser_pool_ : std::vector<GeneratedUser>brewery_pool_ : std::vector<GeneratedBrewery>beer_pool_ : std::vector<GeneratedBeer>checkin_pool_ : std::vector<GeneratedCheckin>follow_pool_ : std::vector<GeneratedFollow>Run() : boolRunUserPhase(locations : const std::vector<Location>&) : voidRunBreweryAndBeerPhase(locations : const std::vector<Location>&) : voidRunCheckinPhase() : voidRunRatingPhase() : voidRunFollowPhase() : voidLoggerPipelineLoggerLogWorkerSampleName() producesemitsconsumesuser_pool_0..*brewery_pool_0..*beer_pool_0..*checkin_pool_0..*follow_pool_0..*logs todrains from \ No newline at end of file diff --git a/docs/website/diagrams-out/architecture.svg b/docs/website/diagrams-out/architecture.svg deleted file mode 100644 index b07f8b34..00000000 --- a/docs/website/diagrams-out/architecture.svg +++ /dev/null @@ -1 +0,0 @@ -The Biergarten App - Vertical Slice ArchitectureAPI.Core (thin host)Feature SlicesSharedInfrastructure LayerDomain LayerSQL ServerAPI.CoreASP.NET Core Web API host- Program.cs wiring only- MediatR + AddApplicationPartper Features.* assembly- Swagger/OpenAPI, health checks- JWT auth middleware- Global exception filterFeatures.UsersFeatures.BreweriesFeatures.EmailsFeatures.LocationsEach slice owns its own:- Controller (HTTP endpoints)- Commands/Queries + Handlers- Validators- Repository (Dapper) Features.Users covers auth(Commands/Authentication),account management(Commands/Account), andprofile/avatar (Commands/Profile)No controller - invoked onlyvia MediatR commands sentfrom other slicesNo controller and no MediatRhandlers - exposes ILocationRepositorydirectly as a plain DI service.Not registered by API.Core at all;only Database.Seed depends on it todayShared.ContractsShared.Application- ValidationBehavior(MediatR pipeline)- Cross-slice email commands(SendRegistrationEmailCommand,SendResendConfirmationEmailCommand)Infrastructure.SqlInfrastructure.JwtInfrastructure.PasswordHashingInfrastructure.EmailInfrastructure.Email.TemplatesDomain.EntitiesDomain.Exceptions- UserAccount, UserCredential, UserVerification- UserProfile, UserAvatar- BreweryPost, BreweryPostLocation- City, StateProvince, Country- BeerPost, BeerStyle(schema tables exist; no repository yet)TablesDatabase.Seed(separate console app)SQL-first approachEach slice's own repository issuesinline SQL via Dapper - no ORM,no stored proceduresNo Features.* projectever references anotherFeatures.* project directlyAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanMediatR scan only(no controller)AddFeaturesUsers()(DI only, no API/MediatR)AddFeaturesBreweries()AddFeaturesLocations()sendsSendRegistrationEmailCommandhandlesSendRegistrationEmailCommandDapper SQLDapper SQLDapper SQL \ No newline at end of file diff --git a/docs/website/diagrams-out/authentication-flow.svg b/docs/website/diagrams-out/authentication-flow.svg deleted file mode 100644 index e90a7d3e..00000000 --- a/docs/website/diagrams-out/authentication-flow.svg +++ /dev/null @@ -1 +0,0 @@ -User Authentication Flow - ExpandedUser Authentication Flow - ExpandedHandler LayerInfrastructure LayerRepository / Cross-sliceAPIAPIAPIAPIAPIAPIAPIAPIAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerRefreshTokenHandlerConfirmUserHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdateProfileHandlerDeleteAccountHandlerUserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.TokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherJWTJWTJWTJWTJWTJWTJWTEmailTemplateDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreEmailDispatcherEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerUserAPIMediatR pipelineRegisterUserHandlerLoginHandlerRefreshTokenHandlerConfirmUserHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdateProfileHandlerDeleteAccountHandlerUserManager.ApplicationUser.TokenServiceArgon2PasswordHasherJWTEmailTemplateDapperUserStoreEmailDispatcherSQL ServerUserUserAPIControllerAPIControllerMediatR pipeline(ValidationBehavior)MediatR pipeline(ValidationBehavior)RegisterUserHandlerRegisterUserHandlerLoginHandlerLoginHandlerRefreshTokenHandlerRefreshTokenHandlerConfirmUserHandlerConfirmUserHandlerResendConfirmationEmailHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdatePasswordHandlerUpdateProfileHandlerUpdateProfileHandlerDeleteAccountHandlerDeleteAccountHandlerUserManager<ApplicationUser>UserManager<ApplicationUser>TokenServiceTokenServiceArgon2PasswordHasherArgon2PasswordHasherJWTInfrastructureJWTInfrastructureEmailProviderEmailProviderTemplateProviderTemplateProviderDapperUserStore(IUserStore/IUserPasswordStore/IUserEmailStore)DapperUserStore(IUserStore/IUserPasswordStore/IUserEmailStore)EmailDispatcher(Features.Emails)EmailDispatcher(Features.Emails)SQL ServerSQL ServerAPIAPIAPIAPIAPIAPIAPIAPIAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerRefreshTokenHandlerConfirmUserHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdateProfileHandlerDeleteAccountHandlerUserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.TokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherJWTJWTJWTJWTJWTJWTJWTEmailTemplateDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreEmailDispatcherEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerASP.NET Core Identity's UserManager<TUser>replaces the deleted AuthRepository: handlersno longer issue SQL or call Argon2 directly,they delegate to UserManager, which in turncalls DapperUserStore (persistence) andArgon2PasswordHasher (IPasswordHasher<TUser>).Registration FlowPOST /api/auth/register{username, firstName,lastName,email, dateOfBirth, password}Send(RegisterUserCommand)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 oldalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)user = new UserAccount{...}.ToApplicationUser()CreateAsync(user, password)HashPassword(user, password)Delegates to IPasswordInfrastructure.Hash:Argon2id parameters:- Salt: 16 bytes (128-bit)- Memory: 64MB- Iterations: 4- Parallelism: CPU count- Hash output: 32 bytes"base64(salt):base64(hash)"SetPasswordHashAsync(user,hash)user.Id is still empty at this point -only sets the in-memory hash. CreateAsyncwrites it to dbo.UserCredential itself.FindByNameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username =@Usernamenull (user doesn't exist)FindByEmailAsync(email)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Email = @Emailnull (email doesn't exist)Default UserManager validators checkuniqueness before CreateAsync is called;DapperUserStore.CreateAsync also guardsagainst a genuine race via a caughtduplicate-key SqlException (2601/2627).CreateAsync(user)BEGIN TRANSACTIONINSERT INTO dbo.UserAccount(Username, FirstName,LastName,DateOfBirth, Email)OUTPUTINSERTED.UserAccountIDUserAccountId (GUID)INSERT INTOdbo.UserCredential(UserAccountId, Hash)COMMIT TRANSACTIONBoth statements run via Dapper'sExecuteScalarAsync/ExecuteAsync againsta single DbTransaction, not a storedprocedure - no manual parameter bindingIdentityResult.Success(user.Id set)IdentityResultalt[!result.Succeeded]throw ConflictException(DuplicateUserName/DuplicateEmailcodes ->"Username or email alreadyexists";otherwise joined errordescriptions)propagate409 Conflict"Username or email alreadyexists"[result.Succeeded]GenerateAccessToken(user)GenerateJwt(userId, username,expiry)JWT Configuration:- Algorithm: HS256- Expires: 1 hour- Claims:* sub: userId* unique_name: username* iat / jtiCreate JWT with claimsSign with secret keyAccess TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(userId, username,expiry)Refresh Token:- Expires: 21 days (504 hours)- Same structure as access token,\n signed with a separate secretRefresh TokenRefresh TokenGenerateConfirmationToken(user)GenerateJwt(userId, username,expiry)Confirmation Token:- Expires: 30 minutes (0.5 hours)- Signed with a third, separate secretConfirmation TokenConfirmation TokenSend(SendRegistrationEmailCommand)(Shared.Application cross-slicecontract)SendRegistrationEmailAsync(  firstName, email,confirmationToken)Routed to Features.Emails'SendRegistrationEmailHandler,the only cross-slice call inthe backendRenderUserRegisteredEmailAsync(  firstName, confirmationLink)Razor Component:- Header with branding- Welcome message- Confirmation button- FooterRender Razor componentto HTMLHTML email contentSendAsync(email, subject, body)SMTP Configuration (MailKit):- Host/port: from env (SMTP_HOST/SMTP_PORT)- TLS: StartTLS when SMTP_USE_SSL=true- Auth: username/password, if configuredBuild MIME messageConnect, authenticate,send, disconnectSuccess / throwsalt[Email sent successfully]emailSent = true[mediator.Send throws]catch(Exception),log to stderr,emailSent = falseRegistrationPayload(  userAccountId, username,  refreshToken, accessToken,  confirmationEmailSent)RegistrationPayload201 Created{  message: "User registeredsuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken,    confirmationEmailSent  }}Login FlowPOST /api/auth/login{username, password}Send(LoginCommand)ValidationBehavior runs LoginValidator:username and password must both be non-emptyalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)FindByNameAsync(username)FindByNameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username =@UsernameUserAccount row or nullHydrateAsync(load activeUserCredential.Hash,UserVerification ->EmailConfirmed)ApplicationUser or nullApplicationUser or nullalt[User not found]throw UnauthorizedException"Invalid username or password."propagate401 Unauthorized[User found]CheckPasswordAsync(user,password)HasPasswordAsync short-circuits to false(no active UserCredential was hydrated)without calling the hasher; otherwisedelegates to Argon2PasswordHasher.VerifyHashedPassword(  user, user.PasswordHash,password)Delegates to IPasswordInfrastructure.Verify:1. Split stored hash: "salt:hash"2. Hash provided password with the same salt3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)Success/Failedtrue/falsealt[Password invalid]throw UnauthorizedExceptionpropagate401 Unauthorized[Password valid]GenerateAccessToken(user)GenerateJwt(...)Access TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(...)Refresh TokenRefresh TokenLoginPayload(  userAccountId, username,  refreshToken, accessToken)LoginPayload200 OK{  message: "Logged insuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken  }}Refresh Token FlowPOST /api/auth/refreshHeader: X-Refresh-Token:<token>Send(RefreshTokenCommand(refreshToken))ValidationBehavior runs RefreshTokenValidator:refreshToken must be non-empty. Anonymousendpoint - no Bearer token required.alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)RefreshTokenAsync(refreshToken)ValidateRefreshTokenAsync(refreshToken)Validates signature/expiry against therefresh token secret, extracts userId/username claims.ValidatedToken or throwsalt[Token invalid or expired]throw UnauthorizedExceptionpropagatepropagate401 Unauthorized[Token valid]FindByIdAsync(validated.UserId)FindByIdAsync(userId)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[User not found]throw UnauthorizedException"User account not found"propagatepropagate401 Unauthorized[User found]GenerateAccessToken(user)GenerateRefreshToken(user)new access + refresh tokensRefreshTokenResultLoginPayload(  userId, username,  refreshToken, accessToken)LoginPayload200 OK{message: "Token refreshedsuccessfully.",payload: {...}}Confirm User FlowPOST/api/auth/confirm?token=<token>(requires Bearer access token)Send(ConfirmUserCommand(token))Handle(command)ValidateConfirmationTokenAsync(token)Validate against confirmationtoken secret, extract userIdValidatedToken or throwsValidatedTokenalt[Token invalid or expired]throw UnauthorizedExceptionpropagate401 Unauthorized[Token valid]FindByIdAsync(validatedToken.UserId)FindByIdAsync(userId)SELECT ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[User not found]throw UnauthorizedException"User account not found"propagate401 Unauthorized[User found]SetEmailConfirmedAsync(user,true)IUserEmailStore<ApplicationUser> is injecteddirectly into the handler (not via UserManager),setting the confirmed flag in-memory here.UpdateAsync(user)UpdateAsync(user)UPDATE dbo.UserAccount SET ...INSERT INTOdbo.UserVerification(UserAccountId,VerificationDateTime)Idempotent: skipped (duplicate-keyexception swallowed) if a verificationrow already exists.IdentityResult.SuccessIdentityResultConfirmationPayload(  user.Id, DateTime.UtcNow)ConfirmationPayload200 OK{message: "User with ID {id} isconfirmed.",payload: {...}}Resend Confirmation Email FlowPOST/api/auth/confirm/resend?userId=<id>(requires Bearer access token)Send(ResendConfirmationEmailCommand(userId))Handle(command)FindByIdAsync(userId)FindByIdAsync(userId)SELECT ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[User not found]return silently(prevents user enumeration)[User found]IsEmailConfirmedAsync(user)boolalt[Already confirmed]return silently (no-op)[Not yet confirmed]GenerateConfirmationToken(user)confirmation tokenSend(SendResendConfirmationEmailCommand)SendResendConfirmationEmailAsync(  firstName, email,confirmationToken)Render email(same Razor pipeline asregistration)SendAsync(email, subject, body)(void)(void)200 OK{message: "confirmation emailhas been resent"}Always 200 OK regardless of outcome above -same user-enumeration precaution as a not-founduser or an already-confirmed account.Update Username FlowPATCH /api/auth/username{newUsername}(requires Bearer access token)Send(UpdateUsernameCommand(  GetAuthenticatedUserId(),newUsername))ValidationBehavior runs UpdateUsernameValidator(same format rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)SELECT ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]SetUserNameAsync(user,newUsername)FindByNameAsync(newUsername)Uniqueness check before the UPDATE.UpdateAsync(user)UPDATE dbo.UserAccountSET Username = @UserName, ...IdentityResultIdentityResultalt[!result.Succeeded]throw ConflictException(DuplicateUserName code ->"Username already exists")propagate409 Conflict[result.Succeeded]UpdateUsernamePayload(  user.Id, user.UserName)UpdateUsernamePayload200 OK{message: "Username updatedsuccessfully.",payload: {...}}Update Email FlowPATCH /api/auth/email{newEmail}(requires Bearer access token)Send(UpdateEmailCommand(  GetAuthenticatedUserId(),newEmail))ValidationBehavior runs UpdateEmailValidator(same format rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)SELECT ...ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]SetEmailAsync(user, newEmail)FindByEmailAsync(newEmail)Uniqueness check. Also resetsuser.EmailConfirmed = false -the caller must re-confirm viaPOST /api/auth/confirm/resend.UpdateAsync(user)UPDATE dbo.UserAccountSET Email = @Email, ...DELETE FROMdbo.UserVerificationWHERE UserAccountID = @IdIdentityResultIdentityResultalt[!result.Succeeded]throw ConflictException(DuplicateEmail code ->"Email address already exists")propagate409 Conflict[result.Succeeded]UpdateEmailPayload(  user.Id, user.Email,  user.EmailConfirmed = false)UpdateEmailPayload200 OK{message: "Email updatedsuccessfully.",payload: {...}}Update Password FlowPATCH /api/auth/password{currentPassword,newPassword}(requires Bearer access token)Send(UpdatePasswordCommand(  GetAuthenticatedUserId(),  currentPassword,newPassword))ValidationBehavior runs UpdatePasswordValidator(same password strength rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]ChangePasswordAsync(  user, currentPassword,newPassword)VerifyHashedPassword(  user, user.PasswordHash,currentPassword)Success/Failedalt[Current password mismatch]IdentityResult.Failed(PasswordMismatch)[Current password matches]HashPassword(user,newPassword)new hashSetPasswordHashAsync(user,newHash)UPDATE dbo.UserCredentialSET IsRevoked = 1, RevokedAt =GETDATE()WHERE UserAccountId = @IdAND IsRevoked = 0INSERT INTOdbo.UserCredential(UserAccountId, Hash)Revoke-then-insert runs inside oneDbTransaction (credential rotation).(void)IdentityResult.Successalt[Current password is incorrect]throw UnauthorizedException"Current password is incorrect"propagate401 Unauthorized[!result.Succeeded (other failure)]throw ConflictExceptionpropagate409 Conflict[result.Succeeded]UpdatePasswordPayload(  user.Id, DateTime.UtcNow)UpdatePasswordPayload200 OK{message: "Password updatedsuccessfully.",payload: {...}}Update Profile FlowPATCH /api/auth/profile{firstName, lastName,dateOfBirth}(requires Bearer access token)Send(UpdateProfileCommand(  GetAuthenticatedUserId(),  firstName, lastName,dateOfBirth))ValidationBehavior runs UpdateProfileValidator(same name/DateOfBirth rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]user.FirstName = ...user.LastName = ...user.DateOfBirth = ...First/last name and date of birth aren'ttracked by any Identity store interface -mutated directly on the ApplicationUser,then saved via UserManager.UpdateAsync.UpdateAsync(user)UpdateAsync(user)UPDATE dbo.UserAccountSET FirstName = @FirstName,    LastName = @LastName,    DateOfBirth = @DateOfBirth,...IdentityResultIdentityResultalt[!result.Succeeded]throw ConflictExceptionpropagate409 Conflict[result.Succeeded]UpdateProfilePayload(  user.Id, firstName,  lastName, dateOfBirth)UpdateProfilePayload200 OK{message: "Profile updatedsuccessfully.",payload: {...}}Delete Account FlowDELETE /api/auth/account(requires Bearer access token)Send(DeleteAccountCommand(  GetAuthenticatedUserId()))Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]DeleteAsync(user)DeleteAsync(user)DELETE FROM dbo.UserAccountWHERE UserAccountID = @IdUserCredential/UserVerification/UserAvatarcascade on delete. Posts, comments, photos,and follows use ON DELETE NO ACTION - if anyreference this account, the DELETE throws aforeign key violation (SQL error 547).alt[Foreign key violation (dependent records exist)]IdentityResult.Failed(AccountHasDependentRecords)[Delete succeeds]IdentityResult.SuccessIdentityResultalt[Account has dependent records]throw ConflictException"Account cannot be deletedwhile it stillhas associated posts,comments, photos,or follows."propagate409 Conflict[!result.Succeeded (other failure)]throw ConflictExceptionpropagate409 Conflict[result.Succeeded](void)(void)200 OK{message: "Account deletedsuccessfully."}Error Handling (Global Exception Filter)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 \ No newline at end of file diff --git a/docs/website/diagrams-out/database-schema.svg b/docs/website/diagrams-out/database-schema.svg deleted file mode 100644 index 2c9e70c5..00000000 --- a/docs/website/diagrams-out/database-schema.svg +++ /dev/null @@ -1 +0,0 @@ -Database SchemaUser & AuthLocationBreweryBeer (*)UserAccountUserAccountID: UNIQUEIDENTIFIER «PK»Username: VARCHAR(64) «UNIQUE»FirstName: NVARCHAR(128)LastName: NVARCHAR(128)Email: VARCHAR(128) «UNIQUE»CreatedAt: DATETIMEUpdatedAt: DATETIMEDateOfBirth: DATEUserCredentialUserCredentialID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEExpiry: DATETIMEHash: NVARCHAR(256)IsRevoked: BITRevokedAt: DATETIMEUserVerificationUserVerificationID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»VerificationDateTime: DATETIMEUserProfileUserProfileID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»Biography: NVARCHAR(MAX)UserAvatarUserAvatarID: UNIQUEIDENTIFIER «PK»UserProfileID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»ValidFrom: DATETIME2(3)ValidTo: DATETIME2(3)At most one row per UserProfileIDhas ValidTo IS NULL (active avatar)UserFollow (*)UserFollowID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»FollowingID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEPhoto (*)PhotoID: UNIQUEIDENTIFIER «PK»Hyperlink: NVARCHAR(256)UploadedByID: UNIQUEIDENTIFIER «FK»UploadedAt: DATETIMECountryCountryID: UNIQUEIDENTIFIER «PK»CountryName: NVARCHAR(100)ISO3166_1: CHAR(2) «UNIQUE»StateProvinceStateProvinceID: UNIQUEIDENTIFIER «PK»StateProvinceName: NVARCHAR(100)ISO3166_2: CHAR(6) «UNIQUE»CountryID: UNIQUEIDENTIFIER «FK»CityCityID: UNIQUEIDENTIFIER «PK»CityName: NVARCHAR(100)StateProvinceID: UNIQUEIDENTIFIER «FK»BreweryPostBreweryPostID: UNIQUEIDENTIFIER «PK»BreweryName: NVARCHAR(256)PostedByID: UNIQUEIDENTIFIER «FK»Description: NVARCHAR(512)CreatedAt: DATETIMEUpdatedAt: DATETIMEBreweryPostLocationBreweryPostLocationID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK, UNIQUE»AddressLine1: NVARCHAR(256)AddressLine2: NVARCHAR(256)PostalCode: NVARCHAR(20)CityID: UNIQUEIDENTIFIER «FK»Coordinates: GEOGRAPHYBreweryPostPhoto (*)BreweryPostPhotoID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerStyle (*)BeerStyleID: UNIQUEIDENTIFIER «PK»StyleName: NVARCHAR(100) «UNIQUE»Description: NVARCHAR(MAX)BeerPost (*)BeerPostID: UNIQUEIDENTIFIER «PK»Name: NVARCHAR(100)Description: NVARCHAR(MAX)ABV: DECIMAL(4,2)IBU: INTPostedByID: UNIQUEIDENTIFIER «FK»BeerStyleID: UNIQUEIDENTIFIER «FK»BrewedByID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEUpdatedAt: DATETIMEBeerPostPhoto (*)BeerPostPhotoID: UNIQUEIDENTIFIER «PK»BeerPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerPostComment (*)BeerPostCommentID: UNIQUEIDENTIFIER «PK»Comment: NVARCHAR(250)BeerPostID: UNIQUEIDENTIFIER «FK»CommentedByID: UNIQUEIDENTIFIER «FK»Rating: INTCreatedAt: DATETIMEUpdatedAt: DATETIMEAuth reads/writes (registration, login, credentialrotation, verification) go through ASP.NET CoreIdentity's UserManager<ApplicationUser>, backed byFeatures.Users' DapperUserStore - inline SQL, nostored procedures. Profile/avatar data goes throughFeatures.Users' UserProfileRepository.Backed by Features.Breweries' BreweryRepository(Dapper); create/update/delete run inline SQLinside a transaction, with optimistic concurrencyvia RowVersionhashashashas (history)refers tofollows (UserAccountID)followed by (FollowingID)uploadedhashaspostedhaslocated inhaslinked topostedcategorizesbrewedhaslinked tohascommentedEvery table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.Tables marked (*) have no repository/feature slice yet. \ No newline at end of file diff --git a/docs/website/diagrams-out/deployment.svg b/docs/website/diagrams-out/deployment.svg deleted file mode 100644 index a0ca5751..00000000 --- a/docs/website/diagrams-out/deployment.svg +++ /dev/null @@ -1 +0,0 @@ -Docker Deployment ArchitectureDevelopment Environment(docker-compose.dev.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)BiergartenDatabaseAPI Container(API.Core)Migrations(run-once)Seed(run-once)Mailpit(axllent/mailpit)Test Environment(docker-compose.test.yaml)SQL Server(isolated instance)BiergartenTest DatabaseMigrations(test)Seed(test)API.Specs(Integration Tests)unit.tests(Features.*.Tests, one container)Production Environment(docker-compose.prod.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)Migrations(run-once)API Container(API.Core)Database-only Environment(docker-compose.db.yaml)SQL ServerMinimal Environment(docker-compose.min.yaml)SQL ServerMailpittest-results/(mounted volume)Environment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Developer Volumes:- biergarten-dev-data1433ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Development- DB_SERVER=sql-server- DB_NAME=Biergarten- DB_USER/PASSWORD- JWT_SECRET- SMTP_* (10+ variables) Health Check:/health endpointDatabase.MigrationsRuns: DbUp migrationsEnvironment:- CLEAR_DATABASE=falseDepends on: sql-serverDatabase.SeedCreates:- 100 test users- Location data (US/CA/MX)- test.user accountDepends on: migrations8025 (Web UI)1025 (SMTP)Local SMTP catcher for dev email testing.Environment:- MP_SMTP_AUTH_ACCEPT_ANY=1- MP_SMTP_AUTH_ALLOW_INSECURE=1Fresh instance each runCLEAR_DATABASE=true Volumes:- biergarten-test-data(ephemeral)1434Database.MigrationsDatabase.SeedMinimal seed:- test.user only- Essential dataReqnroll + xUnitTests:- Registration flow- Login flow- Validation rules- 404 handling Uses: TestApiFactoryMocks: Email servicesxUnit + MoqBuilds Dockerfile.tests, whichrestores/builds against Core.slnxthen runs `dotnet test` for everyFeatures/*.Tests project in turn:- Features.Users.Tests- Features.Breweries.Tests- Features.Emails.Tests Uses: Moq to mock each handler'srepository/service dependenciesNo real database neededProd Network (bridge: prodnet)Isolated from dev/test networks.Volumes: sqlserverdata-prod,nuget-cache-prodBiergartenDatabaseEnvironment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Express Volumes:- sqlserverdata-prodNo host port published.Database.MigrationsDepends on: sql-server (healthy)No seed step in production.ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)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.Same devnet volumes/images asdocker-compose.dev.yaml, minusapi.core and mailpit - for workingagainst the database withoutrunning the API.BiergartenDatabaseMigrations(run-once)Seed(run-once)Database + mailpit only, no APIcontainer - for frontend developmentagainst an API.Core process runoutside Docker.BiergartenDatabaseMigrations(run-once)8025 / 1025Seed(run-once)api-specs/results.trxFeatures.Users.Tests.trxFeatures.Breweries.Tests.trxFeatures.Emails.Tests.trxTRX formatReadable by:- Visual Studio- Azure DevOps- GitHub ActionsDeveloperDocker HostDev Network (bridge: biergarten-dev)Internal DNS:- sql-server (resolves to SQL container)- api (resolves to API container)Test Network (bridge: biergarten-test)All test components isolatedStartup Order:1. SQL Server (health check)2. Migrations (run-once)3. Seed (run-once)4. API (long-running)Test Execution:All tests run in parallelResults aggregated1. Run migrationsdepends_on2. Connect & servedepends_on1. Run migrationsdepends_on2. Seed datadepends_on1. Run migrationsdepends_on2. Seed datadepends_ondocker compose uphttp://localhost:80801. Run migrationsdepends_on2. Seed data3. Connect & serve4. SMTP_HOST/PORTdepends_ondepends_on1. Migratedepends_on2. Seed3. Integration testdepends_on for startupordering only (Moq mocks,no real connection)depends_ondepends_ondepends_onExport TRXExport TRX (one .trx per slice) \ No newline at end of file diff --git a/docs/website/diagrams-src/architecture.puml b/docs/website/diagrams-src/architecture.puml index e634f6c2..80405957 100644 --- a/docs/website/diagrams-src/architecture.puml +++ b/docs/website/diagrams-src/architecture.puml @@ -1,12 +1,9 @@ @startuml architecture -!theme plain -skinparam backgroundColor #FFFFFF -skinparam defaultFontName Arial -skinparam packageStyle rectangle +!include ../../pipeline/diagrams/biergarten-weizen-theme.puml title The Biergarten App - Vertical Slice Architecture -package "API.Core (thin host)" #E3F2FD { +package "API.Core (thin host)" #F1F3EA { [API.Core\nASP.NET Core Web API host] as API note right of API - Program.cs wiring only @@ -18,7 +15,7 @@ package "API.Core (thin host)" #E3F2FD { end note } -package "Feature Slices" #F3E5F5 { +package "Feature Slices" #DBEEDD { [Features.Users] as UsersSlice [Features.Breweries] as BrewerySlice [Features.Emails] as EmailsSlice @@ -50,7 +47,7 @@ package "Feature Slices" #F3E5F5 { end note } -package "Shared" #FCE4EC { +package "Shared" #E4E6D8 { [Shared.Contracts] as SharedContracts [Shared.Application] as SharedApp note right of SharedApp @@ -62,7 +59,7 @@ package "Shared" #FCE4EC { end note } -package "Infrastructure Layer" #FFF3E0 { +package "Infrastructure Layer" #EBECE3 { [Infrastructure.Sql] as Sql [Infrastructure.Jwt] as JWT [Infrastructure.PasswordHashing] as PwdHash @@ -70,7 +67,7 @@ package "Infrastructure Layer" #FFF3E0 { [Infrastructure.Email.Templates] as EmailTemplates } -package "Domain Layer" #E8F5E9 { +package "Domain Layer" #CBD2B5 { [Domain.Entities] as Domain [Domain.Exceptions] as DomainExceptions note right of Domain diff --git a/docs/website/diagrams-src/auth/confirm-user.puml b/docs/website/diagrams-src/auth/confirm-user.puml new file mode 100644 index 00000000..2692fbd0 --- /dev/null +++ b/docs/website/diagrams-src/auth/confirm-user.puml @@ -0,0 +1,109 @@ +@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 new file mode 100644 index 00000000..90cf4370 --- /dev/null +++ b/docs/website/diagrams-src/auth/delete-account.puml @@ -0,0 +1,91 @@ +@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 new file mode 100644 index 00000000..ed99d309 --- /dev/null +++ b/docs/website/diagrams-src/auth/error-handling.puml @@ -0,0 +1,30 @@ +@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 new file mode 100644 index 00000000..d3c2fea3 --- /dev/null +++ b/docs/website/diagrams-src/auth/login.puml @@ -0,0 +1,129 @@ +@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 new file mode 100644 index 00000000..aef6a468 --- /dev/null +++ b/docs/website/diagrams-src/auth/refresh-token.puml @@ -0,0 +1,108 @@ +@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 new file mode 100644 index 00000000..c4ef5db9 --- /dev/null +++ b/docs/website/diagrams-src/auth/registration.puml @@ -0,0 +1,241 @@ +@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 new file mode 100644 index 00000000..8026ff96 --- /dev/null +++ b/docs/website/diagrams-src/auth/resend-confirmation.puml @@ -0,0 +1,97 @@ +@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 new file mode 100644 index 00000000..7ddd2e46 --- /dev/null +++ b/docs/website/diagrams-src/auth/update-email.puml @@ -0,0 +1,93 @@ +@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 new file mode 100644 index 00000000..615402a0 --- /dev/null +++ b/docs/website/diagrams-src/auth/update-password.puml @@ -0,0 +1,111 @@ +@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 new file mode 100644 index 00000000..53421b99 --- /dev/null +++ b/docs/website/diagrams-src/auth/update-profile.puml @@ -0,0 +1,92 @@ +@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 new file mode 100644 index 00000000..54752d6c --- /dev/null +++ b/docs/website/diagrams-src/auth/update-username.puml @@ -0,0 +1,94 @@ +@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/authentication-flow.puml b/docs/website/diagrams-src/authentication-flow.puml deleted file mode 100644 index b5c97f47..00000000 --- a/docs/website/diagrams-src/authentication-flow.puml +++ /dev/null @@ -1,927 +0,0 @@ -@startuml authentication-flow -!theme plain -skinparam backgroundColor #FFFFFF -skinparam defaultFontName Arial -skinparam sequenceMessageAlign center -skinparam maxMessageSize 200 - -title User Authentication Flow - Expanded - -actor User -participant "API\nController" as API -participant "MediatR pipeline\n(ValidationBehavior)" as Mediator -box "Handler Layer" #LightBlue -participant "RegisterUserHandler" as RegHandler -participant "LoginHandler" as LoginHandler -participant "RefreshTokenHandler" as RefreshHandler -participant "ConfirmUserHandler" as ConfirmHandler -participant "ResendConfirmationEmailHandler" as ResendHandler -participant "UpdateUsernameHandler" as UpdateUsernameHandler -participant "UpdateEmailHandler" as UpdateEmailHandler -participant "UpdatePasswordHandler" as UpdatePasswordHandler -participant "UpdateProfileHandler" as UpdateProfileHandler -participant "DeleteAccountHandler" as DeleteAccountHandler -participant "UserManager" as UserMgr -participant "TokenService" as TokenSvc -end box -box "Infrastructure Layer" #LightGreen -participant "Argon2PasswordHasher" as Argon2 -participant "JWT\nInfrastructure" as JWT -participant "Email\nProvider" as SMTP -participant "Template\nProvider" as Template -end box -box "Repository / Cross-slice" #LightYellow -participant "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 - -== Registration Flow == - -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 - -== Login Flow == - -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 - -== Refresh Token Flow == - -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 - -== Confirm User Flow == - -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 - -== Resend Confirmation Email Flow == - -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 - -== Update Username Flow == - -User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token) -activate API - -API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername)) -activate Mediator - -note right of Mediator - ValidationBehavior runs UpdateUsernameValidator - (same format rules as registration) -end note - -alt Validation fails - Mediator -> API: throws FluentValidation.ValidationException - API -> User: 400 Bad Request -else Validation succeeds - - Mediator -> UpdateUsernameHandler: Handle(command) - activate UpdateUsernameHandler - - UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId) - activate UserMgr - UserMgr -> UserStore: FindByIdAsync(userAccountId) - activate UserStore - UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id - activate DB - DB --> UserStore: row or null - deactivate DB - UserStore --> UserMgr: ApplicationUser or null - deactivate UserStore - UserMgr --> UpdateUsernameHandler: ApplicationUser or null - deactivate UserMgr - - alt Account not found - UpdateUsernameHandler -> Mediator: throw NotFoundException - Mediator -> API: propagate - API -> User: 404 Not Found - else Account found - - UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername) - activate UserMgr - UserMgr -> UserStore: FindByNameAsync(newUsername) - note right of UserMgr - Uniqueness check before the UPDATE. - end note - UserMgr -> UserStore: UpdateAsync(user) - UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ... - UserStore --> UserMgr: IdentityResult - UserMgr --> UpdateUsernameHandler: IdentityResult - deactivate UserMgr - - alt !result.Succeeded - UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists") - Mediator -> API: propagate - API -> User: 409 Conflict - else result.Succeeded - UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName) - deactivate UpdateUsernameHandler - Mediator --> API: UpdateUsernamePayload - deactivate Mediator - API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}} - end - end -end -deactivate API - -== Update Email Flow == - -User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token) -activate API - -API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail)) -activate Mediator - -note right of Mediator - ValidationBehavior runs UpdateEmailValidator - (same format rules as registration) -end note - -alt Validation fails - Mediator -> API: throws FluentValidation.ValidationException - API -> User: 400 Bad Request -else Validation succeeds - - Mediator -> UpdateEmailHandler: Handle(command) - activate UpdateEmailHandler - - UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId) - activate UserMgr - UserMgr -> UserStore: FindByIdAsync(userAccountId) - UserStore -> DB: SELECT ... - UserStore --> UserMgr: ApplicationUser or null - UserMgr --> UpdateEmailHandler: ApplicationUser or null - deactivate UserMgr - - alt Account not found - UpdateEmailHandler -> Mediator: throw NotFoundException - Mediator -> API: propagate - API -> User: 404 Not Found - else Account found - - UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail) - activate UserMgr - UserMgr -> UserStore: FindByEmailAsync(newEmail) - note right of UserMgr - Uniqueness check. Also resets - user.EmailConfirmed = false - - the caller must re-confirm via - POST /api/auth/confirm/resend. - end note - UserMgr -> UserStore: UpdateAsync(user) - UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ... - UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id - UserStore --> UserMgr: IdentityResult - UserMgr --> UpdateEmailHandler: IdentityResult - deactivate UserMgr - - alt !result.Succeeded - UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists") - Mediator -> API: propagate - API -> User: 409 Conflict - else result.Succeeded - UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false) - deactivate UpdateEmailHandler - Mediator --> API: UpdateEmailPayload - deactivate Mediator - API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}} - end - end -end -deactivate API - -== Update Password Flow == - -User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token) -activate API - -API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword)) -activate Mediator - -note right of Mediator - ValidationBehavior runs UpdatePasswordValidator - (same password strength rules as registration) -end note - -alt Validation fails - Mediator -> API: throws FluentValidation.ValidationException - API -> User: 400 Bad Request -else Validation succeeds - - Mediator -> UpdatePasswordHandler: Handle(command) - activate UpdatePasswordHandler - - UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId) - activate UserMgr - UserMgr -> UserStore: FindByIdAsync(userAccountId) - UserStore --> UserMgr: ApplicationUser or null - UserMgr --> UpdatePasswordHandler: ApplicationUser or null - deactivate UserMgr - - alt Account not found - UpdatePasswordHandler -> Mediator: throw NotFoundException - Mediator -> API: propagate - API -> User: 404 Not Found - else Account found - - UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword) - activate UserMgr - UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword) - activate Argon2 - Argon2 --> UserMgr: Success/Failed - deactivate Argon2 - - alt Current password mismatch - UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch) - else Current password matches - UserMgr -> Argon2: HashPassword(user, newPassword) - activate Argon2 - Argon2 --> UserMgr: new hash - deactivate Argon2 - UserMgr -> UserStore: SetPasswordHashAsync(user, newHash) - activate UserStore - UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0 - UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash) - note right of UserStore - Revoke-then-insert runs inside one - DbTransaction (credential rotation). - end note - UserStore --> UserMgr: (void) - deactivate UserStore - UserMgr --> UpdatePasswordHandler: IdentityResult.Success - end - deactivate UserMgr - - alt Current password is incorrect - UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect" - Mediator -> API: propagate - API -> User: 401 Unauthorized - else !result.Succeeded (other failure) - UpdatePasswordHandler -> Mediator: throw ConflictException - Mediator -> API: propagate - API -> User: 409 Conflict - else result.Succeeded - UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow) - deactivate UpdatePasswordHandler - Mediator --> API: UpdatePasswordPayload - deactivate Mediator - API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}} - end - end -end -deactivate API - -== Update Profile Flow == - -User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token) -activate API - -API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth)) -activate Mediator - -note right of Mediator - ValidationBehavior runs UpdateProfileValidator - (same name/DateOfBirth rules as registration) -end note - -alt Validation fails - Mediator -> API: throws FluentValidation.ValidationException - API -> User: 400 Bad Request -else Validation succeeds - - Mediator -> UpdateProfileHandler: Handle(command) - activate UpdateProfileHandler - - UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId) - activate UserMgr - UserMgr -> UserStore: FindByIdAsync(userAccountId) - UserStore --> UserMgr: ApplicationUser or null - UserMgr --> UpdateProfileHandler: ApplicationUser or null - deactivate UserMgr - - alt Account not found - UpdateProfileHandler -> Mediator: throw NotFoundException - Mediator -> API: propagate - API -> User: 404 Not Found - else Account found - - UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ... - note right of UpdateProfileHandler - First/last name and date of birth aren't - tracked by any Identity store interface - - mutated directly on the ApplicationUser, - then saved via UserManager.UpdateAsync. - end note - - UpdateProfileHandler -> UserMgr: UpdateAsync(user) - activate UserMgr - UserMgr -> UserStore: UpdateAsync(user) - UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ... - UserStore --> UserMgr: IdentityResult - UserMgr --> UpdateProfileHandler: IdentityResult - deactivate UserMgr - - alt !result.Succeeded - UpdateProfileHandler -> Mediator: throw ConflictException - Mediator -> API: propagate - API -> User: 409 Conflict - else result.Succeeded - UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth) - deactivate UpdateProfileHandler - Mediator --> API: UpdateProfilePayload - deactivate Mediator - API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}} - end - end -end -deactivate API - -== Delete Account Flow == - -User -> API: DELETE /api/auth/account\n(requires Bearer access token) -activate API - -API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId())) -activate Mediator - -Mediator -> DeleteAccountHandler: Handle(command) -activate DeleteAccountHandler - -DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId) -activate UserMgr -UserMgr -> UserStore: FindByIdAsync(userAccountId) -UserStore --> UserMgr: ApplicationUser or null -UserMgr --> DeleteAccountHandler: ApplicationUser or null -deactivate UserMgr - -alt Account not found - DeleteAccountHandler -> Mediator: throw NotFoundException - Mediator -> API: propagate - API -> User: 404 Not Found -else Account found - - DeleteAccountHandler -> UserMgr: DeleteAsync(user) - activate UserMgr - UserMgr -> UserStore: DeleteAsync(user) - activate UserStore - UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id - note right of UserStore - UserCredential/UserVerification/UserAvatar - cascade on delete. Posts, comments, photos, - and follows use ON DELETE NO ACTION - if any - reference this account, the DELETE throws a - foreign key violation (SQL error 547). - end note - - alt Foreign key violation (dependent records exist) - UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords) - else Delete succeeds - UserStore --> UserMgr: IdentityResult.Success - end - deactivate UserStore - UserMgr --> DeleteAccountHandler: IdentityResult - deactivate UserMgr - - alt Account has dependent records - DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows." - Mediator -> API: propagate - API -> User: 409 Conflict - else !result.Succeeded (other failure) - DeleteAccountHandler -> Mediator: throw ConflictException - Mediator -> API: propagate - API -> User: 409 Conflict - else result.Succeeded - DeleteAccountHandler --> Mediator: (void) - deactivate DeleteAccountHandler - Mediator --> API: (void) - deactivate Mediator - API -> User: 200 OK\n{message: "Account deleted successfully."} - end -end -deactivate API - -== Error Handling (Global Exception Filter) == - -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 - -@enduml diff --git a/docs/website/diagrams-src/database-schema.puml b/docs/website/diagrams-src/database-schema.puml index 37a1269a..31777a12 100644 --- a/docs/website/diagrams-src/database-schema.puml +++ b/docs/website/diagrams-src/database-schema.puml @@ -1,9 +1,6 @@ @startuml database-schema -!theme plain -skinparam backgroundColor #FFFFFF -skinparam defaultFontName Arial +!include ../../pipeline/diagrams/biergarten-weizen-theme.puml skinparam linetype ortho -skinparam packageStyle rectangle 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. diff --git a/docs/website/diagrams-src/deployment.puml b/docs/website/diagrams-src/deployment.puml index cdaa2e64..a212554f 100644 --- a/docs/website/diagrams-src/deployment.puml +++ b/docs/website/diagrams-src/deployment.puml @@ -1,7 +1,5 @@ @startuml deployment -!theme plain -skinparam backgroundColor #FFFFFF -skinparam defaultFontName Arial +!include ../../pipeline/diagrams/biergarten-weizen-theme.puml skinparam linetype ortho title Docker Deployment Architecture @@ -10,7 +8,7 @@ title Docker Deployment Architecture actor Developer cloud "Docker Host" as Host -package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD { +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 { @@ -80,7 +78,7 @@ package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD { } -package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 { +package "Test Environment\n(docker-compose.test.yaml)" #EBECE3 { node "SQL Server\n(isolated instance)" as TestDB { database "Biergarten\nTest Database" as TestDBInner { @@ -142,7 +140,7 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 { } -package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC { +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 @@ -195,7 +193,7 @@ package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC { end note } -package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 { +package "Database-only Environment\n(docker-compose.db.yaml)" #E4E6D8 { node "SQL Server" as DbOnlyDB { database "Biergarten\nDatabase" as DbOnlyDBInner { @@ -225,7 +223,7 @@ package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 { DbOnlySeed .up.> DbOnlyMig : depends_on } -package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 { +package "Minimal Environment\n(docker-compose.min.yaml)" #DBEEDD { node "SQL Server" as MinDB { database "Biergarten\nDatabase" as MinDBInner {