From f4471c7fd75fac5160cebdf64ae42fef18931e8e Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Mon, 31 Aug 2026 19:54:04 -0400 Subject: [PATCH] Add mkdocs site and GitHub Pages deployment for docs --- .github/workflows/deploy-docs.yml | 52 ++ .github/workflows/tests.yml | 10 +- docs/architecture.md | 122 ++-- docs/pipeline/diagrams-md/current/activity.md | 80 +++ docs/pipeline/diagrams-md/current/class.md | 264 ++++++++ docs/pipeline/diagrams-md/planned/activity.md | 200 ++++++ docs/pipeline/diagrams-md/planned/class.md | 593 ++++++++++++++++++ .../diagrams/biergarten-weizen-theme.puml | 111 ++-- docs/website/diagrams-md/architecture.md | 84 +++ docs/website/diagrams-md/auth/confirm-user.md | 85 +++ .../diagrams-md/auth/delete-account.md | 71 +++ .../diagrams-md/auth/error-handling.md | 18 + docs/website/diagrams-md/auth/login.md | 99 +++ .../website/diagrams-md/auth/refresh-token.md | 83 +++ docs/website/diagrams-md/auth/registration.md | 157 +++++ .../diagrams-md/auth/resend-confirmation.md | 77 +++ docs/website/diagrams-md/auth/update-email.md | 70 +++ .../diagrams-md/auth/update-password.md | 90 +++ .../diagrams-md/auth/update-profile.md | 69 ++ .../diagrams-md/auth/update-username.md | 74 +++ docs/website/diagrams-md/database-schema.md | 168 +++++ docs/website/diagrams-md/deployment.md | 90 +++ docs/website/diagrams-src/architecture.puml | 24 +- .../website/diagrams-src/database-schema.puml | 60 +- docs/website/token-validation.md | 76 +-- mkdocs.yml | 58 ++ 26 files changed, 2676 insertions(+), 209 deletions(-) create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 docs/pipeline/diagrams-md/current/activity.md create mode 100644 docs/pipeline/diagrams-md/current/class.md create mode 100644 docs/pipeline/diagrams-md/planned/activity.md create mode 100644 docs/pipeline/diagrams-md/planned/class.md create mode 100644 docs/website/diagrams-md/architecture.md create mode 100644 docs/website/diagrams-md/auth/confirm-user.md create mode 100644 docs/website/diagrams-md/auth/delete-account.md create mode 100644 docs/website/diagrams-md/auth/error-handling.md create mode 100644 docs/website/diagrams-md/auth/login.md create mode 100644 docs/website/diagrams-md/auth/refresh-token.md create mode 100644 docs/website/diagrams-md/auth/registration.md create mode 100644 docs/website/diagrams-md/auth/resend-confirmation.md create mode 100644 docs/website/diagrams-md/auth/update-email.md create mode 100644 docs/website/diagrams-md/auth/update-password.md create mode 100644 docs/website/diagrams-md/auth/update-profile.md create mode 100644 docs/website/diagrams-md/auth/update-username.md create mode 100644 docs/website/diagrams-md/database-schema.md create mode 100644 docs/website/diagrams-md/deployment.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 00000000..2ab8b0a2 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e6630881..aaba03c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,8 @@ jobs: run: ./generate-env.sh .env.test - 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 run: | @@ -43,7 +44,8 @@ jobs: - name: Show service logs on 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 if: always() @@ -54,4 +56,6 @@ jobs: - name: Tear down 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 diff --git a/docs/architecture.md b/docs/architecture.md index 7d5afcd8..659bb04b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,13 +1,4 @@ ---- -title: System architecture — backend, frontend, database, and deployment -last-updated: 2026-08-31 -tags: - - architecture - - backend - - frontend - - vertical-slice - - deployment ---- +# Architecture This document describes the architecture of The Biergarten App. @@ -134,13 +125,12 @@ own **Components**: -- `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 - concern) -- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware -- Swagger/OpenAPI documentation, health check endpoints +| Component | Description | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `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 concern). | +| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. | +| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. | **Dependencies**: @@ -159,29 +149,28 @@ own **Components** (per slice): -- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the - request contract -- `Commands//` and `Queries//`: one folder per operation, - each containing the Command/Query record, its `IRequestHandler`, and (for - commands) a FluentValidation validator -- `Repository/`: the slice's own Dapper repository implementation -- `Dtos/`: response shapes returned by query handlers (never the raw domain - entity) -- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the - slice's repository/services +| Component | Description | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `Controllers/` | HTTP endpoints, binding directly to Command/Query types as the request contract | +| `Commands//` and `Queries//` | One folder per operation, each containing the Command/Query record, its `IRequestHandler`, and (for commands) a FluentValidation validator | +| `Repository/` | The slice's own Dapper repository implementation | +| `Dtos/` | Response shapes returned by query handlers (never the raw domain entity) | +| `DependencyInjection/` | An `AddFeaturesX()` extension method registering the slice's repository/services | + +##### Note `Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR -commands sent from other slices, never over HTTP. `Features.Locations` also has -no `Controllers/` folder and no MediatR handlers; it exposes -`ILocationRepository` directly as a plain service, currently consumed only by -`Database.Seed` to look up cities while seeding brewery locations. Other slices -(for example, `Features.Breweries`) do not yet depend on it; they run their own -`CityID` existence checks inline. `Features.PhotoUpload` also has no -`Controllers/` folder; its `UploadPhotoCommand` is sent by other features' -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 -API endpoint wired directly in `API.Core/Program.cs`, a temporary route for -exercising the upload path end to end. +commands sent from other slices, never over HTTP. + +`Features.Locations` also has no `Controllers/` folder and no MediatR handlers; +it exposes `ILocationRepository` directly as a plain service, currently consumed +only by `Database.Seed` to look up cities while seeding brewery locations. Other +slices (for example, `Features.Breweries`) do not yet depend on it; they run +their own `CityID` existence checks inline. + +`Features.PhotoUpload` also has no `Controllers/` folder; its +`UploadPhotoCommand` is sent by other features' handlers (for example, a future +brewery photo upload command) rather than bound to an HTTP route directly. **Dependencies**: @@ -207,12 +196,10 @@ needs it, or because duplicating it four times would be worse than sharing it **Components**: -- `Shared.Contracts`: `ResponseBody`/`ResponseBody`, the API response - envelope every controller returns -- `Shared.Application`: `ValidationBehavior` (the MediatR - pipeline behavior that runs FluentValidation before a handler executes) and - the cross-slice email commands (`SendRegistrationEmailCommand`, - `SendResendConfirmationEmailCommand`) +| Project | Purpose | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Shared.Contracts` | `ResponseBody`/`ResponseBody`, the API response envelope every controller returns | +| `Shared.Application` | `ValidationBehavior` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) | **Rules**: @@ -226,19 +213,14 @@ whichever slices need them **Components**: -- **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.PasswordHashing**: Argon2id password hashing -- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit) -- **Infrastructure.Email.Templates**: Email template rendering (Razor - 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` +| Project | Description | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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.PasswordHashing` | Argon2id password hashing. | +| `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). | +| `Infrastructure.Email.Templates` | Email template rendering (Razor 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`. | **Dependencies**: @@ -256,19 +238,19 @@ whichever slices need them **Components**: -- `UserAccount` - User profile data -- `UserCredential` - Authentication credentials -- `UserVerification` - Account verification state -- `BreweryPost` - A user-submitted brewery listing -- `BreweryPostLocation` - A brewery's address and coordinates -- `City` - A city referenced by a brewery location -- `StateProvince` - A state/province referenced by a city -- `Country` - A country referenced by a state/province -- `BeerStyle` - A beer style/category (schema table exists; no repository yet) -- `BeerPost` - A beer listing tied to a brewery and style (schema table exists; - no repository yet) -- `Photo` - A photo uploaded by a user account, written via - `Features.PhotoUpload`'s `IPhotoUploadRepository` +| Entity | Description | +| --------------------- | -------------------------------------------------------------------------------------------------- | +| `UserAccount` | User profile data. | +| `UserCredential` | Authentication credentials. | +| `UserVerification` | Account verification state. | +| `BreweryPost` | A user-submitted brewery listing. | +| `BreweryPostLocation` | A brewery's address and coordinates. | +| `City` | A city referenced by a brewery location. | +| `StateProvince` | A state/province referenced by a city. | +| `Country` | A country referenced by a state/province. | +| `BeerStyle` | A beer style/category (schema table exists; no repository yet). | +| `BeerPost` | A beer listing tied to a brewery and style (schema table exists; no repository yet). | +| `Photo` | A photo uploaded by a user account, written via `Features.PhotoUpload`'s `IPhotoUploadRepository`. | **Dependencies**: diff --git a/docs/pipeline/diagrams-md/current/activity.md b/docs/pipeline/diagrams-md/current/activity.md new file mode 100644 index 00000000..0054a3b9 --- /dev/null +++ b/docs/pipeline/diagrams-md/current/activity.md @@ -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(...)
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()
builds biergarten_seed_<UTC datetime>.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)
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
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
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
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
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)
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
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
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)
generation + jitter run inside one try/catch per record"] + BrewGenDone -- no --> BrewGenFail["Log warning: generation failed, skipping"] --> BrewLoop + + BrewPostProc --> BrewExport["SqliteExportService.ProcessRecord(GeneratedBrewery)
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()
commits transaction, closes database connection"] + + Finalize --> CloseLog["main.cc: close log_channel, join log dispatcher thread"] + CloseLog --> Stop2(["return 0 / stop"]) +``` diff --git a/docs/pipeline/diagrams-md/current/class.md b/docs/pipeline/diagrams-md/current/class.md new file mode 100644 index 00000000..3ead5e4a --- /dev/null +++ b/docs/pipeline/diagrams-md/current/class.md @@ -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 { + <> + Debug + Info + Warn + Error + } + + class PipelinePhase { + <> + Startup + Enrichment + UserGeneration + BreweryAndBeerGeneration + CheckinGeneration + RatingGeneration + FollowGeneration + Teardown + } + + class LogDTO { + <> + +LogLevel level + +PipelinePhase phase + +string message + } + + class LogEntry { + <> + +time_point timestamp + +source_location origin + +thread::id thread_id + +LogLevel level + +PipelinePhase phase + +string message + } + + class ILogger { + <> + +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 { + <> + +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 { + <> + +Get(url) string + +UrlEncode(value) string + } + + class HttpWebClient { + +Get(url) string + +UrlEncode(value) string + } + + class DataGenerator { + <> + +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 { + <> + +Format(system_prompt, user_prompt) string + } + + class Gemma4JinjaPromptFormatter { + +Format(system_prompt, user_prompt) string + } + + class IPromptDirectory { + <> + +Load(key) string + } + + class PromptDirectory { + -path prompt_dir_ + -unordered_map~string, string~ cache_ + +Load(key) string + } + + class ICuratedDataService { + <> + +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 { + <> + +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 { + <> + +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 +``` diff --git a/docs/pipeline/diagrams-md/planned/activity.md b/docs/pipeline/diagrams-md/planned/activity.md new file mode 100644 index 00000000..e6c4e5f5 --- /dev/null +++ b/docs/pipeline/diagrams-md/planned/activity.md @@ -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
opens SQLite connection; transactions now managed per-phase via batching"] + InitSqlite --> LogSetup["Main: create BoundedChannel log_ch, spawn Log Worker thread
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)
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)
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
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
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
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
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_)
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_)
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_)
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
checkin_pool_ and follow_pool_ are now fully populated"] + + PhaseJoin --> RatingPhase["Orchestrator: RunRatingPhase()
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]) +``` diff --git a/docs/pipeline/diagrams-md/planned/class.md b/docs/pipeline/diagrams-md/planned/class.md new file mode 100644 index 00000000..ff6b7b1a --- /dev/null +++ b/docs/pipeline/diagrams-md/planned/class.md @@ -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 { + <> + 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 { + <> + +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 { + <> + +Sample(locations) vector~Location~ + } + + class UniformSamplingStrategy { + -size_t sample_size_ + +Sample(locations) vector~Location~ + } + + class BeerSelectionStrategy { + <> + +SelectStyles(brewery, palette) vector~BeerStyle~ + } + + class RandomBeerSelectionStrategy { + -mt19937 rng_ + -size_t min_beers_ + -size_t max_beers_ + +SelectStyles(brewery, palette) vector~BeerStyle~ + } + + class CheckinDistributionStrategy { + <> + +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 { + <> + +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 { + <> + Debug + Info + Warn + Error + } + + class PipelinePhase { + <> + Startup + UserGeneration + BreweryAndBeerGeneration + CheckinGeneration + RatingGeneration + FollowGeneration + Teardown + } + + class LogEntry { + +time_point timestamp + +LogLevel level + +PipelinePhase phase + +string message + +optional~string~ worker + } + + class ILogger { + <> + +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 { + <> + +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 { + <> + +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 { + <> + +Get(url) string + +UrlEncode(value) string + } + + class HttpWebClient { + +Get(url) string + +UrlEncode(value) string + } + } + + namespace InfrastructurePrompting { + class IPromptDirectory { + <> + +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 { + <> + +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 { + <> + +Format(system_prompt, user_prompt) string + +ExpectedArchitecture() string_view + } + + class Gemma4JinjaPromptFormatter { + +Format(system_prompt, user_prompt) string + +ExpectedArchitecture() string_view + } + } + + namespace InfrastructureDataExport { + class ExportService { + <> + +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 { + <> + +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. diff --git a/docs/pipeline/diagrams/biergarten-weizen-theme.puml b/docs/pipeline/diagrams/biergarten-weizen-theme.puml index b85f22c9..d20a6260 100644 --- a/docs/pipeline/diagrams/biergarten-weizen-theme.puml +++ b/docs/pipeline/diagrams/biergarten-weizen-theme.puml @@ -1,55 +1,56 @@ -skinparam shadowing false -skinparam backgroundColor #FCFCF7 -skinparam defaultFontName "DM Sans" -skinparam defaultFontColor #14180C -skinparam titleFontName "Volkhov" -skinparam titleFontColor #14180C -skinparam ArrowColor #656F33 -skinparam NoteBackgroundColor #DBEEDD -skinparam NoteFontColor #14180C -skinparam NoteBorderColor #4A5837 -skinparam SwimlaneBorderColor #4A5837 -skinparam SwimlaneBorderThickness 1 -skinparam activityStartColor #EBECE3 -skinparam activityEndColor #4A5837 -skinparam activityStopColor #4A5837 -skinparam ActivityBackgroundColor #EBECE3 -skinparam ActivityBorderColor #4A5837 -skinparam ActivityDiamondBackgroundColor #CBD2B5 -skinparam ActivityDiamondBorderColor #4A5837 -skinparam packageStyle rectangle -skinparam packageBackgroundColor #F1F3EA -skinparam packageBorderColor #4A5837 -skinparam packageFontColor #14180C -skinparam classBackgroundColor #EBECE3 -skinparam classBorderColor #4A5837 -skinparam classFontColor #14180C -skinparam classAttributeFontColor #3F4724 -skinparam classStereotypeFontColor #4A5837 -skinparam interfaceBackgroundColor #DBEEDD -skinparam interfaceBorderColor #4A5837 -skinparam interfaceFontColor #14180C -skinparam enumBackgroundColor #E4E6D8 -skinparam enumBorderColor #4A5837 -skinparam enumFontColor #14180C -skinparam ParticipantBackgroundColor #EBECE3 -skinparam ParticipantBorderColor #4A5837 -skinparam ParticipantFontColor #14180C -skinparam ActorBackgroundColor #DBEEDD -skinparam ActorBorderColor #4A5837 -skinparam ActorFontColor #14180C -skinparam LifeLineBorderColor #4A5837 -skinparam LifeLineBackgroundColor #F1F3EA -skinparam DatabaseBackgroundColor #EBECE3 -skinparam DatabaseBorderColor #4A5837 -skinparam DatabaseFontColor #14180C -skinparam SequenceGroupBackgroundColor #DBEEDD -skinparam SequenceGroupBorderColor #4A5837 -skinparam SequenceGroupFontColor #14180C -skinparam SequenceGroupBodyBackgroundColor transparent -skinparam SequenceDividerBackgroundColor #DBEEDD -skinparam SequenceDividerBorderColor #4A5837 -skinparam SequenceDividerFontColor #14180C -skinparam SequenceBoxBackgroundColor #F1F3EA -skinparam SequenceBoxBorderColor #4A5837 -skinparam SequenceBoxFontColor #14180C +' skinparam shadowing false +' skinparam backgroundColor #FCFCF7 +' skinparam defaultFontName "DM Sans" +' skinparam defaultFontColor #14180C +' skinparam titleFontName "Volkhov" +' skinparam titleFontColor #14180C +' skinparam ArrowColor #656F33 +' skinparam NoteBackgroundColor #DBEEDD +' skinparam NoteFontColor #14180C + +' skinparam NoteBorderColor #4A5837 +' skinparam SwimlaneBorderColor #4A5837 +' skinparam SwimlaneBorderThickness 1 +' skinparam activityStartColor #EBECE3 +' skinparam activityEndColor #4A5837 +' skinparam activityStopColor #4A5837 +' skinparam ActivityBackgroundColor #EBECE3 +' skinparam ActivityBorderColor #4A5837 +' skinparam ActivityDiamondBackgroundColor #CBD2B5 +' skinparam ActivityDiamondBorderColor #4A5837 +' skinparam packageStyle rectangle +' skinparam packageBackgroundColor #F1F3EA +' skinparam packageBorderColor #4A5837 +' skinparam packageFontColor #14180C +' skinparam classBackgroundColor #EBECE3 +' skinparam classBorderColor #4A5837 +' skinparam classFontColor #14180C +' skinparam classAttributeFontColor #3F4724 +' skinparam classStereotypeFontColor #4A5837 +' skinparam interfaceBackgroundColor #DBEEDD +' skinparam interfaceBorderColor #4A5837 +' skinparam interfaceFontColor #14180C +' skinparam enumBackgroundColor #E4E6D8 +' skinparam enumBorderColor #4A5837 +' skinparam enumFontColor #14180C +' skinparam ParticipantBackgroundColor #EBECE3 +' skinparam ParticipantBorderColor #4A5837 +' skinparam ParticipantFontColor #14180C +' skinparam ActorBackgroundColor #DBEEDD +' skinparam ActorBorderColor #4A5837 +' skinparam ActorFontColor #14180C +' skinparam LifeLineBorderColor #4A5837 +' skinparam LifeLineBackgroundColor #F1F3EA +' skinparam DatabaseBackgroundColor #EBECE3 +' skinparam DatabaseBorderColor #4A5837 +' skinparam DatabaseFontColor #14180C +' skinparam SequenceGroupBackgroundColor #DBEEDD +' skinparam SequenceGroupBorderColor #4A5837 +' skinparam SequenceGroupFontColor #14180C +' skinparam SequenceGroupBodyBackgroundColor transparent +' skinparam SequenceDividerBackgroundColor #DBEEDD +' skinparam SequenceDividerBorderColor #4A5837 +' skinparam SequenceDividerFontColor #14180C +' skinparam SequenceBoxBackgroundColor #F1F3EA +' skinparam SequenceBoxBorderColor #4A5837 +' skinparam SequenceBoxFontColor #14180C diff --git a/docs/website/diagrams-md/architecture.md b/docs/website/diagrams-md/architecture.md new file mode 100644 index 00000000..2b59d477 --- /dev/null +++ b/docs/website/diagrams-md/architecture.md @@ -0,0 +1,84 @@ +# The Biergarten App — Vertical Slice Architecture + +```mermaid +flowchart TB + subgraph HOST["API.Core (thin host)"] + API["API.Core
ASP.NET Core Web API host"] + end + + subgraph SLICES["Feature Slices"] + UsersSlice["Features.Users"] + BrewerySlice["Features.Breweries"] + EmailsSlice["Features.Emails
(no controller)"] + LocationsSlice["Features.Locations
(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
Tables")] + Seed["Database.Seed
(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. diff --git a/docs/website/diagrams-md/auth/confirm-user.md b/docs/website/diagrams-md/auth/confirm-user.md new file mode 100644 index 00000000..2e7532d5 --- /dev/null +++ b/docs/website/diagrams-md/auth/confirm-user.md @@ -0,0 +1,85 @@ +# User Authentication Flow — Confirm User + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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 +``` diff --git a/docs/website/diagrams-md/auth/delete-account.md b/docs/website/diagrams-md/auth/delete-account.md new file mode 100644 index 00000000..6b3b1caa --- /dev/null +++ b/docs/website/diagrams-md/auth/delete-account.md @@ -0,0 +1,71 @@ +# User Authentication Flow — Delete Account + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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 +``` diff --git a/docs/website/diagrams-md/auth/error-handling.md b/docs/website/diagrams-md/auth/error-handling.md new file mode 100644 index 00000000..ebf53d5d --- /dev/null +++ b/docs/website/diagrams-md/auth/error-handling.md @@ -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 +``` diff --git a/docs/website/diagrams-md/auth/login.md b/docs/website/diagrams-md/auth/login.md new file mode 100644 index 00000000..6cf6cfa1 --- /dev/null +++ b/docs/website/diagrams-md/auth/login.md @@ -0,0 +1,99 @@ +# User Authentication Flow — Login + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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 +``` diff --git a/docs/website/diagrams-md/auth/refresh-token.md b/docs/website/diagrams-md/auth/refresh-token.md new file mode 100644 index 00000000..c0156c5b --- /dev/null +++ b/docs/website/diagrams-md/auth/refresh-token.md @@ -0,0 +1,83 @@ +# User Authentication Flow — Refresh Token + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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
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 +``` diff --git a/docs/website/diagrams-md/auth/registration.md b/docs/website/diagrams-md/auth/registration.md new file mode 100644 index 00000000..b77159c1 --- /dev/null +++ b/docs/website/diagrams-md/auth/registration.md @@ -0,0 +1,157 @@ +# User Authentication Flow — Registration + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(IUserStore/IUserPasswordStore/IUserEmailStore) + participant EmailDispatcher as EmailDispatcher
(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 +``` diff --git a/docs/website/diagrams-md/auth/resend-confirmation.md b/docs/website/diagrams-md/auth/resend-confirmation.md new file mode 100644 index 00000000..e7b9e2f6 --- /dev/null +++ b/docs/website/diagrams-md/auth/resend-confirmation.md @@ -0,0 +1,77 @@ +# User Authentication Flow — Resend Confirmation Email + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(IUserStore/IUserPasswordStore/IUserEmailStore) + participant EmailDispatcher as EmailDispatcher
(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 +``` diff --git a/docs/website/diagrams-md/auth/update-email.md b/docs/website/diagrams-md/auth/update-email.md new file mode 100644 index 00000000..53a3f953 --- /dev/null +++ b/docs/website/diagrams-md/auth/update-email.md @@ -0,0 +1,70 @@ +# User Authentication Flow — Update Email + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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 +``` diff --git a/docs/website/diagrams-md/auth/update-password.md b/docs/website/diagrams-md/auth/update-password.md new file mode 100644 index 00000000..a47a75fa --- /dev/null +++ b/docs/website/diagrams-md/auth/update-password.md @@ -0,0 +1,90 @@ +# User Authentication Flow — Update Password + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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 +``` diff --git a/docs/website/diagrams-md/auth/update-profile.md b/docs/website/diagrams-md/auth/update-profile.md new file mode 100644 index 00000000..14adfe5f --- /dev/null +++ b/docs/website/diagrams-md/auth/update-profile.md @@ -0,0 +1,69 @@ +# User Authentication Flow — Update Profile + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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 = ...
user.LastName = ...
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 +``` diff --git a/docs/website/diagrams-md/auth/update-username.md b/docs/website/diagrams-md/auth/update-username.md new file mode 100644 index 00000000..6baf2a51 --- /dev/null +++ b/docs/website/diagrams-md/auth/update-username.md @@ -0,0 +1,74 @@ +# User Authentication Flow — Update Username + +```mermaid +sequenceDiagram + actor User + participant API as API Controller + participant Mediator as MediatR pipeline
(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
(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 +``` diff --git a/docs/website/diagrams-md/database-schema.md b/docs/website/diagrams-md/database-schema.md new file mode 100644 index 00000000..be309cb9 --- /dev/null +++ b/docs/website/diagrams-md/database-schema.md @@ -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" + } +``` diff --git a/docs/website/diagrams-md/deployment.md b/docs/website/diagrams-md/deployment.md new file mode 100644 index 00000000..ced7d8e9 --- /dev/null +++ b/docs/website/diagrams-md/deployment.md @@ -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
Biergarten Database
:1433")] + DevAPI["API Container
ASP.NET Core 10
:8080 HTTP / :8081 HTTPS"] + DevMig["Migrations (run-once)
Database.Migrations"] + DevSeed["Seed (run-once)
Database.Seed"] + DevMailpit["Mailpit
: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
Biergarten Test Database
:1434")] + TestMig["Migrations (test)"] + TestSeed["Seed (test)
minimal: test.user only"] + Specs["API.Specs
Reqnroll + xUnit
(integration tests)"] + UnitTests["unit.tests
xUnit + Moq
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)
api-specs/results.trx
Features.Users.Tests.trx
Features.Breweries.Tests.trx
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
Biergarten Database
no host port published")] + ProdMig["Migrations (run-once)"] + ProdAPI["API Container
ASP.NET Core 10
: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
: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
:1433")] + MinMig["Migrations (run-once)"] + MinMailpit["Mailpit
: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. diff --git a/docs/website/diagrams-src/architecture.puml b/docs/website/diagrams-src/architecture.puml index 80405957..269740e2 100644 --- a/docs/website/diagrams-src/architecture.puml +++ b/docs/website/diagrams-src/architecture.puml @@ -1,5 +1,7 @@ -@startuml architecture -!include ../../pipeline/diagrams/biergarten-weizen-theme.puml +@startuml architecture_v6 +skinparam linetype ortho +skinparam nodesep 90 +skinparam ranksep 110 title The Biergarten App - Vertical Slice Architecture @@ -85,13 +87,19 @@ database "SQL Server" { } node "Database.Seed\n(separate console app)" as Seed +note right of Seed + Seed calls each slice's + AddFeatures*() DI extension + directly - no API host, + no MediatR pipeline +end note ' Relationships -API ..> UsersSlice : AddApplicationPart\n+ MediatR scan -API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan -API ..> EmailsSlice : MediatR scan only\n(no controller) +API ..> UsersSlice : AddApplicationPart +API ..> BrewerySlice : AddApplicationPart +API ..> EmailsSlice : MediatR scan only -Seed ..> UsersSlice : AddFeaturesUsers()\n(DI only, no API/MediatR) +Seed ..> UsersSlice : AddFeaturesUsers() Seed ..> BrewerySlice : AddFeaturesBreweries() Seed ..> LocationsSlice : AddFeaturesLocations() @@ -118,8 +126,8 @@ EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand Sql --> Domain UsersSlice --> Tables : Dapper SQL -BrewerySlice --> Tables : Dapper SQL -LocationsSlice --> Tables : Dapper SQL +BrewerySlice --> Tables +LocationsSlice --> Tables UsersSlice --> Domain BrewerySlice --> Domain diff --git a/docs/website/diagrams-src/database-schema.puml b/docs/website/diagrams-src/database-schema.puml index 31777a12..2c9ae97d 100644 --- a/docs/website/diagrams-src/database-schema.puml +++ b/docs/website/diagrams-src/database-schema.puml @@ -170,49 +170,33 @@ package "Beer (*)" { } ' Relationships: User & Auth -User ||--o{ Cred : "has" -User ||--o| Verify : "has" -User ||--o| Profile : "has" -Profile ||--o{ Avatar : "has (history)" -Avatar }o--|| Photo : "refers to" -User ||--o{ Follow : "follows (UserAccountID)" -User ||--o{ Follow : "followed by (FollowingID)" -User ||--o{ Photo : "uploaded" +User ||--o{ Cred +User ||--o| Verify +User ||--o| Profile +Profile ||--o{ Avatar +Avatar }o--|| Photo +User ||--o{ Follow +User ||--o{ Follow +User ||--o{ Photo ' Relationships: Location -Country ||--o{ StateProvince : "has" -StateProvince ||--o{ City : "has" +Country ||--o{ StateProvince +StateProvince ||--o{ City ' Relationships: Brewery -User ||--o{ Brewery : "posted" -Brewery ||--o| BreweryLoc : "has" -City ||--o{ BreweryLoc : "located in" -Brewery ||--o{ BreweryPhoto : "has" -Photo ||--o{ BreweryPhoto : "linked to" +User ||--o{ Brewery +Brewery ||--o| BreweryLoc +City ||--o{ BreweryLoc +Brewery ||--o{ BreweryPhoto +Photo ||--o{ BreweryPhoto ' Relationships: Beer -User ||--o{ Beer : "posted" -BeerStyle ||--o{ Beer : "categorizes" -Brewery ||--o{ Beer : "brewed" -Beer ||--o{ BeerPhoto : "has" -Photo ||--o{ BeerPhoto : "linked to" -Beer ||--o{ BeerComment : "has" -User ||--o{ BeerComment : "commented" - -note bottom of User - Auth reads/writes (registration, login, credential - rotation, verification) go through ASP.NET Core - Identity's UserManager, 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 +User ||--o{ Beer +BeerStyle ||--o{ Beer +Brewery ||--o{ Beer +Beer ||--o{ BeerPhoto +Photo ||--o{ BeerPhoto +Beer ||--o{ BeerComment +User ||--o{ BeerComment @enduml diff --git a/docs/website/token-validation.md b/docs/website/token-validation.md index a9a21dee..bda828d8 100644 --- a/docs/website/token-validation.md +++ b/docs/website/token-validation.md @@ -10,13 +10,13 @@ tags: ## 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 -- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access - tokens -- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email - confirmation +| Token Type | Lifetime | Purpose | +| --- | --- | --- | +| **Access Tokens** | 1 hour | API authentication | +| **Refresh Tokens** | 21 days | Obtaining new access tokens | +| **Confirmation Tokens** | 30 minutes | Email confirmation | ## Components @@ -183,29 +183,35 @@ Validation failures return HTTP 401 Unauthorized: ### Access token lifecycle -1. **Generation**: During login (1-hour validity) -2. **Usage**: Included in Authorization header on API requests -3. **Validation**: Validated on protected endpoints -4. **Expiration**: Token becomes invalid after 1 hour -5. **Refresh**: Use refresh token to obtain new access token +| Step | Description | +| --- | --- | +| Generation | During login (1-hour validity) | +| Usage | Included in Authorization header on API requests | +| Validation | Validated on protected endpoints | +| Expiration | Token becomes invalid after 1 hour | +| Refresh | Use refresh token to obtain new access token | ### Refresh token lifecycle -1. **Generation**: During login (21-day validity) -2. **Storage**: Client-side (secure storage) -3. **Usage**: Posted to `/api/auth/refresh` endpoint -4. **Validation**: Validated by `ITokenService.RefreshTokenAsync()` -5. **Rotation**: New refresh token issued on successful refresh -6. **Expiration**: Token becomes invalid after 21 days +| Step | Description | +| --- | --- | +| Generation | During login (21-day validity) | +| Storage | Client-side (secure storage) | +| Usage | Posted to `/api/auth/refresh` endpoint | +| Validation | Validated by `ITokenService.RefreshTokenAsync()` | +| Rotation | New refresh token issued on successful refresh | +| Expiration | Token becomes invalid after 21 days | ### Confirmation token lifecycle -1. **Generation**: During user registration (30-minute validity) -2. **Delivery**: Emailed to user in confirmation link -3. **Usage**: User clicks link, token posted to `/api/auth/confirm` -4. **Validation**: Validated by `ConfirmUserHandler` -5. **Completion**: User account marked as confirmed -6. **Expiration**: Token becomes invalid after 30 minutes +| Step | Description | +| --- | --- | +| Generation | During user registration (30-minute validity) | +| Delivery | Emailed to user in confirmation link | +| Usage | User clicks link, token posted to `/api/auth/confirm` | +| Validation | Validated by `ConfirmUserHandler` | +| Completion | User account marked as confirmed | +| Expiration | Token becomes invalid after 30 minutes | ## Testing @@ -266,21 +272,21 @@ All of the following live in `Features.Users.Tests`. ### Stretch goals 1. **Middleware for Access Token Validation** - - Automatically validate access tokens on protected routes - - Populate HttpContext.User from token claims - - Return 401 for invalid/missing tokens + - Automatically validate access tokens on protected routes + - Populate HttpContext.User from token claims + - Return 401 for invalid/missing tokens 2. **Token Denylisting** - - Implement token revocation (for example, on logout) - - Store denylisted tokens in cache/database - - Check the denylist during validation + - Implement token revocation (for example, on logout) + - Store denylisted tokens in cache/database + - Check the denylist during validation 3. **Refresh Token Rotation Strategy** - - Detect token reuse (replay attacks) - - Automatically invalidate entire token chain on reuse - - Log suspicious activity + - Detect token reuse (replay attacks) + - Automatically invalidate entire token chain on reuse + - Log suspicious activity 4. **Structured Logging** - - Log token validation attempts - - Track failed validation reasons - - Alert on repeated validation failures (brute force detection) + - Log token validation attempts + - Track failed validation reasons + - Alert on repeated validation failures (brute force detection) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..e01d8bca --- /dev/null +++ b/mkdocs.yml @@ -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