Add mkdocs site and GitHub Pages deployment for docs

This commit is contained in:
Aaron Po
2026-08-31 19:54:04 -04:00
parent d459b8559c
commit f4471c7fd7
26 changed files with 2676 additions and 209 deletions

52
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Deploy Docs to GitHub Pages
on:
push:
branches:
- main
- docs
paths:
- "docs/**"
- "mkdocs.yml"
- ".github/workflows/deploy-docs.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: pip install mkdocs mkdocs-material mkdocs-glightbox mkdocs-panzoom-plugin
- name: Build site
run: mkdocs build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "site"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -21,7 +21,8 @@ jobs:
run: ./generate-env.sh .env.test run: ./generate-env.sh .env.test
- name: Build test images - name: Build test images
run: docker compose --env-file .env.test -f docker-compose.test.yaml build run:
docker compose --env-file .env.test -f docker-compose.test.yaml build
- name: Run tests - name: Run tests
run: | run: |
@@ -43,7 +44,8 @@ jobs:
- name: Show service logs on failure - name: Show service logs on failure
if: failure() if: failure()
run: docker compose --env-file .env.test -f docker-compose.test.yaml logs run:
docker compose --env-file .env.test -f docker-compose.test.yaml logs
- name: Upload test results - name: Upload test results
if: always() if: always()
@@ -54,4 +56,6 @@ jobs:
- name: Tear down - name: Tear down
if: always() if: always()
run: docker compose --env-file .env.test -f docker-compose.test.yaml down -v run:
docker compose --env-file .env.test -f docker-compose.test.yaml down
-v

View File

