mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add mkdocs site and GitHub Pages deployment for docs
This commit is contained in:
52
.github/workflows/deploy-docs.yml
vendored
Normal file
52
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: Deploy Docs to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- docs
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "mkdocs.yml"
|
||||||
|
- ".github/workflows/deploy-docs.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install mkdocs mkdocs-material mkdocs-glightbox mkdocs-panzoom-plugin
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: mkdocs build
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: "site"
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
10
.github/workflows/tests.yml
vendored
10
.github/workflows/tests.yml
vendored
@@ -21,7 +21,8 @@ jobs:
|
|||||||
run: ./generate-env.sh .env.test
|
run: ./generate-env.sh .env.test
|
||||||
|
|
||||||
- name: Build test images
|
- name: Build test images
|
||||||
run: docker compose --env-file .env.test -f docker-compose.test.yaml build
|
run:
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml build
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
@@ -43,7 +44,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Show service logs on failure
|
- name: Show service logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose --env-file .env.test -f docker-compose.test.yaml logs
|
run:
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml logs
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
@@ -54,4 +56,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose --env-file .env.test -f docker-compose.test.yaml down -v
|
run:
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml down
|
||||||
|
-v
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
---
|
# Architecture
|
||||||
title: System architecture — backend, frontend, database, and deployment
|
|
||||||
last-updated: 2026-08-31
|
|
||||||
tags:
|
|
||||||
- architecture
|
|
||||||
- backend
|
|
||||||
- frontend
|
|
||||||
- vertical-slice
|
|
||||||
- deployment
|
|
||||||
---
|
|
||||||
|
|
||||||
This document describes the architecture of The Biergarten App.
|
This document describes the architecture of The Biergarten App.
|
||||||
|
|
||||||
@@ -134,13 +125,12 @@ own
|
|||||||
|
|
||||||
**Components**:
|
**Components**:
|
||||||
|
|
||||||
- `Program.cs`: registers MediatR (scanning every `Features.*` assembly),
|
| Component | Description |
|
||||||
FluentValidation, and uses `AddApplicationPart` so each slice's controllers
|
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
are discovered by MVC
|
| `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
|
||||||
- `GlobalException.cs`: global exception filter (host-level cross-cutting
|
| `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
|
||||||
concern)
|
| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
|
||||||
- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware
|
| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |
|
||||||
- Swagger/OpenAPI documentation, health check endpoints
|
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -159,29 +149,28 @@ own
|
|||||||
|
|
||||||
**Components** (per slice):
|
**Components** (per slice):
|
||||||
|
|
||||||
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the
|
| Component | Description |
|
||||||
request contract
|
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
|
| `Controllers/` | HTTP endpoints, binding directly to Command/Query types as the request contract |
|
||||||
each containing the Command/Query record, its `IRequestHandler`, and (for
|
| `Commands/<Operation>/` and `Queries/<Operation>/` | One folder per operation, each containing the Command/Query record, its `IRequestHandler`, and (for commands) a FluentValidation validator |
|
||||||
commands) a FluentValidation validator
|
| `Repository/` | The slice's own Dapper repository implementation |
|
||||||
- `Repository/`: the slice's own Dapper repository implementation
|
| `Dtos/` | Response shapes returned by query handlers (never the raw domain entity) |
|
||||||
- `Dtos/`: response shapes returned by query handlers (never the raw domain
|
| `DependencyInjection/` | An `AddFeaturesX()` extension method registering the slice's repository/services |
|
||||||
entity)
|
|
||||||
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
|
##### Note
|
||||||
slice's repository/services
|
|
||||||
|
|
||||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||||
commands sent from other slices, never over HTTP. `Features.Locations` also has
|
commands sent from other slices, never over HTTP.
|
||||||
no `Controllers/` folder and no MediatR handlers; it exposes
|
|
||||||
`ILocationRepository` directly as a plain service, currently consumed only by
|
`Features.Locations` also has no `Controllers/` folder and no MediatR handlers;
|
||||||
`Database.Seed` to look up cities while seeding brewery locations. Other slices
|
it exposes `ILocationRepository` directly as a plain service, currently consumed
|
||||||
(for example, `Features.Breweries`) do not yet depend on it; they run their own
|
only by `Database.Seed` to look up cities while seeding brewery locations. Other
|
||||||
`CityID` existence checks inline. `Features.PhotoUpload` also has no
|
slices (for example, `Features.Breweries`) do not yet depend on it; they run
|
||||||
`Controllers/` folder; its `UploadPhotoCommand` is sent by other features'
|
their own `CityID` existence checks inline.
|
||||||
handlers (for example, a future brewery photo upload command) rather than bound
|
|
||||||
to an HTTP route directly. The one exception today is the `/upload-test` minimal
|
`Features.PhotoUpload` also has no `Controllers/` folder; its
|
||||||
API endpoint wired directly in `API.Core/Program.cs`, a temporary route for
|
`UploadPhotoCommand` is sent by other features' handlers (for example, a future
|
||||||
exercising the upload path end to end.
|
brewery photo upload command) rather than bound to an HTTP route directly.
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -207,12 +196,10 @@ needs it, or because duplicating it four times would be worse than sharing it
|
|||||||
|
|
||||||
**Components**:
|
**Components**:
|
||||||
|
|
||||||
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
|
| Project | Purpose |
|
||||||
envelope every controller returns
|
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR
|
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns |
|
||||||
pipeline behavior that runs FluentValidation before a handler executes) and
|
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) |
|
||||||
the cross-slice email commands (`SendRegistrationEmailCommand`,
|
|
||||||
`SendResendConfirmationEmailCommand`)
|
|
||||||
|
|
||||||
**Rules**:
|
**Rules**:
|
||||||
|
|
||||||
@@ -226,19 +213,14 @@ whichever slices need them
|
|||||||
|
|
||||||
**Components**:
|
**Components**:
|
||||||
|
|
||||||
- **Database.Connection**: generic ADO.NET connection/command plumbing
|
| Project | Description |
|
||||||
(`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class),
|
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
not domain-specific; each slice's own `Repository/` folder builds on this
|
| `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. |
|
||||||
- **Infrastructure.Jwt**: JWT token generation and validation
|
| `Infrastructure.Jwt` | JWT token generation and validation. |
|
||||||
- **Infrastructure.PasswordHashing**: Argon2id password hashing
|
| `Infrastructure.PasswordHashing` | Argon2id password hashing. |
|
||||||
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
|
| `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). |
|
||||||
- **Infrastructure.Email.Templates**: Email template rendering (Razor
|
| `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
|
||||||
components)
|
| `Infrastructure.FileUpload` | `IFileStorageProvider` abstraction over S3-compatible object storage, implemented by `S3FileStorageProvider` (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's `AmazonS3Client` with `ForcePathStyle = true`. |
|
||||||
- **Infrastructure.FileUpload**: `IFileStorageProvider` abstraction over
|
|
||||||
S3-compatible object storage, implemented by `S3FileStorageProvider` (targets
|
|
||||||
the SeaweedFS container in dev; any S3-compatible endpoint in other
|
|
||||||
environments) using the AWS SDK's `AmazonS3Client` with
|
|
||||||
`ForcePathStyle = true`
|
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -256,19 +238,19 @@ whichever slices need them
|
|||||||
|
|
||||||
**Components**:
|
**Components**:
|
||||||
|
|
||||||
- `UserAccount` - User profile data
|
| Entity | Description |
|
||||||
- `UserCredential` - Authentication credentials
|
| --------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
- `UserVerification` - Account verification state
|
| `UserAccount` | User profile data. |
|
||||||
- `BreweryPost` - A user-submitted brewery listing
|
| `UserCredential` | Authentication credentials. |
|
||||||
- `BreweryPostLocation` - A brewery's address and coordinates
|
| `UserVerification` | Account verification state. |
|
||||||
- `City` - A city referenced by a brewery location
|
| `BreweryPost` | A user-submitted brewery listing. |
|
||||||
- `StateProvince` - A state/province referenced by a city
|
| `BreweryPostLocation` | A brewery's address and coordinates. |
|
||||||
- `Country` - A country referenced by a state/province
|
| `City` | A city referenced by a brewery location. |
|
||||||
- `BeerStyle` - A beer style/category (schema table exists; no repository yet)
|
| `StateProvince` | A state/province referenced by a city. |
|
||||||
- `BeerPost` - A beer listing tied to a brewery and style (schema table exists;
|
| `Country` | A country referenced by a state/province. |
|
||||||
no repository yet)
|
| `BeerStyle` | A beer style/category (schema table exists; no repository yet). |
|
||||||
- `Photo` - A photo uploaded by a user account, written via
|
| `BeerPost` | A beer listing tied to a brewery and style (schema table exists; no repository yet). |
|
||||||
`Features.PhotoUpload`'s `IPhotoUploadRepository`
|
| `Photo` | A photo uploaded by a user account, written via `Features.PhotoUpload`'s `IPhotoUploadRepository`. |
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
|
|||||||
80
docs/pipeline/diagrams-md/current/activity.md
Normal file
80
docs/pipeline/diagrams-md/current/activity.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# The Biergarten Data Pipeline (Current — Synchronous Data Path)
|
||||||
|
|
||||||
|
The only concurrency in the current pipeline is a dedicated log dispatcher thread that drains `log_channel` and forwards entries to spdlog for the entire run (joined during shutdown after `log_channel` closes). `BiergartenPipelineOrchestrator::Run()` itself is synchronous — sampling, enrichment, generation, and export all happen on the main thread.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start([start]) --> ParseArgs["main.cc: ParseArguments(argc, argv)"]
|
||||||
|
ParseArgs --> ArgsValid{Arguments valid?}
|
||||||
|
ArgsValid -- no --> LogErr[Log error / usage info] --> Stop1([stop])
|
||||||
|
ArgsValid -- yes --> Init["main.cc: Init OpenSSL global state and LlamaBackendState"]
|
||||||
|
Init --> DI["main.cc: di::make_injector(...)<br/>binds ILogger, IEnrichmentService, DataGenerator, IExportService, IPromptFormatter, WebClient based on --mocked vs. model-backed mode"]
|
||||||
|
DI --> LogThread["main.cc: create BoundedChannel log_channel, spawn log dispatcher thread (LogDispatcher::Run)"]
|
||||||
|
LogThread --> CreateOrch["main.cc: injector.create BiergartenPipelineOrchestrator"]
|
||||||
|
CreateOrch --> Run["BiergartenPipelineOrchestrator::Run()"]
|
||||||
|
|
||||||
|
Run --> SqliteInit["SqliteExportService.Initialize()<br/>builds biergarten_seed_<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)<br/>WikipediaEnrichmentService fetches a generic 'brewing' extract and a 'beer in {country}' extract (not city/region-specific); MockEnrichmentService (--mocked) returns an empty string"]
|
||||||
|
GetContext --> LookupFailed{Lookup failed?}
|
||||||
|
LookupFailed -- yes --> SkipCity1[Log warning, skip city] --> EnrichLoop
|
||||||
|
LookupFailed -- no --> StoreEnriched["Store EnrichedCity{Location, region_context}"] --> EnrichLoop
|
||||||
|
EnrichLoop -- done --> GenUsers[Orchestrator: GenerateUsers enriched_cities]
|
||||||
|
|
||||||
|
GenUsers --> LoadUserData["JsonLoader: LoadPersonas / LoadForenamesByCountry / LoadSurnamesByCountry<br/>each memoized after first parse; MockCuratedDataService (--mocked) returns a fixed in-memory dataset instead, skipping these JSON files"]
|
||||||
|
|
||||||
|
LoadUserData --> UserLoop{For each EnrichedCity}
|
||||||
|
UserLoop --> SampleName["SampleName(forenames_by_country, surnames_by_country, location.iso3166_1)"]
|
||||||
|
SampleName --> NamesAvail{Names available for country?}
|
||||||
|
NamesAvail -- no --> SkipCity2[Log warning, skip city] --> UserLoop
|
||||||
|
NamesAvail -- yes --> SamplePersona["Sample UserPersona (uniform random)"]
|
||||||
|
SamplePersona --> UserGenMode{Generator mode}
|
||||||
|
|
||||||
|
UserGenMode -- MockGenerator --> UserMockGen[DeterministicHash and Format]
|
||||||
|
UserGenMode -- LlamaGenerator --> UserLlamaLoad["prompt_directory_.Load(USER_GENERATION) → USER_GENERATION.md in --prompt-dir"]
|
||||||
|
UserLlamaLoad --> UserLlamaRetry["Infer(...) with kUserJsonGrammar → ValidateUserJson<br/>retry up to 3 attempts, appending validation error to the retry prompt on failure"]
|
||||||
|
UserGenMode -- OpenAIGenerator --> UserOAILoad["prompt_directory_.Load(USER_GENERATION) — same prompt file as LlamaGenerator, content is backend-agnostic"]
|
||||||
|
UserOAILoad --> UserOAIRetry["CallChatCompletionsApi(...) with kUserJsonSchema → ValidateUserJson<br/>retry up to 2 attempts — Structured Outputs (json_schema, strict) already guarantees schema shape, so retries only cover transient failures / placeholder text"]
|
||||||
|
|
||||||
|
UserMockGen --> UserGenDone{Generation successful?}
|
||||||
|
UserLlamaRetry --> UserGenDone
|
||||||
|
UserOAIRetry --> UserGenDone
|
||||||
|
|
||||||
|
UserGenDone -- yes --> UserPostProc["GetRandomCoordsWithinRange (5km jitter), BuildEmail, GenerateDateOfBirth<br/>email and date_of_birth are generated programmatically, never LLM-authored; generation + coordinate jitter run inside one try/catch per record"]
|
||||||
|
UserGenDone -- no --> UserGenFail["Log warning: generation failed, skipping"] --> UserLoop
|
||||||
|
|
||||||
|
UserPostProc --> UserExport["SqliteExportService.ProcessRecord(UserRecord)<br/>reuse cached location_id or insert Location and cache its id; insert User (FK location_id)"]
|
||||||
|
UserExport --> UserExportFail{Exception during insert?}
|
||||||
|
UserExportFail -- yes --> UserExportWarn["Log warning: SQLite export failed"] --> UserLoop
|
||||||
|
UserExportFail -- no --> UserLoop
|
||||||
|
UserLoop -- done --> GenBrew[Orchestrator: GenerateBreweries enriched_cities]
|
||||||
|
|
||||||
|
GenBrew --> BrewLoop{For each EnrichedCity}
|
||||||
|
BrewLoop --> BrewGenMode{Generator mode}
|
||||||
|
|
||||||
|
BrewGenMode -- MockGenerator --> BrewMockGen[DeterministicHash and Format]
|
||||||
|
BrewGenMode -- LlamaGenerator --> BrewLlamaLoad["PrepareRegionContext, prompt_directory_.Load(BREWERY_GENERATION) → BREWERY_GENERATION.md"]
|
||||||
|
BrewLlamaLoad --> BrewLlamaRetry["Infer(...) with kBreweryJsonGrammar → ValidateBreweryJson<br/>retry up to 3 attempts; max_tokens is fixed across retries, not raised on truncation"]
|
||||||
|
BrewGenMode -- OpenAIGenerator --> BrewOAILoad["PrepareRegionContext, prompt_directory_.Load(BREWERY_GENERATION) — same prompt file as LlamaGenerator"]
|
||||||
|
BrewOAILoad --> BrewOAIRetry["CallChatCompletionsApi(...) with kBreweryJsonSchema → ValidateBreweryJson<br/>retry up to 2 attempts — Structured Outputs already guarantees schema shape"]
|
||||||
|
|
||||||
|
BrewMockGen --> BrewGenDone{Generation successful?}
|
||||||
|
BrewLlamaRetry --> BrewGenDone
|
||||||
|
BrewOAIRetry --> BrewGenDone
|
||||||
|
|
||||||
|
BrewGenDone -- yes --> BrewPostProc["GetRandomCoordsWithinRange (5km jitter)<br/>generation + jitter run inside one try/catch per record"]
|
||||||
|
BrewGenDone -- no --> BrewGenFail["Log warning: generation failed, skipping"] --> BrewLoop
|
||||||
|
|
||||||
|
BrewPostProc --> BrewExport["SqliteExportService.ProcessRecord(GeneratedBrewery)<br/>reuse cached location_id or insert Location and cache its id; insert Brewery (FK location_id)"]
|
||||||
|
BrewExport --> BrewExportFail{Exception during insert?}
|
||||||
|
BrewExportFail -- yes --> BrewExportWarn["Log warning: SQLite export failed (data loss prevented per-record; pipeline continues)"] --> BrewLoop
|
||||||
|
BrewExportFail -- no --> BrewLoop
|
||||||
|
BrewLoop -- done --> Finalize["SqliteExportService.Finalize()<br/>commits transaction, closes database connection"]
|
||||||
|
|
||||||
|
Finalize --> CloseLog["main.cc: close log_channel, join log dispatcher thread"]
|
||||||
|
CloseLog --> Stop2(["return 0 / stop"])
|
||||||
|
```
|
||||||
264
docs/pipeline/diagrams-md/current/class.md
Normal file
264
docs/pipeline/diagrams-md/current/class.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# The Biergarten Data Pipeline — Class Diagram (Current)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class BiergartenPipelineOrchestrator {
|
||||||
|
-shared_ptr~ILogger~ logger_
|
||||||
|
-unique_ptr~IEnrichmentService~ context_service_
|
||||||
|
-unique_ptr~DataGenerator~ generator_
|
||||||
|
-unique_ptr~IExportService~ exporter_
|
||||||
|
-unique_ptr~ICuratedDataService~ curated_data_service_
|
||||||
|
-ApplicationOptions application_options_
|
||||||
|
-vector~BreweryRecord~ generated_breweries_
|
||||||
|
-vector~UserRecord~ generated_users_
|
||||||
|
+Run() bool
|
||||||
|
-QueryLocations() vector~City~
|
||||||
|
-GenerateBreweries(cities) void
|
||||||
|
-GenerateUsers(cities) void
|
||||||
|
-LogResults() void
|
||||||
|
}
|
||||||
|
note for BiergartenPipelineOrchestrator "GenerateBreweries/GenerateUsers jitter each address via GetRandomCoordsWithinRange(), uniformly within 5km of the city centre"
|
||||||
|
|
||||||
|
class LogLevel {
|
||||||
|
<<enumeration>>
|
||||||
|
Debug
|
||||||
|
Info
|
||||||
|
Warn
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
class PipelinePhase {
|
||||||
|
<<enumeration>>
|
||||||
|
Startup
|
||||||
|
Enrichment
|
||||||
|
UserGeneration
|
||||||
|
BreweryAndBeerGeneration
|
||||||
|
CheckinGeneration
|
||||||
|
RatingGeneration
|
||||||
|
FollowGeneration
|
||||||
|
Teardown
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogDTO {
|
||||||
|
<<struct>>
|
||||||
|
+LogLevel level
|
||||||
|
+PipelinePhase phase
|
||||||
|
+string message
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogEntry {
|
||||||
|
<<struct>>
|
||||||
|
+time_point timestamp
|
||||||
|
+source_location origin
|
||||||
|
+thread::id thread_id
|
||||||
|
+LogLevel level
|
||||||
|
+PipelinePhase phase
|
||||||
|
+string message
|
||||||
|
}
|
||||||
|
|
||||||
|
class ILogger {
|
||||||
|
<<interface>>
|
||||||
|
+Log(payload) void
|
||||||
|
-DoLog(entry)* void
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogProducer {
|
||||||
|
-BoundedChannel~LogEntry~ channel_
|
||||||
|
-DoLog(entry) void
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogDispatcher {
|
||||||
|
-BoundedChannel~LogEntry~ channel_
|
||||||
|
+Run() void
|
||||||
|
-ToSpdlogLevel(level) level_enum
|
||||||
|
}
|
||||||
|
|
||||||
|
class IEnrichmentService {
|
||||||
|
<<interface>>
|
||||||
|
+GetLocationContext(loc) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockEnrichmentService {
|
||||||
|
+GetLocationContext(loc) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class WikipediaEnrichmentService {
|
||||||
|
-unique_ptr~WebClient~ client_
|
||||||
|
-unordered_map~string, string~ extract_cache_
|
||||||
|
+GetLocationContext(loc) string
|
||||||
|
-FetchExtract(query) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebClient {
|
||||||
|
<<interface>>
|
||||||
|
+Get(url) string
|
||||||
|
+UrlEncode(value) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpWebClient {
|
||||||
|
+Get(url) string
|
||||||
|
+UrlEncode(value) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataGenerator {
|
||||||
|
<<interface>>
|
||||||
|
+GenerateBrewery(city) BreweryResult
|
||||||
|
+GenerateUser(city, persona, name) UserResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGenerator {
|
||||||
|
+GenerateBrewery(city) BreweryResult
|
||||||
|
+GenerateUser(city, persona, name) UserResult
|
||||||
|
-DeterministicHash(location) size_t
|
||||||
|
-DeterministicHash(location, persona, name) size_t
|
||||||
|
}
|
||||||
|
|
||||||
|
class LlamaGenerator {
|
||||||
|
-ModelHandle model_
|
||||||
|
-ContextHandle context_
|
||||||
|
-unique_ptr~IPromptFormatter~ prompt_formatter_
|
||||||
|
-unique_ptr~IPromptDirectory~ prompt_directory_
|
||||||
|
-mt19937 rng_
|
||||||
|
+GenerateBrewery(...) BreweryResult
|
||||||
|
+GenerateUser(...) UserResult
|
||||||
|
-Load(model_path) void
|
||||||
|
-Infer(...) string
|
||||||
|
-InferFormatted(...) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpenAIGenerator {
|
||||||
|
-string api_key_
|
||||||
|
-string model_
|
||||||
|
-shared_ptr~ILogger~ logger_
|
||||||
|
-unique_ptr~IPromptDirectory~ prompt_directory_
|
||||||
|
-unique_ptr~WebClient~ web_client_
|
||||||
|
+GenerateBrewery(city) BreweryResult
|
||||||
|
+GenerateUser(city, persona, name) UserResult
|
||||||
|
-CallChatCompletionsApi(...) string
|
||||||
|
}
|
||||||
|
note for OpenAIGenerator "Chat Completions with Structured Outputs (json_schema, strict) guarantees schema-valid JSON, replacing llama.cpp's GBNF grammar — no IPromptFormatter needed"
|
||||||
|
|
||||||
|
class IPromptFormatter {
|
||||||
|
<<interface>>
|
||||||
|
+Format(system_prompt, user_prompt) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Gemma4JinjaPromptFormatter {
|
||||||
|
+Format(system_prompt, user_prompt) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class IPromptDirectory {
|
||||||
|
<<interface>>
|
||||||
|
+Load(key) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class PromptDirectory {
|
||||||
|
-path prompt_dir_
|
||||||
|
-unordered_map~string, string~ cache_
|
||||||
|
+Load(key) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class ICuratedDataService {
|
||||||
|
<<interface>>
|
||||||
|
+LoadLocations(filepath) vector~Location~
|
||||||
|
+LoadPersonas(filepath) vector~UserPersona~
|
||||||
|
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
|
||||||
|
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsonLoader {
|
||||||
|
-cache cache_
|
||||||
|
+LoadLocations(filepath) vector~Location~
|
||||||
|
+LoadPersonas(filepath) vector~UserPersona~
|
||||||
|
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
|
||||||
|
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
|
||||||
|
}
|
||||||
|
note for JsonLoader "Each Load* method memoizes its result in cache_ after its first call"
|
||||||
|
|
||||||
|
class MockCuratedDataService {
|
||||||
|
-vector~Location~ locations_
|
||||||
|
-vector~UserPersona~ personas_
|
||||||
|
-unordered_map~string, forename_list~ forenames_by_country_
|
||||||
|
-unordered_map~string, surname_list~ surnames_by_country_
|
||||||
|
+LoadLocations(filepath) vector~Location~
|
||||||
|
+LoadPersonas(filepath) vector~UserPersona~
|
||||||
|
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
|
||||||
|
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
|
||||||
|
}
|
||||||
|
note for MockCuratedDataService "Fixed 4-location/3-persona dataset (US, DE, FR, BE) for --mocked runs; filepath arguments are ignored"
|
||||||
|
|
||||||
|
class IExportService {
|
||||||
|
<<interface>>
|
||||||
|
+Initialize() void
|
||||||
|
+ProcessRecord(brewery) uint64_t
|
||||||
|
+ProcessRecord(user) uint64_t
|
||||||
|
+Finalize() void
|
||||||
|
}
|
||||||
|
|
||||||
|
class SqliteExportService {
|
||||||
|
-unique_ptr~IDateTimeProvider~ date_time_provider_
|
||||||
|
-string run_timestamp_utc_
|
||||||
|
-path database_path_
|
||||||
|
-SqliteDatabaseHandle db_handle_
|
||||||
|
-SqliteStatementHandle insert_location_stmt_
|
||||||
|
-SqliteStatementHandle insert_brewery_stmt_
|
||||||
|
-SqliteStatementHandle insert_user_stmt_
|
||||||
|
-bool transaction_open_
|
||||||
|
-unordered_map~string, sqlite3_int64~ location_cache_
|
||||||
|
+Initialize() void
|
||||||
|
+ProcessRecord(brewery) uint64_t
|
||||||
|
+ProcessRecord(user) uint64_t
|
||||||
|
+Finalize() void
|
||||||
|
-InitializeSchema() void
|
||||||
|
-ResolveLocationId(location) sqlite3_int64
|
||||||
|
}
|
||||||
|
|
||||||
|
class IDateTimeProvider {
|
||||||
|
<<interface>>
|
||||||
|
+GetUtcTimestamp() string
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemDateTimeProvider {
|
||||||
|
+GetUtcTimestamp() string
|
||||||
|
}
|
||||||
|
|
||||||
|
BiergartenPipelineOrchestrator *-- ILogger : owns
|
||||||
|
BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
|
||||||
|
BiergartenPipelineOrchestrator *-- DataGenerator : owns
|
||||||
|
BiergartenPipelineOrchestrator *-- IExportService : owns
|
||||||
|
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
|
||||||
|
|
||||||
|
LogEntry *-- LogLevel
|
||||||
|
LogEntry *-- PipelinePhase
|
||||||
|
LogDTO *-- LogLevel
|
||||||
|
LogDTO *-- PipelinePhase
|
||||||
|
ILogger <|.. LogProducer : implements
|
||||||
|
LogProducer ..> LogEntry : emits
|
||||||
|
LogDispatcher ..> LogEntry : consumes
|
||||||
|
|
||||||
|
IEnrichmentService <|.. WikipediaEnrichmentService : implements
|
||||||
|
IEnrichmentService <|.. MockEnrichmentService : implements
|
||||||
|
WikipediaEnrichmentService *-- WebClient : owns
|
||||||
|
|
||||||
|
WebClient <|.. HttpWebClient : implements
|
||||||
|
|
||||||
|
DataGenerator <|.. MockGenerator : implements
|
||||||
|
DataGenerator <|.. LlamaGenerator : implements
|
||||||
|
DataGenerator <|.. OpenAIGenerator : implements
|
||||||
|
|
||||||
|
LlamaGenerator *-- IPromptFormatter : uses
|
||||||
|
LlamaGenerator *-- IPromptDirectory : uses
|
||||||
|
|
||||||
|
OpenAIGenerator *-- IPromptDirectory : uses
|
||||||
|
OpenAIGenerator *-- WebClient : uses
|
||||||
|
|
||||||
|
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
||||||
|
IPromptDirectory <|.. PromptDirectory : implements
|
||||||
|
|
||||||
|
ICuratedDataService <|.. JsonLoader : implements
|
||||||
|
ICuratedDataService <|.. MockCuratedDataService : implements
|
||||||
|
|
||||||
|
IExportService <|.. SqliteExportService : implements
|
||||||
|
SqliteExportService *-- IDateTimeProvider : owns
|
||||||
|
IDateTimeProvider <|.. SystemDateTimeProvider : implements
|
||||||
|
```
|
||||||
200
docs/pipeline/diagrams-md/planned/activity.md
Normal file
200
docs/pipeline/diagrams-md/planned/activity.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# The Biergarten Data Pipeline — Activity Diagram (Planned)
|
||||||
|
|
||||||
|
Each generation phase runs a producer/consumer trio concurrently: an **Orchestrator** producer feeds a bounded channel, an **LLM Worker** consumes it and generates records (closing its output channel when the input is drained and closed), and a **SQLite Worker** consumes that output channel and commits in batches. The Checkin and Follow phases run in parallel with each other once the user and brewery pools are populated.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start([start]) --> ParseArgs["Main: ParseArguments(argc, argv)"]
|
||||||
|
ParseArgs --> InvalidArgs{Invalid args?}
|
||||||
|
InvalidArgs -- yes --> LogErr["spdlog::error"] --> Stop1([stop])
|
||||||
|
InvalidArgs -- no --> InitOpenSSL["Main: Init OpenSSL global state and LlamaBackendState"]
|
||||||
|
InitOpenSSL --> BuildDI["Main: Build DI injector"]
|
||||||
|
BuildDI --> InitSqlite["Main: Initialize SqliteExportService<br/>opens SQLite connection; transactions now managed per-phase via batching"]
|
||||||
|
InitSqlite --> LogSetup["Main: create BoundedChannel log_ch, spawn Log Worker thread<br/>drains log_ch for the pipeline lifetime; all workers emit LogEntry via PipelineLogger, never spdlog directly"]
|
||||||
|
LogSetup --> OrchRun["BiergartenPipelineOrchestrator::Run()"]
|
||||||
|
|
||||||
|
OrchRun --> PL1["JsonLoader.LoadBeerStyles → EnrichmentService.PreWarmBeerStyleCache"]
|
||||||
|
OrchRun --> PL2["JsonLoader.LoadLocations → EnrichmentService.PreWarmLocationCache"]
|
||||||
|
OrchRun --> PL3["JsonLoader.LoadForenamesByCountry"]
|
||||||
|
OrchRun --> PL4["JsonLoader.LoadSurnamesByCountry"]
|
||||||
|
OrchRun --> PL5["JsonLoader.LoadPersonas"]
|
||||||
|
PL1 & PL2 & PL3 & PL4 & PL5 --> UserPhase["Orchestrator: RunUserPhase(sampled_locations)<br/>each Load* call memoized after first parse; MockCuratedDataService (--mocked) returns a fixed in-memory dataset instead, skipping these JSON files"]
|
||||||
|
|
||||||
|
UserPhase --> UCreate["Create BoundedChannels (loc_ch, exp_ch)"]
|
||||||
|
|
||||||
|
subgraph USERGEN["Phase 0 — User Generation (concurrent)"]
|
||||||
|
direction LR
|
||||||
|
subgraph U_PROD["Orchestrator (producer)"]
|
||||||
|
UP1["Loop: send Location → loc_ch"]
|
||||||
|
UP1 --> UP2["Close loc_ch"]
|
||||||
|
end
|
||||||
|
subgraph U_LLM["LLM Worker"]
|
||||||
|
UL1{"loc_ch has items?"}
|
||||||
|
UL1 -- yes --> UL2["Receive Location"]
|
||||||
|
UL2 --> UL3["GetLocationContextFromCache(location) — guaranteed cache hit from startup"]
|
||||||
|
UL3 --> UL4["IPersonaSelectionStrategy.SelectPersona(personas_palette_) — guaranteed cache hit; returns Persona{style_affinities, abv_range, ibu_preference, checkin_weight}"]
|
||||||
|
UL4 --> UL5["SampleName(forenames_by_country, surnames_by_country, iso3166_1, rng)<br/>deterministic, no LLM; free function; skips the city if either map has no entry for iso3166_1"]
|
||||||
|
UL5 --> UL6["GenerateUser(enriched_city, persona, sampled_name) via DataGenerator<br/>LLM receives EnrichedCity context + persona description + sampled name"]
|
||||||
|
UL6 --> UL7["PipelineLogger.Log(Info, UserGeneration, city, user_id, llm)"]
|
||||||
|
UL7 --> UL8["Send GeneratedUser → exp_ch"]
|
||||||
|
UL8 --> UL1
|
||||||
|
UL1 -- no --> UL9["Close exp_ch"]
|
||||||
|
end
|
||||||
|
subgraph U_SQL["SQLite Worker"]
|
||||||
|
US1["BEGIN TRANSACTION"] --> US2{"exp_ch has items?"}
|
||||||
|
US2 -- yes --> US3["Receive GeneratedUser"]
|
||||||
|
US3 --> US4["ProcessUser(user)"]
|
||||||
|
US4 --> US5["PipelineLogger.Log(Info, UserGeneration, city, user_id, sqlite)"]
|
||||||
|
US5 --> US6["Append → user_pool_"]
|
||||||
|
US6 --> US7{"Batch size reached?"}
|
||||||
|
US7 -- yes --> US8["COMMIT and BEGIN"] --> US2
|
||||||
|
US7 -- no --> US2
|
||||||
|
US2 -- no --> US9["COMMIT (Final)"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UCreate --> UP1
|
||||||
|
UP2 -.->|loc_ch| UL1
|
||||||
|
UL9 -.->|exp_ch| US2
|
||||||
|
UL9 & US9 --> UserJoin["Orchestrator: Join LLM Worker, SQLite Worker"]
|
||||||
|
|
||||||
|
UserJoin --> BreweryPhase["Orchestrator: RunBreweryPhase(sampled_locations)"]
|
||||||
|
BreweryPhase --> BCreate["Create BoundedChannels (loc_ch, exp_ch)"]
|
||||||
|
|
||||||
|
subgraph BREWGEN["Phase 1a — Brewery Generation (concurrent)"]
|
||||||
|
direction LR
|
||||||
|
subgraph B_PROD["Orchestrator (producer)"]
|
||||||
|
BP1["Loop: sample User from user_pool_, pair with Location"]
|
||||||
|
BP1 --> BP2["Send BreweryTask(Location, User) → loc_ch"]
|
||||||
|
BP2 --> BP3["Close loc_ch"]
|
||||||
|
end
|
||||||
|
subgraph B_LLM["LLM Worker"]
|
||||||
|
BL1{"loc_ch has items?"}
|
||||||
|
BL1 -- yes --> BL2["Receive BreweryTask(Location, User)"]
|
||||||
|
BL2 --> BL3["GetLocationContextFromCache(task.location) — guaranteed cache hit"]
|
||||||
|
BL3 --> BL4["GenerateBrewery(enriched_city, context, task.user) via DataGenerator<br/>KV cache stays warm; brewery linked to sampled owner_user_id"]
|
||||||
|
BL4 --> BL5["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, llm)"]
|
||||||
|
BL5 --> BL6["Send GeneratedBrewery → exp_ch"]
|
||||||
|
BL6 --> BL1
|
||||||
|
BL1 -- no --> BL7["Close exp_ch"]
|
||||||
|
end
|
||||||
|
subgraph B_SQL["SQLite Worker"]
|
||||||
|
BS1["BEGIN TRANSACTION"] --> BS2{"exp_ch has items?"}
|
||||||
|
BS2 -- yes --> BS3["Receive GeneratedBrewery"]
|
||||||
|
BS3 --> BS4["ProcessBrewery(brewery)"]
|
||||||
|
BS4 --> BS5["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, sqlite)"]
|
||||||
|
BS5 --> BS6["Append → brewery_pool_"]
|
||||||
|
BS6 --> BS7{"Batch size reached?"}
|
||||||
|
BS7 -- yes --> BS8["COMMIT and BEGIN"] --> BS2
|
||||||
|
BS7 -- no --> BS2
|
||||||
|
BS2 -- no --> BS9["COMMIT (Final)"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
BCreate --> BP1
|
||||||
|
BP3 -.->|loc_ch| BL1
|
||||||
|
BL7 -.->|exp_ch| BS2
|
||||||
|
BL7 & BS9 --> BrewJoin["Orchestrator: Join LLM Worker, SQLite Worker<br/>brewery_pool_ is now fully populated"]
|
||||||
|
|
||||||
|
BrewJoin --> BeerPhase["Orchestrator: RunBeerPhase()"]
|
||||||
|
BeerPhase --> BeCreate["Create BoundedChannels (brew_ch, exp_ch)"]
|
||||||
|
|
||||||
|
subgraph BEERGEN["Phase 1b — Beer Generation (concurrent)"]
|
||||||
|
direction LR
|
||||||
|
subgraph BE_PROD["Orchestrator (producer)"]
|
||||||
|
BeP1["Loop: send Breweries → brew_ch"]
|
||||||
|
BeP1 --> BeP2["Close brew_ch"]
|
||||||
|
end
|
||||||
|
subgraph BE_LLM["LLM Worker"]
|
||||||
|
BeL1{"brew_ch has items?"}
|
||||||
|
BeL1 -- yes --> BeL2["Receive GeneratedBrewery"]
|
||||||
|
BeL2 --> BeL3["IBeerSelectionStrategy.SelectStyles(brewery, beer_style_palette_)"]
|
||||||
|
BeL3 --> BeL4{"For each selected BeerStyle"}
|
||||||
|
BeL4 -- remaining --> BeL5["GetStyleContextFromCache(style) — guaranteed cache hit; KV cache stays warm across all beer generations in this phase"]
|
||||||
|
BeL5 --> BeL6["GenerateBeer(brewery, style_context) via DataGenerator"]
|
||||||
|
BeL6 --> BeL7["Attach GeneratedBeer to bundle"] --> BeL4
|
||||||
|
BeL4 -- done --> BeL8["PipelineLogger.Log(Info, BeerGeneration, city, brewery_id, llm)"]
|
||||||
|
BeL8 --> BeL9["Send BeersBundle → exp_ch"]
|
||||||
|
BeL9 --> BeL1
|
||||||
|
BeL1 -- no --> BeL10["Close exp_ch"]
|
||||||
|
end
|
||||||
|
subgraph BE_SQL["SQLite Worker"]
|
||||||
|
BeS1["BEGIN TRANSACTION"] --> BeS2{"exp_ch has items?"}
|
||||||
|
BeS2 -- yes --> BeS3["Receive BeersBundle"]
|
||||||
|
BeS3 --> BeS4{"For each beer in bundle"}
|
||||||
|
BeS4 -- remaining --> BeS5["Set beer.brewery_id from bundle"]
|
||||||
|
BeS5 --> BeS6["ProcessBeer(beer)"]
|
||||||
|
BeS6 --> BeS7["Append → beer_pool_"] --> BeS4
|
||||||
|
BeS4 -- done --> BeS8["PipelineLogger.Log(Info, BeerGeneration, city, brewery_id, sqlite)"]
|
||||||
|
BeS8 --> BeS9{"Batch size reached?"}
|
||||||
|
BeS9 -- yes --> BeS10["COMMIT and BEGIN"] --> BeS2
|
||||||
|
BeS9 -- no --> BeS2
|
||||||
|
BeS2 -- no --> BeS11["COMMIT (Final)"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
BeCreate --> BeP1
|
||||||
|
BeP2 -.->|brew_ch| BeL1
|
||||||
|
BeL10 -.->|exp_ch| BeS2
|
||||||
|
BeL10 & BeS11 --> BeerJoin["Orchestrator: Join LLM Worker, SQLite Worker<br/>brewery_pool_ and beer_pool_ are now completely populated — Checkin and Follow phases may run in parallel"]
|
||||||
|
|
||||||
|
BeerJoin --> CheckinPhase["Orchestrator: RunCheckinPhase()"]
|
||||||
|
BeerJoin --> FollowPhase["Orchestrator: RunFollowPhase()"]
|
||||||
|
|
||||||
|
subgraph CHECKIN["Checkin Generation"]
|
||||||
|
CK1["ICheckinDistributionStrategy.AssignActivityWeights(user_pool_)<br/>weights seeded from persona.checkin_weight; J-curve profile emerges from persona distribution"]
|
||||||
|
CK1 --> CK2["BEGIN TRANSACTION"]
|
||||||
|
CK2 --> CK3{"For each GeneratedUser in user_pool_"}
|
||||||
|
CK3 -- remaining --> CK4["CheckinsForUser(user, brewery_pool_.size())"]
|
||||||
|
CK4 --> CK5{"For each checkin index"}
|
||||||
|
CK5 -- remaining --> CK6["TimestampFor(user, index)"]
|
||||||
|
CK6 --> CK7["Select brewery from brewery_pool_"]
|
||||||
|
CK7 --> CK8["GenerateCheckin(user, brewery, timestamp) via DataGenerator"]
|
||||||
|
CK8 --> CK9["ProcessCheckin(checkin)"]
|
||||||
|
CK9 --> CK10["PipelineLogger.Log(Info, CheckinGeneration, nullopt, checkin_id, sqlite)"]
|
||||||
|
CK10 --> CK11["Append → checkin_pool_"]
|
||||||
|
CK11 --> CK12{"Batch size reached?"}
|
||||||
|
CK12 -- yes --> CK13["COMMIT and BEGIN"] --> CK5
|
||||||
|
CK12 -- no --> CK5
|
||||||
|
CK5 -- done --> CK3
|
||||||
|
CK3 -- done --> CK14["COMMIT (Final)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph FOLLOW["Follow Generation"]
|
||||||
|
FL1["IFollowGenerationStrategy.AssignFollowWeights(user_pool_)<br/>RandomFollowStrategy: uniform weights; ActivityWeightedFollowStrategy: derived from user.activity_weight so high-activity users attract more followers"]
|
||||||
|
FL1 --> FL2["BEGIN TRANSACTION"]
|
||||||
|
FL2 --> FL3["IFollowGenerationStrategy.GenerateFollows(user_pool_)<br/>self-follow constraint (follower_id != followed_id) enforced here and at the DB schema level"]
|
||||||
|
FL3 --> FL4{"For each GeneratedFollow"}
|
||||||
|
FL4 -- remaining --> FL5["ProcessFollow(follow)"]
|
||||||
|
FL5 --> FL6["PipelineLogger.Log(Info, FollowGeneration, nullopt, follower_id, sqlite)"]
|
||||||
|
FL6 --> FL7["Append → follow_pool_"]
|
||||||
|
FL7 --> FL8{"Batch size reached?"}
|
||||||
|
FL8 -- yes --> FL9["COMMIT and BEGIN"] --> FL4
|
||||||
|
FL8 -- no --> FL4
|
||||||
|
FL4 -- done --> FL10["COMMIT (Final)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
CheckinPhase --> CK1
|
||||||
|
FollowPhase --> FL1
|
||||||
|
CK14 & FL10 --> PhaseJoin["Orchestrator: Join CheckinPhase, FollowPhase<br/>checkin_pool_ and follow_pool_ are now fully populated"]
|
||||||
|
|
||||||
|
PhaseJoin --> RatingPhase["Orchestrator: RunRatingPhase()<br/>beer selection biased by user.persona.style_affinities and abv_range; rating skew modulated per persona"]
|
||||||
|
RatingPhase --> RT1["BEGIN TRANSACTION"]
|
||||||
|
RT1 --> RT2{"For each GeneratedCheckin in checkin_pool_"}
|
||||||
|
RT2 -- remaining --> RT3["Match brewery_id, select beer from beer_pool_ (same brewery_id, biased by persona affinities)"]
|
||||||
|
RT3 --> RT4{"Beer exists for brewery?"}
|
||||||
|
RT4 -- yes --> RT5["GenerateRating(user, beer, checkin_id) via DataGenerator"]
|
||||||
|
RT5 --> RT6["ProcessRating(rating)"]
|
||||||
|
RT6 --> RT7["PipelineLogger.Log(Info, RatingGeneration, nullopt, rating_id, sqlite)"]
|
||||||
|
RT7 --> RT8{"Batch size reached?"}
|
||||||
|
RT8 -- yes --> RT9["COMMIT and BEGIN"] --> RT2
|
||||||
|
RT8 -- no --> RT2
|
||||||
|
RT4 -- no --> RT10["Log warning; skip — brewery has no beers"] --> RT2
|
||||||
|
RT2 -- done --> RT11["COMMIT (Final)"]
|
||||||
|
|
||||||
|
RT11 --> Teardown["Orchestrator: Finalize SqliteExportService — safely closes the DB connection"]
|
||||||
|
Teardown --> CloseLogCh["Orchestrator: close log_ch"]
|
||||||
|
CloseLogCh --> MainDone["Main: spdlog::info 'Pipeline complete in X ms'"]
|
||||||
|
MainDone --> JoinLog["Main: Join Log Worker — drain guarantees no LogEntry is dropped at shutdown"]
|
||||||
|
JoinLog --> Stop2([stop])
|
||||||
|
```
|
||||||
593
docs/pipeline/diagrams-md/planned/class.md
Normal file
593
docs/pipeline/diagrams-md/planned/class.md
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
# Biergarten Data Pipeline — Class Diagram (Planned)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
namespace DomainModels {
|
||||||
|
class Location {
|
||||||
|
+string city
|
||||||
|
+string state_province
|
||||||
|
+string iso3166_2
|
||||||
|
+string country
|
||||||
|
+string iso3166_1
|
||||||
|
+vector~string~ local_languages
|
||||||
|
+double latitude
|
||||||
|
+double longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocationContext {
|
||||||
|
+string text
|
||||||
|
+Completeness completeness
|
||||||
|
+size_t char_count
|
||||||
|
}
|
||||||
|
|
||||||
|
class Completeness {
|
||||||
|
<<enumeration>>
|
||||||
|
Full
|
||||||
|
Partial
|
||||||
|
Absent
|
||||||
|
}
|
||||||
|
|
||||||
|
class EnrichedCity {
|
||||||
|
+Location location
|
||||||
|
+LocationContext context
|
||||||
|
}
|
||||||
|
|
||||||
|
class BeerStyle {
|
||||||
|
+string name
|
||||||
|
+string description
|
||||||
|
+float min_abv
|
||||||
|
+float max_abv
|
||||||
|
+int min_ibu
|
||||||
|
+int max_ibu
|
||||||
|
}
|
||||||
|
|
||||||
|
class BreweryResult {
|
||||||
|
+string name_en
|
||||||
|
+string description_en
|
||||||
|
+string name_local
|
||||||
|
+string description_local
|
||||||
|
}
|
||||||
|
|
||||||
|
class BeerResult {
|
||||||
|
+string name_en
|
||||||
|
+string description_en
|
||||||
|
+string name_local
|
||||||
|
+string description_local
|
||||||
|
+string style
|
||||||
|
+float abv
|
||||||
|
+int ibu
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserResult {
|
||||||
|
+string first_name
|
||||||
|
+string last_name
|
||||||
|
+string gender
|
||||||
|
+string username
|
||||||
|
+string bio
|
||||||
|
+float activity_weight
|
||||||
|
}
|
||||||
|
|
||||||
|
class Name {
|
||||||
|
+string first_name
|
||||||
|
+string last_name
|
||||||
|
+string gender
|
||||||
|
}
|
||||||
|
|
||||||
|
class ForenameEntry {
|
||||||
|
+string name
|
||||||
|
+string gender
|
||||||
|
}
|
||||||
|
|
||||||
|
class CheckinResult {
|
||||||
|
+string checked_in_at
|
||||||
|
+string note
|
||||||
|
}
|
||||||
|
|
||||||
|
class RatingResult {
|
||||||
|
+float score
|
||||||
|
+string note
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenerationMetadata {
|
||||||
|
+uint64_t generation_id
|
||||||
|
+string generated_time
|
||||||
|
+bool context_provided
|
||||||
|
+string generated_with
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedBrewery {
|
||||||
|
+uint64_t brewery_id
|
||||||
|
+Location location
|
||||||
|
+BreweryResult brewery
|
||||||
|
+Completeness context_completeness
|
||||||
|
+GenerationMetadata metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedBeer {
|
||||||
|
+uint64_t beer_id
|
||||||
|
+uint64_t brewery_id
|
||||||
|
+Location location
|
||||||
|
+BeerStyle style
|
||||||
|
+BeerResult beer
|
||||||
|
+GenerationMetadata metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedUser {
|
||||||
|
+uint64_t user_id
|
||||||
|
+Location location
|
||||||
|
+UserResult user
|
||||||
|
+string email
|
||||||
|
+string date_of_birth
|
||||||
|
+string password
|
||||||
|
+GenerationMetadata metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedCheckin {
|
||||||
|
+uint64_t checkin_id
|
||||||
|
+uint64_t user_id
|
||||||
|
+uint64_t brewery_id
|
||||||
|
+CheckinResult checkin
|
||||||
|
+GenerationMetadata metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedRating {
|
||||||
|
+uint64_t user_id
|
||||||
|
+uint64_t beer_id
|
||||||
|
+uint64_t checkin_id
|
||||||
|
+RatingResult rating
|
||||||
|
+GenerationMetadata metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedFollow {
|
||||||
|
+uint64_t follower_id
|
||||||
|
+uint64_t followed_id
|
||||||
|
+GenerationMetadata metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserPersona {
|
||||||
|
+string name
|
||||||
|
+string description
|
||||||
|
+vector~string~ style_affinities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note for ForenameEntry "forename_list/surname_list are unordered_set aliases, keyed by ISO 3166-1 country code via ICuratedDataService. SampleName() is a free function, not a method, sourced from popular-names-by-country-dataset (CC0)."
|
||||||
|
note for GeneratedUser "email, date_of_birth, and password are generated programmatically by the orchestrator (not LLM-authored) so a downstream auth-account seeding consumer can register real accounts from this export."
|
||||||
|
ForenameEntry ..> Name : SampleName() produces
|
||||||
|
LocationContext *-- Completeness
|
||||||
|
|
||||||
|
namespace DomainApplicationConfiguration {
|
||||||
|
class SamplingOptions {
|
||||||
|
+float temperature
|
||||||
|
+float top_p
|
||||||
|
+uint32_t top_k
|
||||||
|
+uint32_t n_ctx
|
||||||
|
+int seed
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratorOptions {
|
||||||
|
+path model_path
|
||||||
|
+bool use_mocked
|
||||||
|
+optional~SamplingOptions~ sampling
|
||||||
|
}
|
||||||
|
|
||||||
|
class PipelineOptions {
|
||||||
|
+path output_path
|
||||||
|
+path log_path
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApplicationOptions {
|
||||||
|
+GeneratorOptions generator
|
||||||
|
+PipelineOptions pipeline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplicationOptions *-- GeneratorOptions
|
||||||
|
ApplicationOptions *-- PipelineOptions
|
||||||
|
GeneratorOptions o-- SamplingOptions
|
||||||
|
|
||||||
|
namespace DomainPolicy {
|
||||||
|
class ContextStrategy {
|
||||||
|
<<interface>>
|
||||||
|
+QueriesFor(loc) vector~string~
|
||||||
|
+MaxContextChars() size_t
|
||||||
|
}
|
||||||
|
|
||||||
|
class BreweryContextStrategy {
|
||||||
|
+QueriesFor(loc) vector~string~
|
||||||
|
+MaxContextChars() size_t
|
||||||
|
}
|
||||||
|
|
||||||
|
class BeerContextStrategy {
|
||||||
|
+QueriesFor(loc) vector~string~
|
||||||
|
+MaxContextChars() size_t
|
||||||
|
}
|
||||||
|
|
||||||
|
class SamplingStrategy {
|
||||||
|
<<interface>>
|
||||||
|
+Sample(locations) vector~Location~
|
||||||
|
}
|
||||||
|
|
||||||
|
class UniformSamplingStrategy {
|
||||||
|
-size_t sample_size_
|
||||||
|
+Sample(locations) vector~Location~
|
||||||
|
}
|
||||||
|
|
||||||
|
class BeerSelectionStrategy {
|
||||||
|
<<interface>>
|
||||||
|
+SelectStyles(brewery, palette) vector~BeerStyle~
|
||||||
|
}
|
||||||
|
|
||||||
|
class RandomBeerSelectionStrategy {
|
||||||
|
-mt19937 rng_
|
||||||
|
-size_t min_beers_
|
||||||
|
-size_t max_beers_
|
||||||
|
+SelectStyles(brewery, palette) vector~BeerStyle~
|
||||||
|
}
|
||||||
|
|
||||||
|
class CheckinDistributionStrategy {
|
||||||
|
<<interface>>
|
||||||
|
+AssignActivityWeights(users) void
|
||||||
|
+CheckinsForUser(user, brewery_count) size_t
|
||||||
|
+TimestampFor(user, index) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class JCurveCheckinStrategy {
|
||||||
|
-mt19937 rng_
|
||||||
|
+AssignActivityWeights(users) void
|
||||||
|
+CheckinsForUser(user, brewery_count) size_t
|
||||||
|
+TimestampFor(user, index) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class RandomCheckinStrategy {
|
||||||
|
-mt19937 rng_
|
||||||
|
-size_t min_checkins_
|
||||||
|
-size_t max_checkins_
|
||||||
|
+AssignActivityWeights(users) void
|
||||||
|
+CheckinsForUser(user, brewery_count) size_t
|
||||||
|
+TimestampFor(user, index) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class FollowGenerationStrategy {
|
||||||
|
<<interface>>
|
||||||
|
+GenerateFollows(users) vector~GeneratedFollow~
|
||||||
|
}
|
||||||
|
|
||||||
|
class RandomFollowStrategy {
|
||||||
|
-mt19937 rng_
|
||||||
|
-size_t min_follows_
|
||||||
|
-size_t max_follows_
|
||||||
|
+GenerateFollows(users) vector~GeneratedFollow~
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivityWeightedFollowStrategy {
|
||||||
|
-mt19937 rng_
|
||||||
|
-size_t min_follows_
|
||||||
|
-size_t max_follows_
|
||||||
|
+GenerateFollows(users) vector~GeneratedFollow~
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace InfrastructureLogging {
|
||||||
|
class LogLevel {
|
||||||
|
<<enumeration>>
|
||||||
|
Debug
|
||||||
|
Info
|
||||||
|
Warn
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
class PipelinePhase {
|
||||||
|
<<enumeration>>
|
||||||
|
Startup
|
||||||
|
UserGeneration
|
||||||
|
BreweryAndBeerGeneration
|
||||||
|
CheckinGeneration
|
||||||
|
RatingGeneration
|
||||||
|
FollowGeneration
|
||||||
|
Teardown
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogEntry {
|
||||||
|
+time_point timestamp
|
||||||
|
+LogLevel level
|
||||||
|
+PipelinePhase phase
|
||||||
|
+string message
|
||||||
|
+optional~string~ worker
|
||||||
|
}
|
||||||
|
|
||||||
|
class ILogger {
|
||||||
|
<<interface>>
|
||||||
|
+Log(entry) void
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogProducer {
|
||||||
|
-BoundedChannel~LogEntry~ channel_
|
||||||
|
+Log(entry) void
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogDispatcher {
|
||||||
|
-BoundedChannel~LogEntry~ channel_
|
||||||
|
+Run() void
|
||||||
|
-ToSpdlogLevel(level) level_enum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry *-- LogLevel
|
||||||
|
LogEntry *-- PipelinePhase
|
||||||
|
ILogger <|.. LogProducer
|
||||||
|
LogProducer ..> LogEntry : emits
|
||||||
|
LogDispatcher ..> LogEntry : consumes
|
||||||
|
|
||||||
|
namespace InfrastructurePipelineChannel {
|
||||||
|
class BoundedChannel~T~ {
|
||||||
|
-queue~T~ queue_
|
||||||
|
-mutex mutex_
|
||||||
|
-condition_variable not_full_
|
||||||
|
-condition_variable not_empty_
|
||||||
|
-size_t capacity_
|
||||||
|
-bool closed_
|
||||||
|
+Send(item) void
|
||||||
|
+Receive() optional~T~
|
||||||
|
+Close() void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace InfrastructureDataPreloading {
|
||||||
|
class ICuratedDataService {
|
||||||
|
<<interface>>
|
||||||
|
+LoadLocations(filepath) vector~Location~
|
||||||
|
+LoadBeerStyles(filepath) vector~BeerStyle~
|
||||||
|
+LoadPersonas(filepath) vector~UserPersona~
|
||||||
|
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
|
||||||
|
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsonLoader {
|
||||||
|
+LoadLocations(filepath) vector~Location~
|
||||||
|
+LoadBeerStyles(filepath) vector~BeerStyle~
|
||||||
|
+LoadPersonas(filepath) vector~UserPersona~
|
||||||
|
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
|
||||||
|
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockCuratedDataService {
|
||||||
|
+LoadLocations(filepath) vector~Location~
|
||||||
|
+LoadBeerStyles(filepath) vector~BeerStyle~
|
||||||
|
+LoadPersonas(filepath) vector~UserPersona~
|
||||||
|
+LoadForenamesByCountry(filepath) unordered_map~string, forename_list~
|
||||||
|
+LoadSurnamesByCountry(filepath) unordered_map~string, surname_list~
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note for JsonLoader "Each Load* call is memoized after its first parse — implemented today, ahead of LoadBeerStyles which is still planned."
|
||||||
|
note for MockCuratedDataService "Fixed in-memory dataset for --mocked mode, implemented today for Locations/Personas/Forenames/Surnames; would need a LoadBeerStyles fixture too once that method is implemented."
|
||||||
|
|
||||||
|
namespace InfrastructureEnrichment {
|
||||||
|
class EnrichmentService {
|
||||||
|
<<interface>>
|
||||||
|
+GetLocationContext(loc, strategy) LocationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
class WikipediaService {
|
||||||
|
-unique_ptr~WebClient~ client_
|
||||||
|
-unordered_map~string, string~ extract_cache_
|
||||||
|
+GetLocationContext(loc, strategy) LocationContext
|
||||||
|
-FetchExtract(query) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebClient {
|
||||||
|
<<interface>>
|
||||||
|
+Get(url) string
|
||||||
|
+UrlEncode(value) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpWebClient {
|
||||||
|
+Get(url) string
|
||||||
|
+UrlEncode(value) string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace InfrastructurePrompting {
|
||||||
|
class IPromptDirectory {
|
||||||
|
<<interface>>
|
||||||
|
+Load(key) string
|
||||||
|
}
|
||||||
|
|
||||||
|
class PromptDirectory {
|
||||||
|
-path prompt_dir_
|
||||||
|
-unordered_map~string, string~ cache_
|
||||||
|
+PromptDirectory(prompt_dir)
|
||||||
|
+Load(key) string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IPromptDirectory <|.. PromptDirectory
|
||||||
|
|
||||||
|
namespace InfrastructureDataGeneration {
|
||||||
|
class DataGenerator {
|
||||||
|
<<interface>>
|
||||||
|
+GenerateBrewery(location, context) BreweryResult
|
||||||
|
+GenerateBeer(brewery_id, location, context, style) BeerResult
|
||||||
|
+GenerateUser(city, persona, name) UserResult
|
||||||
|
+GenerateCheckin(user, brewery, timestamp) CheckinResult
|
||||||
|
+GenerateRating(user, beer, checkin_id) RatingResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGenerator {
|
||||||
|
+GenerateBrewery(...) BreweryResult
|
||||||
|
+GenerateBeer(...) BeerResult
|
||||||
|
+GenerateUser(...) UserResult
|
||||||
|
+GenerateCheckin(...) CheckinResult
|
||||||
|
+GenerateRating(...) RatingResult
|
||||||
|
-DeterministicHash(location) size_t
|
||||||
|
}
|
||||||
|
|
||||||
|
class LlamaGenerator {
|
||||||
|
-ModelHandle model_
|
||||||
|
-ContextHandle context_
|
||||||
|
-unique_ptr~PromptFormatter~ prompt_formatter_
|
||||||
|
-unique_ptr~IPromptDirectory~ prompt_directory_
|
||||||
|
-mt19937 rng_
|
||||||
|
+GenerateBrewery(...) BreweryResult
|
||||||
|
+GenerateBeer(...) BeerResult
|
||||||
|
+GenerateUser(...) UserResult
|
||||||
|
+GenerateCheckin(...) CheckinResult
|
||||||
|
+GenerateRating(...) RatingResult
|
||||||
|
-Load(opts) void
|
||||||
|
-Infer(system_prompt, user_prompt, max_tokens, grammar) string
|
||||||
|
-ValidateModelArchitecture() void
|
||||||
|
}
|
||||||
|
|
||||||
|
class PromptFormatter {
|
||||||
|
<<interface>>
|
||||||
|
+Format(system_prompt, user_prompt) string
|
||||||
|
+ExpectedArchitecture() string_view
|
||||||
|
}
|
||||||
|
|
||||||
|
class Gemma4JinjaPromptFormatter {
|
||||||
|
+Format(system_prompt, user_prompt) string
|
||||||
|
+ExpectedArchitecture() string_view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace InfrastructureDataExport {
|
||||||
|
class ExportService {
|
||||||
|
<<interface>>
|
||||||
|
+Initialize() void
|
||||||
|
+ProcessBrewery(brewery) uint64_t
|
||||||
|
+ProcessBeer(beer) uint64_t
|
||||||
|
+ProcessUser(user) uint64_t
|
||||||
|
+ProcessCheckin(checkin) uint64_t
|
||||||
|
+ProcessRating(rating) void
|
||||||
|
+ProcessFollow(follow) void
|
||||||
|
+Finalize() void
|
||||||
|
}
|
||||||
|
|
||||||
|
class SqliteExportService {
|
||||||
|
-unique_ptr~DateTimeProvider~ date_time_provider_
|
||||||
|
-SqliteDatabaseHandle db_handle_
|
||||||
|
-SqliteStatementHandle insert_location_stmt_
|
||||||
|
-SqliteStatementHandle insert_brewery_stmt_
|
||||||
|
-SqliteStatementHandle insert_beer_stmt_
|
||||||
|
-SqliteStatementHandle insert_user_stmt_
|
||||||
|
-SqliteStatementHandle insert_checkin_stmt_
|
||||||
|
-SqliteStatementHandle insert_rating_stmt_
|
||||||
|
-SqliteStatementHandle insert_follow_stmt_
|
||||||
|
-bool transaction_open_
|
||||||
|
-unordered_map~string, uint64_t~ location_cache_
|
||||||
|
-unordered_map~string, uint64_t~ brewery_cache_
|
||||||
|
+Initialize() void
|
||||||
|
+ProcessRecord(brewery) uint64_t
|
||||||
|
+ProcessRecord(beer) uint64_t
|
||||||
|
+ProcessRecord(user) uint64_t
|
||||||
|
+ProcessRecord(checkin) uint64_t
|
||||||
|
+ProcessRecord(rating) void
|
||||||
|
+ProcessRecord(follow) void
|
||||||
|
+Finalize() void
|
||||||
|
-InitializeSchema() void
|
||||||
|
-PrepareStatements() void
|
||||||
|
-RollbackAndCloseNoThrow() void
|
||||||
|
-FinalizeStatements() void
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateTimeProvider {
|
||||||
|
<<interface>>
|
||||||
|
+GetUtcTimestamp() string
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemDateTimeProvider {
|
||||||
|
+GetUtcTimestamp() string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BiergartenPipelineOrchestrator {
|
||||||
|
-unique_ptr~ICuratedDataService~ curated_data_service_
|
||||||
|
-unique_ptr~EnrichmentService~ enrichment_service_
|
||||||
|
-unique_ptr~DataGenerator~ generator_
|
||||||
|
-unique_ptr~Logger~ logger_
|
||||||
|
-unique_ptr~ExportService~ exporter_
|
||||||
|
-unique_ptr~ContextStrategy~ brewery_context_strategy_
|
||||||
|
-unique_ptr~SamplingStrategy~ sampling_strategy_
|
||||||
|
-unique_ptr~BeerSelectionStrategy~ beer_selection_strategy_
|
||||||
|
-unique_ptr~CheckinDistributionStrategy~ checkin_strategy_
|
||||||
|
-unique_ptr~FollowGenerationStrategy~ follow_strategy_
|
||||||
|
-vector~BeerStyle~ beer_style_palette_
|
||||||
|
-ApplicationOptions options_
|
||||||
|
-vector~GeneratedUser~ user_pool_
|
||||||
|
-vector~GeneratedBrewery~ brewery_pool_
|
||||||
|
-vector~GeneratedBeer~ beer_pool_
|
||||||
|
-vector~GeneratedCheckin~ checkin_pool_
|
||||||
|
-vector~GeneratedFollow~ follow_pool_
|
||||||
|
+Run() bool
|
||||||
|
-RunUserPhase(locations) void
|
||||||
|
-RunBreweryAndBeerPhase(locations) void
|
||||||
|
-RunCheckinPhase() void
|
||||||
|
-RunRatingPhase() void
|
||||||
|
-RunFollowPhase() void
|
||||||
|
}
|
||||||
|
|
||||||
|
BiergartenPipelineOrchestrator *-- ICuratedDataService
|
||||||
|
BiergartenPipelineOrchestrator *-- EnrichmentService
|
||||||
|
BiergartenPipelineOrchestrator *-- DataGenerator
|
||||||
|
BiergartenPipelineOrchestrator *-- ExportService
|
||||||
|
BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy
|
||||||
|
BiergartenPipelineOrchestrator *-- FollowGenerationStrategy
|
||||||
|
BiergartenPipelineOrchestrator *-- SamplingStrategy
|
||||||
|
BiergartenPipelineOrchestrator *-- BeerSelectionStrategy
|
||||||
|
BiergartenPipelineOrchestrator *-- ApplicationOptions
|
||||||
|
BiergartenPipelineOrchestrator *-- Logger
|
||||||
|
|
||||||
|
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedUser : user_pool_
|
||||||
|
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedBrewery : brewery_pool_
|
||||||
|
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedBeer : beer_pool_
|
||||||
|
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedCheckin : checkin_pool_
|
||||||
|
BiergartenPipelineOrchestrator "1" *-- "0..*" GeneratedFollow : follow_pool_
|
||||||
|
|
||||||
|
ICuratedDataService <|.. JsonLoader
|
||||||
|
ICuratedDataService <|.. MockCuratedDataService
|
||||||
|
Logger <|.. PipelineLogger
|
||||||
|
ContextStrategy <|.. BreweryContextStrategy
|
||||||
|
ContextStrategy <|.. BeerContextStrategy
|
||||||
|
SamplingStrategy <|.. UniformSamplingStrategy
|
||||||
|
BeerSelectionStrategy <|.. RandomBeerSelectionStrategy
|
||||||
|
CheckinDistributionStrategy <|.. JCurveCheckinStrategy
|
||||||
|
CheckinDistributionStrategy <|.. RandomCheckinStrategy
|
||||||
|
FollowGenerationStrategy <|.. RandomFollowStrategy
|
||||||
|
FollowGenerationStrategy <|.. ActivityWeightedFollowStrategy
|
||||||
|
EnrichmentService <|.. WikipediaService
|
||||||
|
WebClient <|.. HttpWebClient
|
||||||
|
DataGenerator <|.. MockGenerator
|
||||||
|
DataGenerator <|.. LlamaGenerator
|
||||||
|
PromptFormatter <|.. Gemma4JinjaPromptFormatter
|
||||||
|
ExportService <|.. SqliteExportService
|
||||||
|
DateTimeProvider <|.. SystemDateTimeProvider
|
||||||
|
|
||||||
|
WikipediaService *-- WebClient
|
||||||
|
WikipediaService ..> ContextStrategy
|
||||||
|
LlamaGenerator *-- PromptFormatter
|
||||||
|
LlamaGenerator *-- IPromptDirectory
|
||||||
|
LlamaGenerator ..> GeneratorOptions
|
||||||
|
SqliteExportService *-- DateTimeProvider
|
||||||
|
|
||||||
|
PipelineLogger o-- BoundedChannel : logs to
|
||||||
|
LogWorker o-- BoundedChannel : drains from
|
||||||
|
|
||||||
|
EnrichedCity *-- Location
|
||||||
|
EnrichedCity *-- LocationContext
|
||||||
|
GeneratedBrewery *-- Location
|
||||||
|
GeneratedBrewery *-- BreweryResult
|
||||||
|
GeneratedBrewery *-- GenerationMetadata
|
||||||
|
GeneratedBeer *-- Location
|
||||||
|
GeneratedBeer *-- BeerStyle
|
||||||
|
GeneratedBeer *-- BeerResult
|
||||||
|
GeneratedBeer *-- GenerationMetadata
|
||||||
|
GeneratedUser *-- Location
|
||||||
|
GeneratedUser *-- UserResult
|
||||||
|
GeneratedUser *-- GenerationMetadata
|
||||||
|
GeneratedCheckin *-- CheckinResult
|
||||||
|
GeneratedCheckin *-- GenerationMetadata
|
||||||
|
GeneratedRating *-- RatingResult
|
||||||
|
GeneratedRating *-- GenerationMetadata
|
||||||
|
GeneratedFollow *-- GenerationMetadata
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `Logger`, `PipelineLogger`, and `LogWorker` are referenced only in relationships in the source PlantUML (not given their own class blocks there either) — they appear here as implicit nodes for the same reason.
|
||||||
|
- Package grouping from the PlantUML source (`Domain: Models`, `Domain: Application Configuration`, `Domain: Policy`, `Infrastructure: Logging`, `Infrastructure: Pipeline Channel`, `Infrastructure: Data Preloading`, `Infrastructure: Enrichment`, `Infrastructure: Prompting`, `Infrastructure: Data Generation`, `Infrastructure: Data Export`) is preserved as Mermaid `namespace` blocks.
|
||||||
@@ -1,55 +1,56 @@
|
|||||||
skinparam shadowing false
|
' skinparam shadowing false
|
||||||
skinparam backgroundColor #FCFCF7
|
' skinparam backgroundColor #FCFCF7
|
||||||
skinparam defaultFontName "DM Sans"
|
' skinparam defaultFontName "DM Sans"
|
||||||
skinparam defaultFontColor #14180C
|
' skinparam defaultFontColor #14180C
|
||||||
skinparam titleFontName "Volkhov"
|
' skinparam titleFontName "Volkhov"
|
||||||
skinparam titleFontColor #14180C
|
' skinparam titleFontColor #14180C
|
||||||
skinparam ArrowColor #656F33
|
' skinparam ArrowColor #656F33
|
||||||
skinparam NoteBackgroundColor #DBEEDD
|
' skinparam NoteBackgroundColor #DBEEDD
|
||||||
skinparam NoteFontColor #14180C
|
' skinparam NoteFontColor #14180C
|
||||||
skinparam NoteBorderColor #4A5837
|
|
||||||
skinparam SwimlaneBorderColor #4A5837
|
' skinparam NoteBorderColor #4A5837
|
||||||
skinparam SwimlaneBorderThickness 1
|
' skinparam SwimlaneBorderColor #4A5837
|
||||||
skinparam activityStartColor #EBECE3
|
' skinparam SwimlaneBorderThickness 1
|
||||||
skinparam activityEndColor #4A5837
|
' skinparam activityStartColor #EBECE3
|
||||||
skinparam activityStopColor #4A5837
|
' skinparam activityEndColor #4A5837
|
||||||
skinparam ActivityBackgroundColor #EBECE3
|
' skinparam activityStopColor #4A5837
|
||||||
skinparam ActivityBorderColor #4A5837
|
' skinparam ActivityBackgroundColor #EBECE3
|
||||||
skinparam ActivityDiamondBackgroundColor #CBD2B5
|
' skinparam ActivityBorderColor #4A5837
|
||||||
skinparam ActivityDiamondBorderColor #4A5837
|
' skinparam ActivityDiamondBackgroundColor #CBD2B5
|
||||||
skinparam packageStyle rectangle
|
' skinparam ActivityDiamondBorderColor #4A5837
|
||||||
skinparam packageBackgroundColor #F1F3EA
|
' skinparam packageStyle rectangle
|
||||||
skinparam packageBorderColor #4A5837
|
' skinparam packageBackgroundColor #F1F3EA
|
||||||
skinparam packageFontColor #14180C
|
' skinparam packageBorderColor #4A5837
|
||||||
skinparam classBackgroundColor #EBECE3
|
' skinparam packageFontColor #14180C
|
||||||
skinparam classBorderColor #4A5837
|
' skinparam classBackgroundColor #EBECE3
|
||||||
skinparam classFontColor #14180C
|
' skinparam classBorderColor #4A5837
|
||||||
skinparam classAttributeFontColor #3F4724
|
' skinparam classFontColor #14180C
|
||||||
skinparam classStereotypeFontColor #4A5837
|
' skinparam classAttributeFontColor #3F4724
|
||||||
skinparam interfaceBackgroundColor #DBEEDD
|
' skinparam classStereotypeFontColor #4A5837
|
||||||
skinparam interfaceBorderColor #4A5837
|
' skinparam interfaceBackgroundColor #DBEEDD
|
||||||
skinparam interfaceFontColor #14180C
|
' skinparam interfaceBorderColor #4A5837
|
||||||
skinparam enumBackgroundColor #E4E6D8
|
' skinparam interfaceFontColor #14180C
|
||||||
skinparam enumBorderColor #4A5837
|
' skinparam enumBackgroundColor #E4E6D8
|
||||||
skinparam enumFontColor #14180C
|
' skinparam enumBorderColor #4A5837
|
||||||
skinparam ParticipantBackgroundColor #EBECE3
|
' skinparam enumFontColor #14180C
|
||||||
skinparam ParticipantBorderColor #4A5837
|
' skinparam ParticipantBackgroundColor #EBECE3
|
||||||
skinparam ParticipantFontColor #14180C
|
' skinparam ParticipantBorderColor #4A5837
|
||||||
skinparam ActorBackgroundColor #DBEEDD
|
' skinparam ParticipantFontColor #14180C
|
||||||
skinparam ActorBorderColor #4A5837
|
' skinparam ActorBackgroundColor #DBEEDD
|
||||||
skinparam ActorFontColor #14180C
|
' skinparam ActorBorderColor #4A5837
|
||||||
skinparam LifeLineBorderColor #4A5837
|
' skinparam ActorFontColor #14180C
|
||||||
skinparam LifeLineBackgroundColor #F1F3EA
|
' skinparam LifeLineBorderColor #4A5837
|
||||||
skinparam DatabaseBackgroundColor #EBECE3
|
' skinparam LifeLineBackgroundColor #F1F3EA
|
||||||
skinparam DatabaseBorderColor #4A5837
|
' skinparam DatabaseBackgroundColor #EBECE3
|
||||||
skinparam DatabaseFontColor #14180C
|
' skinparam DatabaseBorderColor #4A5837
|
||||||
skinparam SequenceGroupBackgroundColor #DBEEDD
|
' skinparam DatabaseFontColor #14180C
|
||||||
skinparam SequenceGroupBorderColor #4A5837
|
' skinparam SequenceGroupBackgroundColor #DBEEDD
|
||||||
skinparam SequenceGroupFontColor #14180C
|
' skinparam SequenceGroupBorderColor #4A5837
|
||||||
skinparam SequenceGroupBodyBackgroundColor transparent
|
' skinparam SequenceGroupFontColor #14180C
|
||||||
skinparam SequenceDividerBackgroundColor #DBEEDD
|
' skinparam SequenceGroupBodyBackgroundColor transparent
|
||||||
skinparam SequenceDividerBorderColor #4A5837
|
' skinparam SequenceDividerBackgroundColor #DBEEDD
|
||||||
skinparam SequenceDividerFontColor #14180C
|
' skinparam SequenceDividerBorderColor #4A5837
|
||||||
skinparam SequenceBoxBackgroundColor #F1F3EA
|
' skinparam SequenceDividerFontColor #14180C
|
||||||
skinparam SequenceBoxBorderColor #4A5837
|
' skinparam SequenceBoxBackgroundColor #F1F3EA
|
||||||
skinparam SequenceBoxFontColor #14180C
|
' skinparam SequenceBoxBorderColor #4A5837
|
||||||
|
' skinparam SequenceBoxFontColor #14180C
|
||||||
|
|||||||
84
docs/website/diagrams-md/architecture.md
Normal file
84
docs/website/diagrams-md/architecture.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# The Biergarten App — Vertical Slice Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph HOST["API.Core (thin host)"]
|
||||||
|
API["API.Core<br/>ASP.NET Core Web API host"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SLICES["Feature Slices"]
|
||||||
|
UsersSlice["Features.Users"]
|
||||||
|
BrewerySlice["Features.Breweries"]
|
||||||
|
EmailsSlice["Features.Emails<br/>(no controller)"]
|
||||||
|
LocationsSlice["Features.Locations<br/>(no controller, no MediatR)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SHARED["Shared"]
|
||||||
|
SharedContracts["Shared.Contracts"]
|
||||||
|
SharedApp["Shared.Application"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph INFRA["Infrastructure Layer"]
|
||||||
|
Sql["Infrastructure.Sql"]
|
||||||
|
JWT["Infrastructure.Jwt"]
|
||||||
|
PwdHash["Infrastructure.PasswordHashing"]
|
||||||
|
Email["Infrastructure.Email"]
|
||||||
|
EmailTemplates["Infrastructure.Email.Templates"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DOMAIN["Domain Layer"]
|
||||||
|
Domain["Domain.Entities"]
|
||||||
|
DomainExceptions["Domain.Exceptions"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Tables[("SQL Server<br/>Tables")]
|
||||||
|
Seed["Database.Seed<br/>(separate console app)"]
|
||||||
|
|
||||||
|
API -.->|AddApplicationPart| UsersSlice
|
||||||
|
API -.->|AddApplicationPart| BrewerySlice
|
||||||
|
API -.->|MediatR scan only| EmailsSlice
|
||||||
|
|
||||||
|
Seed -.->|AddFeaturesUsers| UsersSlice
|
||||||
|
Seed -.->|AddFeaturesBreweries| BrewerySlice
|
||||||
|
Seed -.->|AddFeaturesLocations| LocationsSlice
|
||||||
|
|
||||||
|
UsersSlice --> Sql
|
||||||
|
UsersSlice --> JWT
|
||||||
|
UsersSlice --> PwdHash
|
||||||
|
UsersSlice --> SharedContracts
|
||||||
|
UsersSlice --> SharedApp
|
||||||
|
|
||||||
|
BrewerySlice --> Sql
|
||||||
|
BrewerySlice --> SharedContracts
|
||||||
|
BrewerySlice --> SharedApp
|
||||||
|
|
||||||
|
LocationsSlice --> Sql
|
||||||
|
|
||||||
|
EmailsSlice --> Email
|
||||||
|
EmailsSlice --> EmailTemplates
|
||||||
|
EmailsSlice --> SharedApp
|
||||||
|
|
||||||
|
UsersSlice -.->|sends SendRegistrationEmailCommand| SharedApp
|
||||||
|
EmailsSlice -.->|handles SendRegistrationEmailCommand| SharedApp
|
||||||
|
|
||||||
|
Sql --> Domain
|
||||||
|
|
||||||
|
UsersSlice --> Tables
|
||||||
|
BrewerySlice --> Tables
|
||||||
|
LocationsSlice --> Tables
|
||||||
|
|
||||||
|
UsersSlice --> Domain
|
||||||
|
BrewerySlice --> Domain
|
||||||
|
LocationsSlice --> Domain
|
||||||
|
UsersSlice --> DomainExceptions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter.
|
||||||
|
- **Feature slices**: each owns its own controller (HTTP endpoints), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`). `Features.Emails` has no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller and no MediatR handlers — it exposes `ILocationRepository` directly as a plain DI service, not registered by `API.Core` at all; only `Database.Seed` depends on it today.
|
||||||
|
- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — the one cross-slice interaction is a MediatR command contract, not a project reference.
|
||||||
|
- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle` (schema tables exist for the last two; no repository yet).
|
||||||
|
- **Infrastructure.Sql**: SQL-first approach — each slice's own repository issues inline SQL via Dapper, no ORM, no stored procedures.
|
||||||
|
- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly — no API host, no MediatR pipeline.
|
||||||
|
- No `Features.*` project ever references another `Features.*` project directly.
|
||||||
85
docs/website/diagrams-md/auth/confirm-user.md
Normal file
85
docs/website/diagrams-md/auth/confirm-user.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# User Authentication Flow — Confirm User
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant ConfirmHandler as ConfirmUserHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
participant TokenSvc as TokenService
|
||||||
|
end
|
||||||
|
box rgb(235,236,227) Infrastructure Layer
|
||||||
|
participant JWT as JWT Infrastructure
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: POST /api/auth/confirm?token={token} (requires Bearer access token)
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(ConfirmUserCommand(token))
|
||||||
|
activate Mediator
|
||||||
|
Mediator->>ConfirmHandler: Handle(command)
|
||||||
|
activate ConfirmHandler
|
||||||
|
|
||||||
|
ConfirmHandler->>TokenSvc: ValidateConfirmationTokenAsync(token)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc->>JWT: Validate against confirmation token secret, extract userId
|
||||||
|
activate JWT
|
||||||
|
JWT-->>TokenSvc: ValidatedToken or throws
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc-->>ConfirmHandler: ValidatedToken
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
alt Token invalid or expired
|
||||||
|
ConfirmHandler->>Mediator: throw UnauthorizedException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 401 Unauthorized
|
||||||
|
else Token valid
|
||||||
|
ConfirmHandler->>UserMgr: FindByIdAsync(validatedToken.UserId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userId)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||||
|
activate DB
|
||||||
|
DB-->>UserStore: row or null
|
||||||
|
deactivate DB
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>ConfirmHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt User not found
|
||||||
|
ConfirmHandler->>Mediator: throw UnauthorizedException "User account not found"
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 401 Unauthorized
|
||||||
|
else User found
|
||||||
|
ConfirmHandler->>UserStore: SetEmailConfirmedAsync(user, true)
|
||||||
|
Note right of UserStore: IUserEmailStore(ApplicationUser) is injected directly into the handler (not via UserManager), setting the confirmed flag in-memory here
|
||||||
|
|
||||||
|
ConfirmHandler->>UserMgr: UpdateAsync(user)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: UpdateAsync(user)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: UPDATE UserAccount SET ...
|
||||||
|
UserStore->>DB: INSERT INTO UserVerification (UserAccountId, VerificationDateTime)
|
||||||
|
Note right of UserStore: Idempotent — skipped (duplicate-key exception swallowed) if a verification row already exists
|
||||||
|
UserStore-->>UserMgr: IdentityResult.Success
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>ConfirmHandler: IdentityResult
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
ConfirmHandler-->>Mediator: ConfirmationPayload(user.Id, DateTime.UtcNow)
|
||||||
|
deactivate ConfirmHandler
|
||||||
|
Mediator-->>API: ConfirmationPayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "User with ID {id} is confirmed.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
71
docs/website/diagrams-md/auth/delete-account.md
Normal file
71
docs/website/diagrams-md/auth/delete-account.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# User Authentication Flow — Delete Account
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant DeleteAccountHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: DELETE /api/auth/account (requires Bearer access token)
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(DeleteAccountCommand(GetAuthenticatedUserId()))
|
||||||
|
activate Mediator
|
||||||
|
Mediator->>DeleteAccountHandler: Handle(command)
|
||||||
|
activate DeleteAccountHandler
|
||||||
|
|
||||||
|
DeleteAccountHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
UserMgr-->>DeleteAccountHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Account not found
|
||||||
|
DeleteAccountHandler->>Mediator: throw NotFoundException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 404 Not Found
|
||||||
|
else Account found
|
||||||
|
DeleteAccountHandler->>UserMgr: DeleteAsync(user)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: DeleteAsync(user)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: DELETE FROM UserAccount WHERE UserAccountID = @Id
|
||||||
|
Note right of UserStore: UserCredential/UserVerification/UserAvatar cascade on delete. Posts, comments, photos, and follows use ON DELETE NO ACTION — if any reference this account, the DELETE throws a foreign key violation (SQL error 547)
|
||||||
|
|
||||||
|
alt Foreign key violation (dependent records exist)
|
||||||
|
UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords)
|
||||||
|
else Delete succeeds
|
||||||
|
UserStore-->>UserMgr: IdentityResult.Success
|
||||||
|
end
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>DeleteAccountHandler: IdentityResult
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Account has dependent records
|
||||||
|
DeleteAccountHandler->>Mediator: throw ConflictException "Account cannot be deleted while it still has associated posts, comments, photos, or follows."
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 409 Conflict
|
||||||
|
else !result.Succeeded (other failure)
|
||||||
|
DeleteAccountHandler->>Mediator: throw ConflictException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 409 Conflict
|
||||||
|
else result.Succeeded
|
||||||
|
DeleteAccountHandler-->>Mediator: (void)
|
||||||
|
deactivate DeleteAccountHandler
|
||||||
|
Mediator-->>API: (void)
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "Account deleted successfully."}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
18
docs/website/diagrams-md/auth/error-handling.md
Normal file
18
docs/website/diagrams-md/auth/error-handling.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# User Authentication Flow — Error Handling
|
||||||
|
|
||||||
|
Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`. Applies uniformly across every authentication endpoint (registration, login, refresh, confirm, resend, update-username/email/password/profile, delete-account) — see the per-flow diagrams for where each exception type is thrown.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant API as API Controller
|
||||||
|
|
||||||
|
Note over API: GlobalExceptionFilter catches:
|
||||||
|
Note over API: FluentValidation.ValidationException → 400 Bad Request {message, errors} (grouped by property)
|
||||||
|
Note over API: ConflictException → 409 Conflict
|
||||||
|
Note over API: NotFoundException → 404 Not Found
|
||||||
|
Note over API: UnauthorizedException → 401 Unauthorized
|
||||||
|
Note over API: ForbiddenException → 403 Forbidden
|
||||||
|
Note over API: Domain.Exceptions.ValidationException → 400 Bad Request
|
||||||
|
Note over API: Microsoft.Data.SqlClient.SqlException → 503 Service Unavailable
|
||||||
|
Note over API: All others → 500 Internal Server Error
|
||||||
|
```
|
||||||
99
docs/website/diagrams-md/auth/login.md
Normal file
99
docs/website/diagrams-md/auth/login.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# User Authentication Flow — Login
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant LoginHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
participant TokenSvc as TokenService
|
||||||
|
end
|
||||||
|
box rgb(235,236,227) Infrastructure Layer
|
||||||
|
participant Argon2 as Argon2PasswordHasher
|
||||||
|
participant JWT as JWT Infrastructure
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: POST /api/auth/login {username, password}
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(LoginCommand)
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: ValidationBehavior runs LoginValidator — username and password must both be non-empty
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
Mediator->>API: throws FluentValidation.ValidationException
|
||||||
|
API->>User: 400 Bad Request {message, errors}
|
||||||
|
else Validation succeeds
|
||||||
|
Mediator->>LoginHandler: Handle(command)
|
||||||
|
activate LoginHandler
|
||||||
|
|
||||||
|
LoginHandler->>UserMgr: FindByNameAsync(username)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByNameAsync(username)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
|
||||||
|
activate DB
|
||||||
|
DB-->>UserStore: UserAccount row or null
|
||||||
|
deactivate DB
|
||||||
|
UserStore->>UserStore: Hydrate (active UserCredential.Hash, UserVerification → EmailConfirmed)
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>LoginHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt User not found
|
||||||
|
LoginHandler->>Mediator: throw UnauthorizedException "Invalid username or password."
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 401 Unauthorized
|
||||||
|
else User found
|
||||||
|
LoginHandler->>UserMgr: CheckPasswordAsync(user, password)
|
||||||
|
activate UserMgr
|
||||||
|
Note right of UserMgr: HasPasswordAsync short-circuits to false (no active credential hydrated) without calling the hasher — otherwise delegates to Argon2PasswordHasher
|
||||||
|
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, password)
|
||||||
|
activate Argon2
|
||||||
|
Note right of Argon2: Split stored "salt:hash", rehash password with same salt, constant-time compare (CryptographicOperations.FixedTimeEquals)
|
||||||
|
Argon2-->>UserMgr: Success/Failed
|
||||||
|
deactivate Argon2
|
||||||
|
UserMgr-->>LoginHandler: true/false
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Password invalid
|
||||||
|
LoginHandler->>Mediator: throw UnauthorizedException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 401 Unauthorized
|
||||||
|
else Password valid
|
||||||
|
LoginHandler->>TokenSvc: GenerateAccessToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc->>JWT: GenerateJwt(...)
|
||||||
|
activate JWT
|
||||||
|
JWT-->>TokenSvc: Access Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc-->>LoginHandler: Access Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
LoginHandler->>TokenSvc: GenerateRefreshToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc->>JWT: GenerateJwt(...)
|
||||||
|
activate JWT
|
||||||
|
JWT-->>TokenSvc: Refresh Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc-->>LoginHandler: Refresh Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
LoginHandler-->>Mediator: LoginPayload(userAccountId, username, refreshToken, accessToken)
|
||||||
|
deactivate LoginHandler
|
||||||
|
Mediator-->>API: LoginPayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "Logged in successfully.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
83
docs/website/diagrams-md/auth/refresh-token.md
Normal file
83
docs/website/diagrams-md/auth/refresh-token.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# User Authentication Flow — Refresh Token
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant RefreshHandler as RefreshTokenHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
participant TokenSvc as TokenService
|
||||||
|
end
|
||||||
|
box rgb(235,236,227) Infrastructure Layer
|
||||||
|
participant JWT as JWT Infrastructure
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: POST /api/auth/refresh<br/>Header: X-Refresh-Token: {token}
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(RefreshTokenCommand(refreshToken))
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: ValidationBehavior runs RefreshTokenValidator — refreshToken must be non-empty. Anonymous endpoint, no Bearer token required.
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
Mediator->>API: throws FluentValidation.ValidationException
|
||||||
|
API->>User: 400 Bad Request {message, errors}
|
||||||
|
else Validation succeeds
|
||||||
|
Mediator->>RefreshHandler: Handle(command)
|
||||||
|
activate RefreshHandler
|
||||||
|
|
||||||
|
RefreshHandler->>TokenSvc: RefreshTokenAsync(refreshToken)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc->>JWT: ValidateRefreshTokenAsync(refreshToken)
|
||||||
|
activate JWT
|
||||||
|
Note right of JWT: Validates signature/expiry against the refresh token secret, extracts userId/username claims
|
||||||
|
JWT-->>TokenSvc: ValidatedToken or throws
|
||||||
|
deactivate JWT
|
||||||
|
|
||||||
|
alt Token invalid or expired
|
||||||
|
TokenSvc->>RefreshHandler: throw UnauthorizedException
|
||||||
|
RefreshHandler->>Mediator: propagate
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 401 Unauthorized
|
||||||
|
else Token valid
|
||||||
|
TokenSvc->>UserMgr: FindByIdAsync(validated.UserId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userId)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||||
|
activate DB
|
||||||
|
DB-->>UserStore: row or null
|
||||||
|
deactivate DB
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>TokenSvc: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt User not found
|
||||||
|
TokenSvc->>RefreshHandler: throw UnauthorizedException "User account not found"
|
||||||
|
RefreshHandler->>Mediator: propagate
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 401 Unauthorized
|
||||||
|
else User found
|
||||||
|
TokenSvc->>JWT: GenerateAccessToken(user) + GenerateRefreshToken(user)
|
||||||
|
JWT-->>TokenSvc: new access + refresh tokens
|
||||||
|
TokenSvc-->>RefreshHandler: RefreshTokenResult
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
RefreshHandler-->>Mediator: LoginPayload(userId, username, refreshToken, accessToken)
|
||||||
|
deactivate RefreshHandler
|
||||||
|
Mediator-->>API: LoginPayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "Token refreshed successfully.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
157
docs/website/diagrams-md/auth/registration.md
Normal file
157
docs/website/diagrams-md/auth/registration.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# User Authentication Flow — Registration
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant RegHandler as RegisterUserHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
participant TokenSvc as TokenService
|
||||||
|
end
|
||||||
|
box rgb(235,236,227) Infrastructure Layer
|
||||||
|
participant Argon2 as Argon2PasswordHasher
|
||||||
|
participant JWT as JWT Infrastructure
|
||||||
|
participant SMTP as Email Provider
|
||||||
|
participant Template as Template Provider
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: POST /api/auth/register {username, firstName, lastName, email, dateOfBirth, password}
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(RegisterUserCommand)
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: ValidationBehavior runs RegisterUserValidator — username 3-64 chars alphanumeric+[._-], valid email ≤128 chars, password min 8 chars with upper/lower/number/special, DateOfBirth 19+ years old
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
Mediator->>API: throws FluentValidation.ValidationException
|
||||||
|
API->>User: 400 Bad Request {message, errors}
|
||||||
|
else Validation succeeds
|
||||||
|
Mediator->>RegHandler: Handle(command)
|
||||||
|
activate RegHandler
|
||||||
|
RegHandler->>RegHandler: user = new UserAccount{...}.ToApplicationUser()
|
||||||
|
|
||||||
|
RegHandler->>UserMgr: CreateAsync(user, password)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>Argon2: HashPassword(user, password)
|
||||||
|
activate Argon2
|
||||||
|
Note right of Argon2: Argon2id — salt 16 bytes, memory 64MB, iterations 4, parallelism = CPU count, hash output 32 bytes
|
||||||
|
Argon2-->>UserMgr: "base64(salt):base64(hash)"
|
||||||
|
deactivate Argon2
|
||||||
|
|
||||||
|
UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
|
||||||
|
Note right of UserStore: user.Id is still empty here — only sets the in-memory hash — CreateAsync writes it to UserCredential itself
|
||||||
|
|
||||||
|
UserMgr->>UserStore: FindByNameAsync(username)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
|
||||||
|
activate DB
|
||||||
|
DB-->>UserStore: null (user doesn't exist)
|
||||||
|
deactivate DB
|
||||||
|
deactivate UserStore
|
||||||
|
|
||||||
|
UserMgr->>UserStore: FindByEmailAsync(email)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: SELECT ... FROM UserAccount WHERE Email = @Email
|
||||||
|
activate DB
|
||||||
|
DB-->>UserStore: null (email doesn't exist)
|
||||||
|
deactivate DB
|
||||||
|
deactivate UserStore
|
||||||
|
Note right of UserMgr: Default UserManager validators check uniqueness before CreateAsync — DapperUserStore.CreateAsync also guards a genuine race via a caught duplicate-key SqlException (2601/2627)
|
||||||
|
|
||||||
|
UserMgr->>UserStore: CreateAsync(user)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: BEGIN TRANSACTION
|
||||||
|
activate DB
|
||||||
|
UserStore->>DB: INSERT INTO UserAccount (...) OUTPUT INSERTED.UserAccountID
|
||||||
|
DB-->>UserStore: UserAccountId (GUID)
|
||||||
|
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
|
||||||
|
UserStore->>DB: COMMIT TRANSACTION
|
||||||
|
deactivate DB
|
||||||
|
Note right of UserStore: Both statements run via Dapper against a single DbTransaction, not a stored procedure
|
||||||
|
|
||||||
|
UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>RegHandler: IdentityResult
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt !result.Succeeded
|
||||||
|
RegHandler->>Mediator: throw ConflictException (DuplicateUserName/DuplicateEmail → "Username or email already exists", otherwise joined error descriptions)
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 409 Conflict "Username or email already exists"
|
||||||
|
else result.Succeeded
|
||||||
|
RegHandler->>TokenSvc: GenerateAccessToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
|
||||||
|
activate JWT
|
||||||
|
Note right of JWT: HS256, expires 1 hour, claims sub/unique_name/iat/jti
|
||||||
|
JWT->>JWT: Create JWT with claims, sign with secret key
|
||||||
|
JWT-->>TokenSvc: Access Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc-->>RegHandler: Access Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
RegHandler->>TokenSvc: GenerateRefreshToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
|
||||||
|
activate JWT
|
||||||
|
Note right of JWT: Expires 21 days (504h), same structure as access token, signed with a separate secret
|
||||||
|
JWT-->>TokenSvc: Refresh Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc-->>RegHandler: Refresh Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
RegHandler->>TokenSvc: GenerateConfirmationToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
|
||||||
|
activate JWT
|
||||||
|
Note right of JWT: Expires 30 minutes, signed with a third, separate secret
|
||||||
|
JWT-->>TokenSvc: Confirmation Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc-->>RegHandler: Confirmation Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the only cross-slice call in the backend
|
||||||
|
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
|
||||||
|
activate EmailDispatcher
|
||||||
|
|
||||||
|
EmailDispatcher->>Template: RenderUserRegisteredEmailAsync(firstName, confirmationLink)
|
||||||
|
activate Template
|
||||||
|
Note right of Template: Razor component — header/branding, welcome message, confirmation button, footer
|
||||||
|
Template->>Template: Render Razor component to HTML
|
||||||
|
Template-->>EmailDispatcher: HTML email content
|
||||||
|
deactivate Template
|
||||||
|
|
||||||
|
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
|
||||||
|
activate SMTP
|
||||||
|
Note right of SMTP: MailKit — host/port from SMTP_HOST/SMTP_PORT, StartTLS when SMTP_USE_SSL=true, optional username/password auth
|
||||||
|
SMTP->>SMTP: Build MIME message, connect, authenticate, send, disconnect
|
||||||
|
SMTP-->>EmailDispatcher: Success / throws
|
||||||
|
deactivate SMTP
|
||||||
|
deactivate EmailDispatcher
|
||||||
|
deactivate Mediator
|
||||||
|
|
||||||
|
alt Email sent successfully
|
||||||
|
RegHandler->>RegHandler: emailSent = true
|
||||||
|
else mediator.Send throws
|
||||||
|
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false
|
||||||
|
end
|
||||||
|
|
||||||
|
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
|
||||||
|
deactivate RegHandler
|
||||||
|
Mediator-->>API: RegistrationPayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 201 Created {message: "User registered successfully.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
77
docs/website/diagrams-md/auth/resend-confirmation.md
Normal file
77
docs/website/diagrams-md/auth/resend-confirmation.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# User Authentication Flow — Resend Confirmation Email
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant ResendHandler as ResendConfirmationEmailHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
participant TokenSvc as TokenService
|
||||||
|
end
|
||||||
|
box rgb(235,236,227) Infrastructure Layer
|
||||||
|
participant SMTP as Email Provider
|
||||||
|
participant Template as Template Provider
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: POST /api/auth/confirm/resend?userId={id} (requires Bearer access token)
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(ResendConfirmationEmailCommand(userId))
|
||||||
|
activate Mediator
|
||||||
|
Mediator->>ResendHandler: Handle(command)
|
||||||
|
activate ResendHandler
|
||||||
|
|
||||||
|
ResendHandler->>UserMgr: FindByIdAsync(userId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userId)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||||
|
activate DB
|
||||||
|
DB-->>UserStore: row or null
|
||||||
|
deactivate DB
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>ResendHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt User not found
|
||||||
|
ResendHandler->>ResendHandler: return silently (prevents user enumeration)
|
||||||
|
else User found
|
||||||
|
ResendHandler->>UserMgr: IsEmailConfirmedAsync(user)
|
||||||
|
UserMgr-->>ResendHandler: bool
|
||||||
|
|
||||||
|
alt Already confirmed
|
||||||
|
ResendHandler->>ResendHandler: return silently (no-op)
|
||||||
|
else Not yet confirmed
|
||||||
|
ResendHandler->>TokenSvc: GenerateConfirmationToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc-->>ResendHandler: confirmation token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand)
|
||||||
|
activate Mediator
|
||||||
|
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
|
||||||
|
activate EmailDispatcher
|
||||||
|
EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
|
||||||
|
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
|
||||||
|
deactivate EmailDispatcher
|
||||||
|
deactivate Mediator
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ResendHandler-->>Mediator: (void)
|
||||||
|
deactivate ResendHandler
|
||||||
|
Mediator-->>API: (void)
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "confirmation email has been resent"}
|
||||||
|
Note right of API: Always 200 OK regardless of outcome above — same user-enumeration precaution as a not-found user or an already-confirmed account
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
70
docs/website/diagrams-md/auth/update-email.md
Normal file
70
docs/website/diagrams-md/auth/update-email.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# User Authentication Flow — Update Email
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant UpdateEmailHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: PATCH /api/auth/email {newEmail} (requires Bearer access token)
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(UpdateEmailCommand(GetAuthenticatedUserId(), newEmail))
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: ValidationBehavior runs UpdateEmailValidator (same format rules as registration)
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
Mediator->>API: throws FluentValidation.ValidationException
|
||||||
|
API->>User: 400 Bad Request
|
||||||
|
else Validation succeeds
|
||||||
|
Mediator->>UpdateEmailHandler: Handle(command)
|
||||||
|
activate UpdateEmailHandler
|
||||||
|
|
||||||
|
UpdateEmailHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||||
|
UserStore->>DB: SELECT ...
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
UserMgr-->>UpdateEmailHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Account not found
|
||||||
|
UpdateEmailHandler->>Mediator: throw NotFoundException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 404 Not Found
|
||||||
|
else Account found
|
||||||
|
UpdateEmailHandler->>UserMgr: SetEmailAsync(user, newEmail)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByEmailAsync(newEmail)
|
||||||
|
Note right of UserMgr: Uniqueness check. Also resets user.EmailConfirmed = false — caller must re-confirm via POST /api/auth/confirm/resend
|
||||||
|
UserMgr->>UserStore: UpdateAsync(user)
|
||||||
|
UserStore->>DB: UPDATE UserAccount SET Email = @Email, ...
|
||||||
|
UserStore->>DB: DELETE FROM UserVerification WHERE UserAccountID = @Id
|
||||||
|
UserStore-->>UserMgr: IdentityResult
|
||||||
|
UserMgr-->>UpdateEmailHandler: IdentityResult
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt !result.Succeeded
|
||||||
|
UpdateEmailHandler->>Mediator: throw ConflictException (DuplicateEmail → "Email address already exists")
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 409 Conflict
|
||||||
|
else result.Succeeded
|
||||||
|
UpdateEmailHandler-->>Mediator: UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed = false)
|
||||||
|
deactivate UpdateEmailHandler
|
||||||
|
Mediator-->>API: UpdateEmailPayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "Email updated successfully.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
90
docs/website/diagrams-md/auth/update-password.md
Normal file
90
docs/website/diagrams-md/auth/update-password.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# User Authentication Flow — Update Password
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant UpdatePasswordHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
end
|
||||||
|
box rgb(235,236,227) Infrastructure Layer
|
||||||
|
participant Argon2 as Argon2PasswordHasher
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: PATCH /api/auth/password {currentPassword, newPassword} (requires Bearer access token)
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(UpdatePasswordCommand(GetAuthenticatedUserId(), currentPassword, newPassword))
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: ValidationBehavior runs UpdatePasswordValidator (same password strength rules as registration)
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
Mediator->>API: throws FluentValidation.ValidationException
|
||||||
|
API->>User: 400 Bad Request
|
||||||
|
else Validation succeeds
|
||||||
|
Mediator->>UpdatePasswordHandler: Handle(command)
|
||||||
|
activate UpdatePasswordHandler
|
||||||
|
|
||||||
|
UpdatePasswordHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
UserMgr-->>UpdatePasswordHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Account not found
|
||||||
|
UpdatePasswordHandler->>Mediator: throw NotFoundException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 404 Not Found
|
||||||
|
else Account found
|
||||||
|
UpdatePasswordHandler->>UserMgr: ChangePasswordAsync(user, currentPassword, newPassword)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, currentPassword)
|
||||||
|
activate Argon2
|
||||||
|
Argon2-->>UserMgr: Success/Failed
|
||||||
|
deactivate Argon2
|
||||||
|
|
||||||
|
alt Current password mismatch
|
||||||
|
UserMgr-->>UpdatePasswordHandler: IdentityResult.Failed (PasswordMismatch)
|
||||||
|
else Current password matches
|
||||||
|
UserMgr->>Argon2: HashPassword(user, newPassword)
|
||||||
|
activate Argon2
|
||||||
|
Argon2-->>UserMgr: new hash
|
||||||
|
deactivate Argon2
|
||||||
|
UserMgr->>UserStore: SetPasswordHashAsync(user, newHash)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: UPDATE UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @Id AND IsRevoked = 0
|
||||||
|
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
|
||||||
|
Note right of UserStore: Revoke-then-insert runs inside one DbTransaction (credential rotation)
|
||||||
|
UserStore-->>UserMgr: (void)
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>UpdatePasswordHandler: IdentityResult.Success
|
||||||
|
end
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Current password is incorrect
|
||||||
|
UpdatePasswordHandler->>Mediator: throw UnauthorizedException "Current password is incorrect"
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 401 Unauthorized
|
||||||
|
else !result.Succeeded (other failure)
|
||||||
|
UpdatePasswordHandler->>Mediator: throw ConflictException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 409 Conflict
|
||||||
|
else result.Succeeded
|
||||||
|
UpdatePasswordHandler-->>Mediator: UpdatePasswordPayload(user.Id, DateTime.UtcNow)
|
||||||
|
deactivate UpdatePasswordHandler
|
||||||
|
Mediator-->>API: UpdatePasswordPayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "Password updated successfully.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
69
docs/website/diagrams-md/auth/update-profile.md
Normal file
69
docs/website/diagrams-md/auth/update-profile.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# User Authentication Flow — Update Profile
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant UpdateProfileHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: PATCH /api/auth/profile {firstName, lastName, dateOfBirth} (requires Bearer access token)
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(UpdateProfileCommand(GetAuthenticatedUserId(), firstName, lastName, dateOfBirth))
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: ValidationBehavior runs UpdateProfileValidator (same name/DateOfBirth rules as registration)
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
Mediator->>API: throws FluentValidation.ValidationException
|
||||||
|
API->>User: 400 Bad Request
|
||||||
|
else Validation succeeds
|
||||||
|
Mediator->>UpdateProfileHandler: Handle(command)
|
||||||
|
activate UpdateProfileHandler
|
||||||
|
|
||||||
|
UpdateProfileHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
UserMgr-->>UpdateProfileHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Account not found
|
||||||
|
UpdateProfileHandler->>Mediator: throw NotFoundException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 404 Not Found
|
||||||
|
else Account found
|
||||||
|
UpdateProfileHandler->>UpdateProfileHandler: user.FirstName = ...<br/>user.LastName = ...<br/>user.DateOfBirth = ...
|
||||||
|
Note right of UpdateProfileHandler: First/last name and date of birth aren't tracked by any Identity store interface — mutated directly on the ApplicationUser, then saved via UserManager.UpdateAsync
|
||||||
|
|
||||||
|
UpdateProfileHandler->>UserMgr: UpdateAsync(user)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: UpdateAsync(user)
|
||||||
|
UserStore->>DB: UPDATE UserAccount SET FirstName = @FirstName, LastName = @LastName, DateOfBirth = @DateOfBirth, ...
|
||||||
|
UserStore-->>UserMgr: IdentityResult
|
||||||
|
UserMgr-->>UpdateProfileHandler: IdentityResult
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt !result.Succeeded
|
||||||
|
UpdateProfileHandler->>Mediator: throw ConflictException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 409 Conflict
|
||||||
|
else result.Succeeded
|
||||||
|
UpdateProfileHandler-->>Mediator: UpdateProfilePayload(user.Id, firstName, lastName, dateOfBirth)
|
||||||
|
deactivate UpdateProfileHandler
|
||||||
|
Mediator-->>API: UpdateProfilePayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "Profile updated successfully.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
74
docs/website/diagrams-md/auth/update-username.md
Normal file
74
docs/website/diagrams-md/auth/update-username.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# User Authentication Flow — Update Username
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant API as API Controller
|
||||||
|
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||||
|
box rgb(241,243,234) Handler Layer
|
||||||
|
participant UpdateUsernameHandler
|
||||||
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
|
end
|
||||||
|
box rgb(219,238,221) Repository / Cross-slice
|
||||||
|
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||||
|
end
|
||||||
|
participant DB as SQL Server
|
||||||
|
|
||||||
|
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||||
|
|
||||||
|
User->>API: PATCH /api/auth/username {newUsername} (requires Bearer access token)
|
||||||
|
activate API
|
||||||
|
API->>Mediator: Send(UpdateUsernameCommand(GetAuthenticatedUserId(), newUsername))
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: ValidationBehavior runs UpdateUsernameValidator (same format rules as registration)
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
Mediator->>API: throws FluentValidation.ValidationException
|
||||||
|
API->>User: 400 Bad Request
|
||||||
|
else Validation succeeds
|
||||||
|
Mediator->>UpdateUsernameHandler: Handle(command)
|
||||||
|
activate UpdateUsernameHandler
|
||||||
|
|
||||||
|
UpdateUsernameHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||||
|
activate UserStore
|
||||||
|
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||||
|
activate DB
|
||||||
|
DB-->>UserStore: row or null
|
||||||
|
deactivate DB
|
||||||
|
UserStore-->>UserMgr: ApplicationUser or null
|
||||||
|
deactivate UserStore
|
||||||
|
UserMgr-->>UpdateUsernameHandler: ApplicationUser or null
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt Account not found
|
||||||
|
UpdateUsernameHandler->>Mediator: throw NotFoundException
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 404 Not Found
|
||||||
|
else Account found
|
||||||
|
UpdateUsernameHandler->>UserMgr: SetUserNameAsync(user, newUsername)
|
||||||
|
activate UserMgr
|
||||||
|
UserMgr->>UserStore: FindByNameAsync(newUsername)
|
||||||
|
Note right of UserMgr: Uniqueness check before the UPDATE
|
||||||
|
UserMgr->>UserStore: UpdateAsync(user)
|
||||||
|
UserStore->>DB: UPDATE UserAccount SET Username = @UserName, ...
|
||||||
|
UserStore-->>UserMgr: IdentityResult
|
||||||
|
UserMgr-->>UpdateUsernameHandler: IdentityResult
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt !result.Succeeded
|
||||||
|
UpdateUsernameHandler->>Mediator: throw ConflictException (DuplicateUserName → "Username already exists")
|
||||||
|
Mediator->>API: propagate
|
||||||
|
API->>User: 409 Conflict
|
||||||
|
else result.Succeeded
|
||||||
|
UpdateUsernameHandler-->>Mediator: UpdateUsernamePayload(user.Id, user.UserName)
|
||||||
|
deactivate UpdateUsernameHandler
|
||||||
|
Mediator-->>API: UpdateUsernamePayload
|
||||||
|
deactivate Mediator
|
||||||
|
API->>User: 200 OK {message: "Username updated successfully.", payload: {...}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
```
|
||||||
168
docs/website/diagrams-md/database-schema.md
Normal file
168
docs/website/diagrams-md/database-schema.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Database Schema
|
||||||
|
|
||||||
|
Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency), omitted below for brevity. Tables marked `(*)` have no repository/feature slice yet.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
User ||--o{ Cred : "has credentials"
|
||||||
|
User ||--o| Verify : "has verification"
|
||||||
|
User ||--o| Profile : "has profile"
|
||||||
|
Profile ||--o{ Avatar : "has avatar history"
|
||||||
|
Avatar }o--|| Photo : "uses photo"
|
||||||
|
User ||--o{ Follow : "follows"
|
||||||
|
User ||--o{ Follow : "followed by"
|
||||||
|
User ||--o{ Photo : "uploads"
|
||||||
|
|
||||||
|
Country ||--o{ StateProvince : "has"
|
||||||
|
StateProvince ||--o{ City : "has"
|
||||||
|
|
||||||
|
User ||--o{ Brewery : "posts"
|
||||||
|
Brewery ||--o| BreweryLoc : "has location"
|
||||||
|
City ||--o{ BreweryLoc : "located in"
|
||||||
|
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||||
|
Photo ||--o{ BreweryPhoto : "used in"
|
||||||
|
|
||||||
|
User ||--o{ Beer : "posts"
|
||||||
|
BeerStyle ||--o{ Beer : "categorizes"
|
||||||
|
Brewery ||--o{ Beer : "brews"
|
||||||
|
Beer ||--o{ BeerPhoto : "has photos"
|
||||||
|
Photo ||--o{ BeerPhoto : "used in"
|
||||||
|
Beer ||--o{ BeerComment : "has comments"
|
||||||
|
User ||--o{ BeerComment : "comments"
|
||||||
|
|
||||||
|
User {
|
||||||
|
uniqueidentifier UserAccountID PK
|
||||||
|
varchar Username UK
|
||||||
|
nvarchar FirstName
|
||||||
|
nvarchar LastName
|
||||||
|
varchar Email UK
|
||||||
|
datetime CreatedAt
|
||||||
|
datetime UpdatedAt "nullable"
|
||||||
|
date DateOfBirth
|
||||||
|
}
|
||||||
|
|
||||||
|
Cred {
|
||||||
|
uniqueidentifier UserCredentialID PK
|
||||||
|
uniqueidentifier UserAccountID FK
|
||||||
|
datetime CreatedAt
|
||||||
|
datetime Expiry
|
||||||
|
nvarchar Hash
|
||||||
|
bit IsRevoked
|
||||||
|
datetime RevokedAt "nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
Verify {
|
||||||
|
uniqueidentifier UserVerificationID PK
|
||||||
|
uniqueidentifier UserAccountID "FK, UK"
|
||||||
|
datetime VerificationDateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile {
|
||||||
|
uniqueidentifier UserProfileID PK
|
||||||
|
uniqueidentifier UserAccountID "FK, UK"
|
||||||
|
nvarchar Biography
|
||||||
|
}
|
||||||
|
|
||||||
|
Avatar {
|
||||||
|
uniqueidentifier UserAvatarID PK
|
||||||
|
uniqueidentifier UserProfileID FK
|
||||||
|
uniqueidentifier PhotoID FK
|
||||||
|
datetime2 ValidFrom
|
||||||
|
datetime2 ValidTo "nullable; at most one row per UserProfileID has ValidTo IS NULL (active avatar)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Follow {
|
||||||
|
uniqueidentifier UserFollowID PK
|
||||||
|
uniqueidentifier UserAccountID FK
|
||||||
|
uniqueidentifier FollowingID FK
|
||||||
|
datetime CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
Photo {
|
||||||
|
uniqueidentifier PhotoID PK
|
||||||
|
nvarchar Hyperlink "nullable"
|
||||||
|
uniqueidentifier UploadedByID FK
|
||||||
|
datetime UploadedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
Country {
|
||||||
|
uniqueidentifier CountryID PK
|
||||||
|
nvarchar CountryName
|
||||||
|
char ISO3166_1 UK
|
||||||
|
}
|
||||||
|
|
||||||
|
StateProvince {
|
||||||
|
uniqueidentifier StateProvinceID PK
|
||||||
|
nvarchar StateProvinceName
|
||||||
|
char ISO3166_2 UK
|
||||||
|
uniqueidentifier CountryID FK
|
||||||
|
}
|
||||||
|
|
||||||
|
City {
|
||||||
|
uniqueidentifier CityID PK
|
||||||
|
nvarchar CityName
|
||||||
|
uniqueidentifier StateProvinceID FK
|
||||||
|
}
|
||||||
|
|
||||||
|
Brewery {
|
||||||
|
uniqueidentifier BreweryPostID PK
|
||||||
|
nvarchar BreweryName
|
||||||
|
uniqueidentifier PostedByID FK
|
||||||
|
nvarchar Description
|
||||||
|
datetime CreatedAt
|
||||||
|
datetime UpdatedAt "nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
BreweryLoc {
|
||||||
|
uniqueidentifier BreweryPostLocationID PK
|
||||||
|
uniqueidentifier BreweryPostID "FK, UK"
|
||||||
|
nvarchar AddressLine1
|
||||||
|
nvarchar AddressLine2 "nullable"
|
||||||
|
nvarchar PostalCode
|
||||||
|
uniqueidentifier CityID FK
|
||||||
|
geography Coordinates "nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
BreweryPhoto {
|
||||||
|
uniqueidentifier BreweryPostPhotoID PK
|
||||||
|
uniqueidentifier BreweryPostID FK
|
||||||
|
uniqueidentifier PhotoID FK
|
||||||
|
datetime LinkedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
BeerStyle {
|
||||||
|
uniqueidentifier BeerStyleID PK
|
||||||
|
nvarchar StyleName UK
|
||||||
|
nvarchar Description "nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
Beer {
|
||||||
|
uniqueidentifier BeerPostID PK
|
||||||
|
nvarchar Name
|
||||||
|
nvarchar Description
|
||||||
|
decimal ABV
|
||||||
|
int IBU
|
||||||
|
uniqueidentifier PostedByID FK
|
||||||
|
uniqueidentifier BeerStyleID FK
|
||||||
|
uniqueidentifier BrewedByID FK
|
||||||
|
datetime CreatedAt
|
||||||
|
datetime UpdatedAt "nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
BeerPhoto {
|
||||||
|
uniqueidentifier BeerPostPhotoID PK
|
||||||
|
uniqueidentifier BeerPostID FK
|
||||||
|
uniqueidentifier PhotoID FK
|
||||||
|
datetime LinkedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
BeerComment {
|
||||||
|
uniqueidentifier BeerPostCommentID PK
|
||||||
|
nvarchar Comment
|
||||||
|
uniqueidentifier BeerPostID FK
|
||||||
|
uniqueidentifier CommentedByID FK
|
||||||
|
int Rating
|
||||||
|
datetime CreatedAt
|
||||||
|
datetime UpdatedAt "nullable"
|
||||||
|
}
|
||||||
|
```
|
||||||
90
docs/website/diagrams-md/deployment.md
Normal file
90
docs/website/diagrams-md/deployment.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Docker Deployment Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Developer(["Developer"])
|
||||||
|
Host[["Docker Host"]]
|
||||||
|
Developer -->|docker compose up| Host
|
||||||
|
Host -->|http://localhost:8080| DevAPI
|
||||||
|
|
||||||
|
subgraph DEV["Development Environment (docker-compose.dev.yaml)"]
|
||||||
|
DevDB[("SQL Server 2022<br/>Biergarten Database<br/>:1433")]
|
||||||
|
DevAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
|
||||||
|
DevMig["Migrations (run-once)<br/>Database.Migrations"]
|
||||||
|
DevSeed["Seed (run-once)<br/>Database.Seed"]
|
||||||
|
DevMailpit["Mailpit<br/>:8025 Web UI / :1025 SMTP"]
|
||||||
|
end
|
||||||
|
|
||||||
|
DevMig -->|1. Run migrations| DevDB
|
||||||
|
DevSeed -->|2. Seed data| DevDB
|
||||||
|
DevAPI -->|3. Connect and serve| DevDB
|
||||||
|
DevAPI -.->|4. SMTP_HOST/PORT| DevMailpit
|
||||||
|
DevMig -.->|depends_on| DevDB
|
||||||
|
DevSeed -.->|depends_on| DevMig
|
||||||
|
DevAPI -.->|depends_on| DevSeed
|
||||||
|
|
||||||
|
subgraph TEST["Test Environment (docker-compose.test.yaml)"]
|
||||||
|
TestDB[("SQL Server, isolated instance<br/>Biergarten Test Database<br/>:1434")]
|
||||||
|
TestMig["Migrations (test)"]
|
||||||
|
TestSeed["Seed (test)<br/>minimal: test.user only"]
|
||||||
|
Specs["API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)"]
|
||||||
|
UnitTests["unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container"]
|
||||||
|
end
|
||||||
|
|
||||||
|
TestMig -->|1. Migrate| TestDB
|
||||||
|
TestSeed -->|2. Seed| TestDB
|
||||||
|
Specs -->|3. Integration test| TestDB
|
||||||
|
UnitTests -.->|depends_on for startup ordering only, no real connection| TestDB
|
||||||
|
TestMig -.->|depends_on| TestDB
|
||||||
|
TestSeed -.->|depends_on| TestMig
|
||||||
|
Specs -.->|depends_on| TestSeed
|
||||||
|
UnitTests -.->|depends_on| TestSeed
|
||||||
|
|
||||||
|
Results[["test-results/ (mounted volume)<br/>api-specs/results.trx<br/>Features.Users.Tests.trx<br/>Features.Breweries.Tests.trx<br/>Features.Emails.Tests.trx"]]
|
||||||
|
Specs -->|Export TRX| Results
|
||||||
|
UnitTests -->|Export TRX, one per slice| Results
|
||||||
|
|
||||||
|
subgraph PROD["Production Environment (docker-compose.prod.yaml)"]
|
||||||
|
ProdDB[("SQL Server 2022<br/>Biergarten Database<br/>no host port published")]
|
||||||
|
ProdMig["Migrations (run-once)"]
|
||||||
|
ProdAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
|
||||||
|
end
|
||||||
|
|
||||||
|
ProdMig -->|1. Run migrations| ProdDB
|
||||||
|
ProdAPI -->|2. Connect and serve| ProdDB
|
||||||
|
ProdMig -.->|depends_on| ProdDB
|
||||||
|
ProdAPI -.->|"depends_on: sql-server healthy (not migrations/seed)"| ProdDB
|
||||||
|
|
||||||
|
subgraph DBONLY["Database-only Environment (docker-compose.db.yaml)"]
|
||||||
|
DbOnlyDB[("SQL Server<br/>:1434")]
|
||||||
|
DbOnlyMig["Migrations (run-once)"]
|
||||||
|
DbOnlySeed["Seed (run-once)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
DbOnlyMig -->|1. Run migrations| DbOnlyDB
|
||||||
|
DbOnlySeed -->|2. Seed data| DbOnlyDB
|
||||||
|
DbOnlyMig -.->|depends_on| DbOnlyDB
|
||||||
|
DbOnlySeed -.->|depends_on| DbOnlyMig
|
||||||
|
|
||||||
|
subgraph MIN["Minimal Environment (docker-compose.min.yaml)"]
|
||||||
|
MinDB[("SQL Server<br/>:1433")]
|
||||||
|
MinMig["Migrations (run-once)"]
|
||||||
|
MinMailpit["Mailpit<br/>:8025 / :1025"]
|
||||||
|
MinSeed["Seed (run-once)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MinMig -->|1. Run migrations| MinDB
|
||||||
|
MinSeed -->|2. Seed data| MinDB
|
||||||
|
MinMig -.->|depends_on| MinDB
|
||||||
|
MinSeed -.->|depends_on| MinMig
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Dev network** (`biergarten-dev`, bridge): internal DNS resolves `sql-server` and `api`. Volumes: `biergarten-dev-data`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once) → API (long-running).
|
||||||
|
- **Test network** (`biergarten-test`, bridge): all test components isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `biergarten-test-data` volume). All tests run in parallel, results aggregated.
|
||||||
|
- **Prod network** (`prodnet`, bridge): isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production.
|
||||||
|
- **Database-only environment**: same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core` and `mailpit` — for working against the database without running the API.
|
||||||
|
- **Minimal environment**: database + mailpit only, no API container — for frontend development against an `API.Core` process run outside Docker.
|
||||||
|
- `unit.tests` builds `Dockerfile.tests`, which restores/builds against `Core.slnx` then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed.
|
||||||
|
- `.trx` results are readable by Visual Studio, Azure DevOps, and GitHub Actions.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
@startuml architecture
|
@startuml architecture_v6
|
||||||
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
|
skinparam linetype ortho
|
||||||
|
skinparam nodesep 90
|
||||||
|
skinparam ranksep 110
|
||||||
|
|
||||||
title The Biergarten App - Vertical Slice Architecture
|
title The Biergarten App - Vertical Slice Architecture
|
||||||
|
|
||||||
@@ -85,13 +87,19 @@ database "SQL Server" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
node "Database.Seed\n(separate console app)" as Seed
|
node "Database.Seed\n(separate console app)" as Seed
|
||||||
|
note right of Seed
|
||||||
|
Seed calls each slice's
|
||||||
|
AddFeatures*() DI extension
|
||||||
|
directly - no API host,
|
||||||
|
no MediatR pipeline
|
||||||
|
end note
|
||||||
|
|
||||||
' Relationships
|
' Relationships
|
||||||
API ..> UsersSlice : AddApplicationPart\n+ MediatR scan
|
API ..> UsersSlice : AddApplicationPart
|
||||||
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
API ..> BrewerySlice : AddApplicationPart
|
||||||
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
API ..> EmailsSlice : MediatR scan only
|
||||||
|
|
||||||
Seed ..> UsersSlice : AddFeaturesUsers()\n(DI only, no API/MediatR)
|
Seed ..> UsersSlice : AddFeaturesUsers()
|
||||||
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
||||||
Seed ..> LocationsSlice : AddFeaturesLocations()
|
Seed ..> LocationsSlice : AddFeaturesLocations()
|
||||||
|
|
||||||
@@ -118,8 +126,8 @@ EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
|||||||
Sql --> Domain
|
Sql --> Domain
|
||||||
|
|
||||||
UsersSlice --> Tables : Dapper SQL
|
UsersSlice --> Tables : Dapper SQL
|
||||||
BrewerySlice --> Tables : Dapper SQL
|
BrewerySlice --> Tables
|
||||||
LocationsSlice --> Tables : Dapper SQL
|
LocationsSlice --> Tables
|
||||||
|
|
||||||
UsersSlice --> Domain
|
UsersSlice --> Domain
|
||||||
BrewerySlice --> Domain
|
BrewerySlice --> Domain
|
||||||
|
|||||||
@@ -170,49 +170,33 @@ package "Beer (*)" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
' Relationships: User & Auth
|
' Relationships: User & Auth
|
||||||
User ||--o{ Cred : "has"
|
User ||--o{ Cred
|
||||||
User ||--o| Verify : "has"
|
User ||--o| Verify
|
||||||
User ||--o| Profile : "has"
|
User ||--o| Profile
|
||||||
Profile ||--o{ Avatar : "has (history)"
|
Profile ||--o{ Avatar
|
||||||
Avatar }o--|| Photo : "refers to"
|
Avatar }o--|| Photo
|
||||||
User ||--o{ Follow : "follows (UserAccountID)"
|
User ||--o{ Follow
|
||||||
User ||--o{ Follow : "followed by (FollowingID)"
|
User ||--o{ Follow
|
||||||
User ||--o{ Photo : "uploaded"
|
User ||--o{ Photo
|
||||||
|
|
||||||
' Relationships: Location
|
' Relationships: Location
|
||||||
Country ||--o{ StateProvince : "has"
|
Country ||--o{ StateProvince
|
||||||
StateProvince ||--o{ City : "has"
|
StateProvince ||--o{ City
|
||||||
|
|
||||||
' Relationships: Brewery
|
' Relationships: Brewery
|
||||||
User ||--o{ Brewery : "posted"
|
User ||--o{ Brewery
|
||||||
Brewery ||--o| BreweryLoc : "has"
|
Brewery ||--o| BreweryLoc
|
||||||
City ||--o{ BreweryLoc : "located in"
|
City ||--o{ BreweryLoc
|
||||||
Brewery ||--o{ BreweryPhoto : "has"
|
Brewery ||--o{ BreweryPhoto
|
||||||
Photo ||--o{ BreweryPhoto : "linked to"
|
Photo ||--o{ BreweryPhoto
|
||||||
|
|
||||||
' Relationships: Beer
|
' Relationships: Beer
|
||||||
User ||--o{ Beer : "posted"
|
User ||--o{ Beer
|
||||||
BeerStyle ||--o{ Beer : "categorizes"
|
BeerStyle ||--o{ Beer
|
||||||
Brewery ||--o{ Beer : "brewed"
|
Brewery ||--o{ Beer
|
||||||
Beer ||--o{ BeerPhoto : "has"
|
Beer ||--o{ BeerPhoto
|
||||||
Photo ||--o{ BeerPhoto : "linked to"
|
Photo ||--o{ BeerPhoto
|
||||||
Beer ||--o{ BeerComment : "has"
|
Beer ||--o{ BeerComment
|
||||||
User ||--o{ BeerComment : "commented"
|
User ||--o{ BeerComment
|
||||||
|
|
||||||
note bottom of User
|
|
||||||
Auth reads/writes (registration, login, credential
|
|
||||||
rotation, verification) go through ASP.NET Core
|
|
||||||
Identity's UserManager<ApplicationUser>, backed by
|
|
||||||
Features.Users' DapperUserStore - inline SQL, no
|
|
||||||
stored procedures. Profile/avatar data goes through
|
|
||||||
Features.Users' UserProfileRepository.
|
|
||||||
end note
|
|
||||||
|
|
||||||
note bottom of Brewery
|
|
||||||
Backed by Features.Breweries' BreweryRepository
|
|
||||||
(Dapper); create/update/delete run inline SQL
|
|
||||||
inside a transaction, with optimistic concurrency
|
|
||||||
via RowVersion
|
|
||||||
end note
|
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ tags:
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Core project validates JSON Web Tokens (JWTs) across three token types:
|
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
|
||||||
|
|
||||||
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
| Token Type | Lifetime | Purpose |
|
||||||
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
|
| --- | --- | --- |
|
||||||
tokens
|
| **Access Tokens** | 1 hour | API authentication |
|
||||||
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email
|
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
|
||||||
confirmation
|
| **Confirmation Tokens** | 30 minutes | Email confirmation |
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -183,29 +183,35 @@ Validation failures return HTTP 401 Unauthorized:
|
|||||||
|
|
||||||
### Access token lifecycle
|
### Access token lifecycle
|
||||||
|
|
||||||
1. **Generation**: During login (1-hour validity)
|
| Step | Description |
|
||||||
2. **Usage**: Included in Authorization header on API requests
|
| --- | --- |
|
||||||
3. **Validation**: Validated on protected endpoints
|
| Generation | During login (1-hour validity) |
|
||||||
4. **Expiration**: Token becomes invalid after 1 hour
|
| Usage | Included in Authorization header on API requests |
|
||||||
5. **Refresh**: Use refresh token to obtain new access token
|
| Validation | Validated on protected endpoints |
|
||||||
|
| Expiration | Token becomes invalid after 1 hour |
|
||||||
|
| Refresh | Use refresh token to obtain new access token |
|
||||||
|
|
||||||
### Refresh token lifecycle
|
### Refresh token lifecycle
|
||||||
|
|
||||||
1. **Generation**: During login (21-day validity)
|
| Step | Description |
|
||||||
2. **Storage**: Client-side (secure storage)
|
| --- | --- |
|
||||||
3. **Usage**: Posted to `/api/auth/refresh` endpoint
|
| Generation | During login (21-day validity) |
|
||||||
4. **Validation**: Validated by `ITokenService.RefreshTokenAsync()`
|
| Storage | Client-side (secure storage) |
|
||||||
5. **Rotation**: New refresh token issued on successful refresh
|
| Usage | Posted to `/api/auth/refresh` endpoint |
|
||||||
6. **Expiration**: Token becomes invalid after 21 days
|
| Validation | Validated by `ITokenService.RefreshTokenAsync()` |
|
||||||
|
| Rotation | New refresh token issued on successful refresh |
|
||||||
|
| Expiration | Token becomes invalid after 21 days |
|
||||||
|
|
||||||
### Confirmation token lifecycle
|
### Confirmation token lifecycle
|
||||||
|
|
||||||
1. **Generation**: During user registration (30-minute validity)
|
| Step | Description |
|
||||||
2. **Delivery**: Emailed to user in confirmation link
|
| --- | --- |
|
||||||
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
|
| Generation | During user registration (30-minute validity) |
|
||||||
4. **Validation**: Validated by `ConfirmUserHandler`
|
| Delivery | Emailed to user in confirmation link |
|
||||||
5. **Completion**: User account marked as confirmed
|
| Usage | User clicks link, token posted to `/api/auth/confirm` |
|
||||||
6. **Expiration**: Token becomes invalid after 30 minutes
|
| Validation | Validated by `ConfirmUserHandler` |
|
||||||
|
| Completion | User account marked as confirmed |
|
||||||
|
| Expiration | Token becomes invalid after 30 minutes |
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
58
mkdocs.yml
Normal file
58
mkdocs.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
site_name: The Biergarten App
|
||||||
|
site_url: https://github.io
|
||||||
|
repo_url: https://github.com
|
||||||
|
repo_name: the-biergarten-app
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
nav:
|
||||||
|
- Home: index.md
|
||||||
|
- Architecture: architecture.md
|
||||||
|
- Diagrams:
|
||||||
|
- Website:
|
||||||
|
- Architecture: website/diagrams-md/architecture.md
|
||||||
|
- Database Schema: website/diagrams-md/database-schema.md
|
||||||
|
- Deployment: website/diagrams-md/deployment.md
|
||||||
|
- Auth Flows:
|
||||||
|
- Registration: website/diagrams-md/auth/registration.md
|
||||||
|
- Login: website/diagrams-md/auth/login.md
|
||||||
|
- Refresh Token: website/diagrams-md/auth/refresh-token.md
|
||||||
|
- Confirm User: website/diagrams-md/auth/confirm-user.md
|
||||||
|
- Resend Confirmation: website/diagrams-md/auth/resend-confirmation.md
|
||||||
|
- Update Username: website/diagrams-md/auth/update-username.md
|
||||||
|
- Update Email: website/diagrams-md/auth/update-email.md
|
||||||
|
- Update Password: website/diagrams-md/auth/update-password.md
|
||||||
|
- Update Profile: website/diagrams-md/auth/update-profile.md
|
||||||
|
- Delete Account: website/diagrams-md/auth/delete-account.md
|
||||||
|
- Error Handling: website/diagrams-md/auth/error-handling.md
|
||||||
|
- Pipeline:
|
||||||
|
- Current:
|
||||||
|
- Activity: pipeline/diagrams-md/current/activity.md
|
||||||
|
- Class: pipeline/diagrams-md/current/class.md
|
||||||
|
- Planned:
|
||||||
|
- Activity: pipeline/diagrams-md/planned/activity.md
|
||||||
|
- Class: pipeline/diagrams-md/planned/class.md
|
||||||
|
- Website:
|
||||||
|
- Getting Started: website/getting-started.md
|
||||||
|
- Database: website/database.md
|
||||||
|
- Docker: website/docker.md
|
||||||
|
- Environment Variables: website/environment-variables.md
|
||||||
|
- Testing: website/testing.md
|
||||||
|
- Token Validation: website/token-validation.md
|
||||||
|
- Pipeline:
|
||||||
|
- Overview: pipeline/README.md
|
||||||
|
- Roadmap: pipeline/ROADMAP.md
|
||||||
|
- Ethics & Known Issues: pipeline/ETHICS-AND-KNOWN-ISSUES.md
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search
|
||||||
|
- glightbox
|
||||||
|
- panzoom:
|
||||||
|
mermaid: true
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- pymdownx.superfences:
|
||||||
|
custom_fences:
|
||||||
|
- name: mermaid
|
||||||
|
class: mermaid
|
||||||
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
Reference in New Issue
Block a user