@@ -1,13 +1,4 @@
--- # Architecture
title: System architecture — backend, frontend, database, and deployment
last-updated: 2026-08-31
tags:
- architecture
- backend
- frontend
- vertical-slice
- deployment
---
This document describes the architecture of The Biergarten App. This document describes the architecture of The Biergarten App.
@@ -134,13 +125,12 @@ own
**Components**: **Components**:
- `Program.cs`: registers MediatR (scanning every `Features.*` assembly), | Component | Description |
FluentValidation, and uses `AddApplicationPart` so each slice's controllers | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
are discovered by MVC | `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
- `GlobalException.cs`: global exception filter (host-level cross-cutting | `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
concern) | `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware | Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |
- Swagger/OpenAPI documentation, health check endpoints
**Dependencies**: **Dependencies**:
@@ -159,29 +149,28 @@ own
**Components** (per slice): **Components** (per slice):
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the | Component | Description |
request contract | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation, | `Controllers/` | HTTP endpoints, binding directly to Command/Query types as the request contract |
each containing the Command/Query record, its `IRequestHandler`, and (for | `Commands/<Operation>/` and `Queries/<Operation>/` | One folder per operation, each containing the Command/Query record, its `IRequestHandler`, and (for commands) a FluentValidation validator |
commands) a FluentValidation validator | `Repository/` | The slice's own Dapper repository implementation |
- `Repository/`: the slice's own Dapper repository implementation | `Dtos/` | Response shapes returned by query handlers (never the raw domain entity) |
- `Dtos/`: response shapes returned by query handlers (never the raw domain | `DependencyInjection/` | An `AddFeaturesX()` extension method registering the slice's repository/services |
entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the ##### Note
slice's repository/services
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR `Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
commands sent from other slices, never over HTTP. `Features.Locations` also has commands sent from other slices, never over HTTP.
no `Controllers/` folder and no MediatR handlers; it exposes
`ILocationRepository` directly as a plain service, currently consumed only by `Features.Locations` also has no `Controllers/` folder and no MediatR handlers;
`Database.Seed` to look up cities while seeding brewery locations. Other slices it exposes `ILocationRepository` directly as a plain service, currently consumed
(for example, `Features.Breweries`) do not yet depend on it; they run their own only by `Database.Seed` to look up cities while seeding brewery locations. Other
`CityID` existence checks inline. `Features.PhotoUpload` also has no slices (for example, `Features.Breweries`) do not yet depend on it; they run
`Controllers/` folder; its `UploadPhotoCommand` is sent by other features' their own `CityID` existence checks inline.
handlers (for example, a future brewery photo upload command) rather than bound
to an HTTP route directly. The one exception today is the `/upload-test` minimal `Features.PhotoUpload` also has no `Controllers/` folder; its
API endpoint wired directly in `API.Core/Program.cs`, a temporary route for `UploadPhotoCommand` is sent by other features' handlers (for example, a future
exercising the upload path end to end. brewery photo upload command) rather than bound to an HTTP route directly.
**Dependencies**: **Dependencies**:
@@ -207,12 +196,10 @@ needs it, or because duplicating it four times would be worse than sharing it
**Components**: **Components**:
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response | Project | Purpose |
envelope every controller returns | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR | `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns |
pipeline behavior that runs FluentValidation before a handler executes) and | `Shared.Application` | `ValidationBehavior<TRequest,TResponse>` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) |
the cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
**Rules**: **Rules**:
@@ -226,19 +213,14 @@ whichever slices need them
**Components**: **Components**:
- **Database.Connection**: generic ADO.NET connection/command plumbing | Project | Description |
(`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
not domain-specific; each slice's own `Repository/` folder builds on this | `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. |
- **Infrastructure.Jwt**: JWT token generation and validation | `Infrastructure.Jwt` | JWT token generation and validation. |
- **Infrastructure.PasswordHashing**: Argon2id password hashing | `Infrastructure.PasswordHashing` | Argon2id password hashing. |
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit) | `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). |
- **Infrastructure.Email.Templates**: Email template rendering (Razor | `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
components) | `Infrastructure.FileUpload` | `IFileStorageProvider` abstraction over S3-compatible object storage, implemented by `S3FileStorageProvider` (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's `AmazonS3Client` with `ForcePathStyle = true`. |
- **Infrastructure.FileUpload**: `IFileStorageProvider` abstraction over
S3-compatible object storage, implemented by `S3FileStorageProvider` (targets
the SeaweedFS container in dev; any S3-compatible endpoint in other
environments) using the AWS SDK's `AmazonS3Client` with
`ForcePathStyle = true`
**Dependencies**: **Dependencies**:
@@ -256,19 +238,19 @@ whichever slices need them
**Components**: **Components**:
- `UserAccount` - User profile data | Entity | Description |
- `UserCredential` - Authentication credentials | --------------------- | -------------------------------------------------------------------------------------------------- |
- `UserVerification` - Account verification state | `UserAccount` | User profile data. |
- `BreweryPost` - A user-submitted brewery listing | `UserCredential` | Authentication credentials. |
- `BreweryPostLocation` - A brewery's address and coordinates | `UserVerification` | Account verification state. |
- `City` - A city referenced by a brewery location | `BreweryPost` | A user-submitted brewery listing. |
- `StateProvince` - A state/province referenced by a city | `BreweryPostLocation` | A brewery's address and coordinates. |
- `Country` - A country referenced by a state/province | `City` | A city referenced by a brewery location. |
- `BeerStyle` - A beer style/category (schema table exists; no repository yet) | `StateProvince` | A state/province referenced by a city. |
- `BeerPost` - A beer listing tied to a brewery and style (schema table exists; | `Country` | A country referenced by a state/province. |
no repository yet) | `BeerStyle` | A beer style/category (schema table exists; no repository yet). |
- `Photo` - A photo uploaded by a user account, written via | `BeerPost` | A beer listing tied to a brewery and style (schema table exists; no repository yet). |
`Features.PhotoUpload`'s `IPhotoUploadRepository` | `Photo` | A photo uploaded by a user account, written via `Features.PhotoUpload`'s `IPhotoUploadRepository`. |
**Dependencies**: **Dependencies**:

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,55 +1,56 @@
skinparam shadowing false ' skinparam shadowing false
skinparam backgroundColor #FCFCF7 ' skinparam backgroundColor #FCFCF7
skinparam defaultFontName "DM Sans" ' skinparam defaultFontName "DM Sans"
skinparam defaultFontColor #14180C ' skinparam defaultFontColor #14180C
skinparam titleFontName "Volkhov" ' skinparam titleFontName "Volkhov"
skinparam titleFontColor #14180C ' skinparam titleFontColor #14180C
skinparam ArrowColor #656F33 ' skinparam ArrowColor #656F33
skinparam NoteBackgroundColor #DBEEDD ' skinparam NoteBackgroundColor #DBEEDD
skinparam NoteFontColor #14180C ' skinparam NoteFontColor #14180C
skinparam NoteBorderColor #4A5837
skinparam SwimlaneBorderColor #4A5837 ' skinparam NoteBorderColor #4A5837
skinparam SwimlaneBorderThickness 1 ' skinparam SwimlaneBorderColor #4A5837
skinparam activityStartColor #EBECE3 ' skinparam SwimlaneBorderThickness 1
skinparam activityEndColor #4A5837 ' skinparam activityStartColor #EBECE3
skinparam activityStopColor #4A5837 ' skinparam activityEndColor #4A5837
skinparam ActivityBackgroundColor #EBECE3 ' skinparam activityStopColor #4A5837
skinparam ActivityBorderColor #4A5837 ' skinparam ActivityBackgroundColor #EBECE3
skinparam ActivityDiamondBackgroundColor #CBD2B5 ' skinparam ActivityBorderColor #4A5837
skinparam ActivityDiamondBorderColor #4A5837 ' skinparam ActivityDiamondBackgroundColor #CBD2B5
skinparam packageStyle rectangle ' skinparam ActivityDiamondBorderColor #4A5837
skinparam packageBackgroundColor #F1F3EA ' skinparam packageStyle rectangle
skinparam packageBorderColor #4A5837 ' skinparam packageBackgroundColor #F1F3EA
skinparam packageFontColor #14180C ' skinparam packageBorderColor #4A5837
skinparam classBackgroundColor #EBECE3 ' skinparam packageFontColor #14180C
skinparam classBorderColor #4A5837 ' skinparam classBackgroundColor #EBECE3
skinparam classFontColor #14180C ' skinparam classBorderColor #4A5837
skinparam classAttributeFontColor #3F4724 ' skinparam classFontColor #14180C
skinparam classStereotypeFontColor #4A5837 ' skinparam classAttributeFontColor #3F4724
skinparam interfaceBackgroundColor #DBEEDD ' skinparam classStereotypeFontColor #4A5837
skinparam interfaceBorderColor #4A5837 ' skinparam interfaceBackgroundColor #DBEEDD
skinparam interfaceFontColor #14180C ' skinparam interfaceBorderColor #4A5837
skinparam enumBackgroundColor #E4E6D8 ' skinparam interfaceFontColor #14180C
skinparam enumBorderColor #4A5837 ' skinparam enumBackgroundColor #E4E6D8
skinparam enumFontColor #14180C ' skinparam enumBorderColor #4A5837
skinparam ParticipantBackgroundColor #EBECE3 ' skinparam enumFontColor #14180C
skinparam ParticipantBorderColor #4A5837 ' skinparam ParticipantBackgroundColor #EBECE3
skinparam ParticipantFontColor #14180C ' skinparam ParticipantBorderColor #4A5837
skinparam ActorBackgroundColor #DBEEDD ' skinparam ParticipantFontColor #14180C
skinparam ActorBorderColor #4A5837 ' skinparam ActorBackgroundColor #DBEEDD
skinparam ActorFontColor #14180C ' skinparam ActorBorderColor #4A5837
skinparam LifeLineBorderColor #4A5837 ' skinparam ActorFontColor #14180C
skinparam LifeLineBackgroundColor #F1F3EA ' skinparam LifeLineBorderColor #4A5837
skinparam DatabaseBackgroundColor #EBECE3 ' skinparam LifeLineBackgroundColor #F1F3EA
skinparam DatabaseBorderColor #4A5837 ' skinparam DatabaseBackgroundColor #EBECE3
skinparam DatabaseFontColor #14180C ' skinparam DatabaseBorderColor #4A5837
skinparam SequenceGroupBackgroundColor #DBEEDD ' skinparam DatabaseFontColor #14180C
skinparam SequenceGroupBorderColor #4A5837 ' skinparam SequenceGroupBackgroundColor #DBEEDD
skinparam SequenceGroupFontColor #14180C ' skinparam SequenceGroupBorderColor #4A5837
skinparam SequenceGroupBodyBackgroundColor transparent ' skinparam SequenceGroupFontColor #14180C
skinparam SequenceDividerBackgroundColor #DBEEDD ' skinparam SequenceGroupBodyBackgroundColor transparent
skinparam SequenceDividerBorderColor #4A5837 ' skinparam SequenceDividerBackgroundColor #DBEEDD
skinparam SequenceDividerFontColor #14180C ' skinparam SequenceDividerBorderColor #4A5837
skinparam SequenceBoxBackgroundColor #F1F3EA ' skinparam SequenceDividerFontColor #14180C
skinparam SequenceBoxBorderColor #4A5837 ' skinparam SequenceBoxBackgroundColor #F1F3EA
skinparam SequenceBoxFontColor #14180C ' skinparam SequenceBoxBorderColor #4A5837
' skinparam SequenceBoxFontColor #14180C

View File

@@ -0,0 +1,84 @@
# The Biergarten App — Vertical Slice Architecture
```mermaid
flowchart TB
subgraph HOST["API.Core (thin host)"]
API["API.Core<br/>ASP.NET Core Web API host"]
end
subgraph SLICES["Feature Slices"]
UsersSlice["Features.Users"]
BrewerySlice["Features.Breweries"]
EmailsSlice["Features.Emails<br/>(no controller)"]
LocationsSlice["Features.Locations<br/>(no controller, no MediatR)"]
end
subgraph SHARED["Shared"]
SharedContracts["Shared.Contracts"]
SharedApp["Shared.Application"]
end
subgraph INFRA["Infrastructure Layer"]
Sql["Infrastructure.Sql"]
JWT["Infrastructure.Jwt"]
PwdHash["Infrastructure.PasswordHashing"]
Email["Infrastructure.Email"]
EmailTemplates["Infrastructure.Email.Templates"]
end
subgraph DOMAIN["Domain Layer"]
Domain["Domain.Entities"]
DomainExceptions["Domain.Exceptions"]
end
Tables[("SQL Server<br/>Tables")]
Seed["Database.Seed<br/>(separate console app)"]
API -.->|AddApplicationPart| UsersSlice
API -.->|AddApplicationPart| BrewerySlice
API -.->|MediatR scan only| EmailsSlice
Seed -.->|AddFeaturesUsers| UsersSlice
Seed -.->|AddFeaturesBreweries| BrewerySlice
Seed -.->|AddFeaturesLocations| LocationsSlice
UsersSlice --> Sql
UsersSlice --> JWT
UsersSlice --> PwdHash
UsersSlice --> SharedContracts
UsersSlice --> SharedApp
BrewerySlice --> Sql
BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp
LocationsSlice --> Sql
EmailsSlice --> Email
EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp
UsersSlice -.->|sends SendRegistrationEmailCommand| SharedApp
EmailsSlice -.->|handles SendRegistrationEmailCommand| SharedApp
Sql --> Domain
UsersSlice --> Tables
BrewerySlice --> Tables
LocationsSlice --> Tables
UsersSlice --> Domain
BrewerySlice --> Domain
LocationsSlice --> Domain
UsersSlice --> DomainExceptions
```
## Notes
- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter.
- **Feature slices**: each owns its own controller (HTTP endpoints), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`). `Features.Emails` has no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller and no MediatR handlers — it exposes `ILocationRepository` directly as a plain DI service, not registered by `API.Core` at all; only `Database.Seed` depends on it today.
- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — the one cross-slice interaction is a MediatR command contract, not a project reference.
- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle` (schema tables exist for the last two; no repository yet).
- **Infrastructure.Sql**: SQL-first approach — each slice's own repository issues inline SQL via Dapper, no ORM, no stored procedures.
- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly — no API host, no MediatR pipeline.
- No `Features.*` project ever references another `Features.*` project directly.

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
# User Authentication Flow — Error Handling
Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`. 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.
```mermaid
sequenceDiagram
participant API as API Controller
Note over API: GlobalExceptionFilter catches:
Note over API: FluentValidation.ValidationException → 400 Bad Request {message, errors} (grouped by property)
Note over API: ConflictException → 409 Conflict
Note over API: NotFoundException → 404 Not Found
Note over API: UnauthorizedException → 401 Unauthorized
Note over API: ForbiddenException → 403 Forbidden
Note over API: Domain.Exceptions.ValidationException → 400 Bad Request
Note over API: Microsoft.Data.SqlClient.SqlException → 503 Service Unavailable
Note over API: All others → 500 Internal Server Error
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
# Docker Deployment Architecture
```mermaid
flowchart TB
Developer(["Developer"])
Host[["Docker Host"]]
Developer -->|docker compose up| Host
Host -->|http://localhost:8080| DevAPI
subgraph DEV["Development Environment (docker-compose.dev.yaml)"]
DevDB[("SQL Server 2022<br/>Biergarten Database<br/>:1433")]
DevAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
DevMig["Migrations (run-once)<br/>Database.Migrations"]
DevSeed["Seed (run-once)<br/>Database.Seed"]
DevMailpit["Mailpit<br/>:8025 Web UI / :1025 SMTP"]
end
DevMig -->|1. Run migrations| DevDB
DevSeed -->|2. Seed data| DevDB
DevAPI -->|3. Connect and serve| DevDB
DevAPI -.->|4. SMTP_HOST/PORT| DevMailpit
DevMig -.->|depends_on| DevDB
DevSeed -.->|depends_on| DevMig
DevAPI -.->|depends_on| DevSeed
subgraph TEST["Test Environment (docker-compose.test.yaml)"]
TestDB[("SQL Server, isolated instance<br/>Biergarten Test Database<br/>:1434")]
TestMig["Migrations (test)"]
TestSeed["Seed (test)<br/>minimal: test.user only"]
Specs["API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)"]
UnitTests["unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container"]
end
TestMig -->|1. Migrate| TestDB
TestSeed -->|2. Seed| TestDB
Specs -->|3. Integration test| TestDB
UnitTests -.->|depends_on for startup ordering only, no real connection| TestDB
TestMig -.->|depends_on| TestDB
TestSeed -.->|depends_on| TestMig
Specs -.->|depends_on| TestSeed
UnitTests -.->|depends_on| TestSeed
Results[["test-results/ (mounted volume)<br/>api-specs/results.trx<br/>Features.Users.Tests.trx<br/>Features.Breweries.Tests.trx<br/>Features.Emails.Tests.trx"]]
Specs -->|Export TRX| Results
UnitTests -->|Export TRX, one per slice| Results
subgraph PROD["Production Environment (docker-compose.prod.yaml)"]
ProdDB[("SQL Server 2022<br/>Biergarten Database<br/>no host port published")]
ProdMig["Migrations (run-once)"]
ProdAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
end
ProdMig -->|1. Run migrations| ProdDB
ProdAPI -->|2. Connect and serve| ProdDB
ProdMig -.->|depends_on| ProdDB
ProdAPI -.->|"depends_on: sql-server healthy (not migrations/seed)"| ProdDB
subgraph DBONLY["Database-only Environment (docker-compose.db.yaml)"]
DbOnlyDB[("SQL Server<br/>:1434")]
DbOnlyMig["Migrations (run-once)"]
DbOnlySeed["Seed (run-once)"]
end
DbOnlyMig -->|1. Run migrations| DbOnlyDB
DbOnlySeed -->|2. Seed data| DbOnlyDB
DbOnlyMig -.->|depends_on| DbOnlyDB
DbOnlySeed -.->|depends_on| DbOnlyMig
subgraph MIN["Minimal Environment (docker-compose.min.yaml)"]
MinDB[("SQL Server<br/>:1433")]
MinMig["Migrations (run-once)"]
MinMailpit["Mailpit<br/>:8025 / :1025"]
MinSeed["Seed (run-once)"]
end
MinMig -->|1. Run migrations| MinDB
MinSeed -->|2. Seed data| MinDB
MinMig -.->|depends_on| MinDB
MinSeed -.->|depends_on| MinMig
```
## Notes
- **Dev network** (`biergarten-dev`, bridge): internal DNS resolves `sql-server` and `api`. Volumes: `biergarten-dev-data`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once) → API (long-running).
- **Test network** (`biergarten-test`, bridge): all test components isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `biergarten-test-data` volume). All tests run in parallel, results aggregated.
- **Prod network** (`prodnet`, bridge): isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production.
- **Database-only environment**: same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core` and `mailpit` — for working against the database without running the API.
- **Minimal environment**: database + mailpit only, no API container — for frontend development against an `API.Core` process run outside Docker.
- `unit.tests` builds `Dockerfile.tests`, which restores/builds against `Core.slnx` then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed.
- `.trx` results are readable by Visual Studio, Azure DevOps, and GitHub Actions.

View File

@@ -1,5 +1,7 @@
@startuml architecture @startuml architecture_v6
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml skinparam linetype ortho
skinparam nodesep 90
skinparam ranksep 110
title The Biergarten App - Vertical Slice Architecture title The Biergarten App - Vertical Slice Architecture
@@ -85,13 +87,19 @@ database "SQL Server" {
} }
node "Database.Seed\n(separate console app)" as Seed node "Database.Seed\n(separate console app)" as Seed
note right of Seed
Seed calls each slice's
AddFeatures*() DI extension
directly - no API host,
no MediatR pipeline
end note
' Relationships ' Relationships
API ..> UsersSlice : AddApplicationPart\n+ MediatR scan API ..> UsersSlice : AddApplicationPart
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan API ..> BrewerySlice : AddApplicationPart
API ..> EmailsSlice : MediatR scan only\n(no controller) API ..> EmailsSlice : MediatR scan only
Seed ..> UsersSlice : AddFeaturesUsers()\n(DI only, no API/MediatR) Seed ..> UsersSlice : AddFeaturesUsers()
Seed ..> BrewerySlice : AddFeaturesBreweries() Seed ..> BrewerySlice : AddFeaturesBreweries()
Seed ..> LocationsSlice : AddFeaturesLocations() Seed ..> LocationsSlice : AddFeaturesLocations()
@@ -118,8 +126,8 @@ EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> Domain Sql --> Domain
UsersSlice --> Tables : Dapper SQL UsersSlice --> Tables : Dapper SQL
BrewerySlice --> Tables : Dapper SQL BrewerySlice --> Tables
LocationsSlice --> Tables : Dapper SQL LocationsSlice --> Tables
UsersSlice --> Domain UsersSlice --> Domain
BrewerySlice --> Domain BrewerySlice --> Domain

View File

@@ -170,49 +170,33 @@ package "Beer (*)" {
} }
' Relationships: User & Auth ' Relationships: User & Auth
User ||--o{ Cred : "has" User ||--o{ Cred
User ||--o| Verify : "has" User ||--o| Verify
User ||--o| Profile : "has" User ||--o| Profile
Profile ||--o{ Avatar : "has (history)" Profile ||--o{ Avatar
Avatar }o--|| Photo : "refers to" Avatar }o--|| Photo
User ||--o{ Follow : "follows (UserAccountID)" User ||--o{ Follow
User ||--o{ Follow : "followed by (FollowingID)" User ||--o{ Follow
User ||--o{ Photo : "uploaded" User ||--o{ Photo
' Relationships: Location ' Relationships: Location
Country ||--o{ StateProvince : "has" Country ||--o{ StateProvince
StateProvince ||--o{ City : "has" StateProvince ||--o{ City
' Relationships: Brewery ' Relationships: Brewery
User ||--o{ Brewery : "posted" User ||--o{ Brewery
Brewery ||--o| BreweryLoc : "has" Brewery ||--o| BreweryLoc
City ||--o{ BreweryLoc : "located in" City ||--o{ BreweryLoc
Brewery ||--o{ BreweryPhoto : "has" Brewery ||--o{ BreweryPhoto
Photo ||--o{ BreweryPhoto : "linked to" Photo ||--o{ BreweryPhoto
' Relationships: Beer ' Relationships: Beer
User ||--o{ Beer : "posted" User ||--o{ Beer
BeerStyle ||--o{ Beer : "categorizes" BeerStyle ||--o{ Beer
Brewery ||--o{ Beer : "brewed" Brewery ||--o{ Beer
Beer ||--o{ BeerPhoto : "has" Beer ||--o{ BeerPhoto
Photo ||--o{ BeerPhoto : "linked to" Photo ||--o{ BeerPhoto
Beer ||--o{ BeerComment : "has" Beer ||--o{ BeerComment
User ||--o{ BeerComment : "commented" User ||--o{ BeerComment
note bottom of User
Auth reads/writes (registration, login, credential
rotation, verification) go through ASP.NET Core
Identity's UserManager<ApplicationUser>, backed by
Features.Users' DapperUserStore - inline SQL, no
stored procedures. Profile/avatar data goes through
Features.Users' UserProfileRepository.
end note
note bottom of Brewery
Backed by Features.Breweries' BreweryRepository
(Dapper); create/update/delete run inline SQL
inside a transaction, with optimistic concurrency
via RowVersion
end note
@enduml @enduml

View File

@@ -10,13 +10,13 @@ tags:
## Overview ## Overview
The Core project validates JSON Web Tokens (JWTs) across three token types: The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication | Token Type | Lifetime | Purpose |
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access | --- | --- | --- |
tokens | **Access Tokens** | 1 hour | API authentication |
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email | **Refresh Tokens** | 21 days | Obtaining new access tokens |
confirmation | **Confirmation Tokens** | 30 minutes | Email confirmation |
## Components ## Components
@@ -183,29 +183,35 @@ Validation failures return HTTP 401 Unauthorized:
### Access token lifecycle ### Access token lifecycle
1. **Generation**: During login (1-hour validity) | Step | Description |
2. **Usage**: Included in Authorization header on API requests | --- | --- |
3. **Validation**: Validated on protected endpoints | Generation | During login (1-hour validity) |
4. **Expiration**: Token becomes invalid after 1 hour | Usage | Included in Authorization header on API requests |
5. **Refresh**: Use refresh token to obtain new access token | Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token |
### Refresh token lifecycle ### Refresh token lifecycle
1. **Generation**: During login (21-day validity) | Step | Description |
2. **Storage**: Client-side (secure storage) | --- | --- |
3. **Usage**: Posted to `/api/auth/refresh` endpoint | Generation | During login (21-day validity) |
4. **Validation**: Validated by `ITokenService.RefreshTokenAsync()` | Storage | Client-side (secure storage) |
5. **Rotation**: New refresh token issued on successful refresh | Usage | Posted to `/api/auth/refresh` endpoint |
6. **Expiration**: Token becomes invalid after 21 days | Validation | Validated by `ITokenService.RefreshTokenAsync()` |
| Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days |
### Confirmation token lifecycle ### Confirmation token lifecycle
1. **Generation**: During user registration (30-minute validity) | Step | Description |
2. **Delivery**: Emailed to user in confirmation link | --- | --- |
3. **Usage**: User clicks link, token posted to `/api/auth/confirm` | Generation | During user registration (30-minute validity) |
4. **Validation**: Validated by `ConfirmUserHandler` | Delivery | Emailed to user in confirmation link |
5. **Completion**: User account marked as confirmed | Usage | User clicks link, token posted to `/api/auth/confirm` |
6. **Expiration**: Token becomes invalid after 30 minutes | Validation | Validated by `ConfirmUserHandler` |
| Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes |
## Testing ## Testing
@@ -266,21 +272,21 @@ All of the following live in `Features.Users.Tests`.
### Stretch goals ### Stretch goals
1. **Middleware for Access Token Validation** 1. **Middleware for Access Token Validation**
- Automatically validate access tokens on protected routes - Automatically validate access tokens on protected routes
- Populate HttpContext.User from token claims - Populate HttpContext.User from token claims
- Return 401 for invalid/missing tokens - Return 401 for invalid/missing tokens
2. **Token Denylisting** 2. **Token Denylisting**
- Implement token revocation (for example, on logout) - Implement token revocation (for example, on logout)
- Store denylisted tokens in cache/database - Store denylisted tokens in cache/database
- Check the denylist during validation - Check the denylist during validation
3. **Refresh Token Rotation Strategy** 3. **Refresh Token Rotation Strategy**
- Detect token reuse (replay attacks) - Detect token reuse (replay attacks)
- Automatically invalidate entire token chain on reuse - Automatically invalidate entire token chain on reuse
- Log suspicious activity - Log suspicious activity
4. **Structured Logging** 4. **Structured Logging**
- Log token validation attempts - Log token validation attempts
- Track failed validation reasons - Track failed validation reasons
- Alert on repeated validation failures (brute force detection) - Alert on repeated validation failures (brute force detection)

58
mkdocs.yml Normal file
View File

@@ -0,0 +1,58 @@
site_name: The Biergarten App
site_url: https://github.io
repo_url: https://github.com
repo_name: the-biergarten-app
theme:
name: material
nav:
- Home: index.md
- Architecture: architecture.md
- Diagrams:
- Website:
- Architecture: website/diagrams-md/architecture.md
- Database Schema: website/diagrams-md/database-schema.md
- Deployment: website/diagrams-md/deployment.md
- Auth Flows:
- Registration: website/diagrams-md/auth/registration.md
- Login: website/diagrams-md/auth/login.md
- Refresh Token: website/diagrams-md/auth/refresh-token.md
- Confirm User: website/diagrams-md/auth/confirm-user.md
- Resend Confirmation: website/diagrams-md/auth/resend-confirmation.md
- Update Username: website/diagrams-md/auth/update-username.md
- Update Email: website/diagrams-md/auth/update-email.md
- Update Password: website/diagrams-md/auth/update-password.md
- Update Profile: website/diagrams-md/auth/update-profile.md
- Delete Account: website/diagrams-md/auth/delete-account.md
- Error Handling: website/diagrams-md/auth/error-handling.md
- Pipeline:
- Current:
- Activity: pipeline/diagrams-md/current/activity.md
- Class: pipeline/diagrams-md/current/class.md
- Planned:
- Activity: pipeline/diagrams-md/planned/activity.md
- Class: pipeline/diagrams-md/planned/class.md
- Website:
- Getting Started: website/getting-started.md
- Database: website/database.md
- Docker: website/docker.md
- Environment Variables: website/environment-variables.md
- Testing: website/testing.md
- Token Validation: website/token-validation.md
- Pipeline:
- Overview: pipeline/README.md
- Roadmap: pipeline/ROADMAP.md
- Ethics & Known Issues: pipeline/ETHICS-AND-KNOWN-ISSUES.md
plugins:
- search
- glightbox
- panzoom:
mermaid: true
markdown_extensions:
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format