mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Refactor documentation and diagrams for web/pipeline. (#272)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> for assistance with diagram creation and documentation prose updates.
This commit is contained in:
52
.claude/skills/doc-drift-pipeline/SKILL.md
Normal file
52
.claude/skills/doc-drift-pipeline/SKILL.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: doc-drift-pipeline
|
||||
description: Checks whether recent changes under tooling/pipeline/ have made docs/pipeline/ stale. Use when the user asks to check for doc drift on the pipeline, before committing changes to tooling/pipeline, or when asked "does this need a doc update" for the C++ seed-data pipeline.
|
||||
---
|
||||
|
||||
# Doc Drift Check — Pipeline
|
||||
|
||||
Given a diff scoped to `tooling/pipeline/` (uncommitted changes, a range of
|
||||
commits, or a specific PR), determine whether it invalidates any factual
|
||||
claim in `docs/pipeline/`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git diff -- tooling/pipeline/` (or
|
||||
`git diff <base>...<head> -- tooling/pipeline/` if a range is given) to
|
||||
see what changed. If the diff also touches `web/**`, note that it's out of
|
||||
scope here — use the `doc-drift-web` skill for that half.
|
||||
2. Based on which files changed, identify the relevant doc(s):
|
||||
- `tooling/pipeline/src/**`, `includes/**` (implemented behavior) →
|
||||
`docs/pipeline/README.md`, `docs/pipeline/diagrams/current/*.mmd`
|
||||
(activity, class)
|
||||
- Work that's scaffolded/flagged but not wired into the default run path
|
||||
(roadmap-only) → `docs/pipeline/ROADMAP.md`,
|
||||
`docs/pipeline/diagrams/planned/*.mmd` — do not touch `current/` for
|
||||
planned work that hasn't landed
|
||||
- Changes to pipeline bias/hallucination handling, model prompts, sampling
|
||||
logic, or known-limitation workarounds →
|
||||
`docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md`
|
||||
- `tooling/pipeline/runpod/Dockerfile`, build/run scripts →
|
||||
`docs/pipeline/README.md` (setup/usage sections)
|
||||
- `docs/pipeline/french-cities.example` — check it still matches the input
|
||||
schema the pipeline actually reads
|
||||
- A new file added under `docs/pipeline/**` → also check `docs/index.md`
|
||||
and `mkdocs.yml` nav are updated to reference it
|
||||
3. Read the relevant doc section(s) and check each specific factual claim
|
||||
(tables, "currently X", "not yet implemented", roadmap checkboxes) against
|
||||
the diff. For diagrams, this means reading the `.mmd` source (not just the
|
||||
wrapping `.md`) and checking nodes/edges/labels against the diff — a
|
||||
pipeline stage or control-flow change can invalidate a diagram without
|
||||
touching any prose.
|
||||
4. When a diff adds real behavior that was previously only in
|
||||
`diagrams/planned/`, check whether that planned diagram/roadmap entry
|
||||
should now move to `current/`/README rather than just adding a duplicate
|
||||
claim in both places.
|
||||
5. Report only what's actually stale — do not rewrite unrelated prose, do not
|
||||
invent claims not grounded in the diff.
|
||||
6. If nothing is affected, say so plainly and make no edits.
|
||||
7. For confirmed drift, propose the exact edit (old text → new text, or the
|
||||
changed `.mmd` node/edge) rather than committing directly, unless
|
||||
explicitly told to apply it. Flag `.mmd` changes separately since they may
|
||||
need a re-render (`docs/rendered-diagrams/pipeline/**`) via
|
||||
`docs/makefile`.
|
||||
55
.claude/skills/doc-drift-web/SKILL.md
Normal file
55
.claude/skills/doc-drift-web/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: doc-drift-web
|
||||
description: Checks whether recent changes under web/ have made docs/web/ stale. Use when the user asks to check for doc drift on the web app, before committing changes to web/backend or web/frontend, or when asked "does this need a doc update" for web/API/frontend work.
|
||||
---
|
||||
|
||||
# Doc Drift Check — Web
|
||||
|
||||
Given a diff scoped to `web/` (uncommitted changes, a range of commits, or a
|
||||
specific PR), determine whether it invalidates any factual claim in
|
||||
`docs/web/`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git diff -- web/` (or `git diff <base>...<head> -- web/` if a range is
|
||||
given) to see what changed. If the diff also touches `tooling/pipeline/**`,
|
||||
note that it's out of scope here — use the `doc-drift-pipeline` skill for
|
||||
that half.
|
||||
2. Based on which files changed, identify the relevant doc(s):
|
||||
- `web/backend/API/**`, `Features.*/**` (general) → `docs/web/architecture.md`
|
||||
- `web/backend/Features/Features.Users/**/Authentication/**`,
|
||||
identity/token code (`ITokenService`, `RefreshToken*`, `ConfirmEmail*`,
|
||||
login/registration handlers) → `docs/web/token-validation.md`,
|
||||
`docs/web/diagrams/auth/*.mmd` (pick the matching flow: login,
|
||||
registration, refresh-token, confirm-user, delete-account,
|
||||
update-email/password/profile/username, resend-confirmation,
|
||||
error-handling)
|
||||
- `web/backend/Database/**` (entities, `Database.Migrations/scripts/**`) →
|
||||
`docs/web/database.md`, `docs/web/diagrams/database-schema/*.mmd` (pick
|
||||
the matching schema: beer, brewery, identity, location, media)
|
||||
- Host wiring / DI registration (`API/**Program.cs`,
|
||||
`Database.Seed/**Program.cs`) → `docs/web/diagrams/vertical-slice/host-wiring.mmd`
|
||||
- New project references between `Features.*`/`Shared.*`/`Domain.*`
|
||||
projects (new `.csproj` `<ProjectReference>`) →
|
||||
`docs/web/diagrams/vertical-slice/project-references.mmd`
|
||||
- `web/backend/Dockerfile*`, `web/frontend/Dockerfile*`, compose/deploy
|
||||
config → `docs/web/docker.md`, `docs/web/diagrams/deployment/*.mmd`
|
||||
(dev, test, minimal, db-only, prod), `docs/web/diagrams/deployment.md`
|
||||
- `web/.env.example`, `web/.env.test`, appsettings → `docs/web/environment-variables.md`
|
||||
- `web/backend/API/API.Specs/**`, test infra/fixtures → `docs/web/testing.md`
|
||||
- New backend project setup, dev tooling → `docs/web/getting-started.md`
|
||||
- `web/frontend/**` → `docs/web/architecture.md` (Frontend architecture section)
|
||||
- A new file added under `docs/web/**` → also check `docs/index.md` and
|
||||
`mkdocs.yml` nav are updated to reference it
|
||||
3. Read the relevant doc section(s) and check each specific factual claim
|
||||
(tables, "currently X", "not yet implemented", roadmap checkboxes) against
|
||||
the diff. For diagrams, this means reading the `.mmd` source (not just the
|
||||
wrapping `.md`) and checking nodes/edges/labels against the diff — a
|
||||
schema or flow change can invalidate a diagram without touching any prose.
|
||||
4. Report only what's actually stale — do not rewrite unrelated prose, do not
|
||||
invent claims not grounded in the diff.
|
||||
5. If nothing is affected, say so plainly and make no edits.
|
||||
6. For confirmed drift, propose the exact edit (old text → new text, or the
|
||||
changed `.mmd` node/edge) rather than committing directly, unless
|
||||
explicitly told to apply it. Flag `.mmd` changes separately since they may
|
||||
need a re-render (`docs/rendered-diagrams/web/**`) via `docs/makefile`.
|
||||
51
.github/workflows/deploy-docs.yml
vendored
Normal file
51
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
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"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install mkdocs mkdocs-material mkdocs-glightbox mkdocs-panzoom-plugin mkdocs-minify-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
|
||||
|
||||
- name: Build test images
|
||||
run: docker compose --env-file .env.test -f docker-compose.test.yaml build
|
||||
run:
|
||||
docker compose --env-file .env.test -f docker-compose.test.yaml build
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -43,7 +44,8 @@ jobs:
|
||||
|
||||
- name: Show service logs on failure
|
||||
if: failure()
|
||||
run: docker compose --env-file .env.test -f docker-compose.test.yaml logs
|
||||
run:
|
||||
docker compose --env-file .env.test -f docker-compose.test.yaml logs
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
@@ -54,4 +56,6 @@ jobs:
|
||||
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: docker compose --env-file .env.test -f docker-compose.test.yaml down -v
|
||||
run:
|
||||
docker compose --env-file .env.test -f docker-compose.test.yaml down
|
||||
-v
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,6 +22,7 @@ scratchpad.txt
|
||||
/web/frontend/playwright-report/
|
||||
/web/frontend/test-results/
|
||||
/test-results/
|
||||
/docs/rendered-diagrams/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -503,3 +504,6 @@ FodyWeavers.xsd
|
||||
storybook-static
|
||||
web/test-results/frontend/vitest-storybook.xml
|
||||
web/test-results/frontend/playwright-storybook.xml
|
||||
|
||||
# mkdocs build output
|
||||
/site
|
||||
|
||||
51
README.md
51
README.md
@@ -22,32 +22,25 @@ pipeline notes) lives under `docs/`.
|
||||
|
||||
Website + backend (active stack):
|
||||
|
||||
- [Getting Started](docs/website/getting-started.md)
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Database](docs/website/database.md)
|
||||
- [Docker Guide](docs/website/docker.md)
|
||||
- [Testing](docs/website/testing.md)
|
||||
- [Environment Variables](docs/website/environment-variables.md)
|
||||
- [Token Validation](docs/website/token-validation.md)
|
||||
- [Getting Started](docs/web/getting-started.md)
|
||||
- [Architecture](docs/web/architecture.md)
|
||||
- [Database](docs/web/database.md)
|
||||
- [Docker Guide](docs/web/docker.md)
|
||||
- [Testing](docs/web/testing.md)
|
||||
- [Environment Variables](docs/web/environment-variables.md)
|
||||
- [Token Validation](docs/web/token-validation.md)
|
||||
|
||||
Data generation pipeline (C++):
|
||||
|
||||
- [Pipeline README](docs/pipeline/README.md)
|
||||
- [Ethics & Known Issues](docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md)
|
||||
|
||||
## Diagrams
|
||||
|
||||
- [Architecture](docs/website/diagrams-out/architecture.svg)
|
||||
- [Deployment](docs/website/diagrams-out/deployment.svg)
|
||||
- [Authentication Flow](docs/website/diagrams-out/authentication-flow.svg)
|
||||
- [Database Schema](docs/website/diagrams-out/database-schema.svg)
|
||||
|
||||
## Current status
|
||||
|
||||
Active areas in the repository:
|
||||
|
||||
- .NET 10 backend (vertical-slice architecture with MediatR) + SQL Server
|
||||
- React 19 website (React Router 7 + Vite)
|
||||
- React 19 website (React Router 8 + Vite)
|
||||
- Shared Biergarten theme system + Storybook coverage
|
||||
- Auth flows and account/email integration (local Mailpit in dev compose)
|
||||
- Photo upload to S3-compatible object storage (local SeaweedFS in dev compose)
|
||||
@@ -61,7 +54,7 @@ Archived/reference areas:
|
||||
## Tech stack
|
||||
|
||||
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5,
|
||||
- **Frontend**: React 19, React Router 8, Vite 7, Tailwind CSS 4, DaisyUI 5,
|
||||
Leaflet/React Leaflet (brewery maps)
|
||||
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||
@@ -73,15 +66,15 @@ Archived/reference areas:
|
||||
## Requirements
|
||||
|
||||
- .NET SDK 10 or later
|
||||
- Node.js 18 or later
|
||||
- Node.js 22 or later
|
||||
- Docker Desktop, or an equivalent Docker Engine setup
|
||||
|
||||
See [Getting Started](docs/website/getting-started.md) for the full
|
||||
See [Getting Started](docs/web/getting-started.md) for the full
|
||||
prerequisite list, including the pipeline's C++ toolchain.
|
||||
|
||||
## Quick start
|
||||
|
||||
For full setup details, use [Getting Started](docs/website/getting-started.md).
|
||||
For full setup details, use [Getting Started](docs/web/getting-started.md).
|
||||
This section is the shortest path to a working dev environment.
|
||||
|
||||
### Full stack (Docker)
|
||||
@@ -90,10 +83,15 @@ This section is the shortest path to a working dev environment.
|
||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||
cd the-biergarten-app
|
||||
|
||||
cp web/.env.example web/.env.dev
|
||||
cd web && ./generate-env.sh .env.dev && cd ..
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up --build -d
|
||||
```
|
||||
|
||||
`generate-env.sh` copies `.env.example` and replaces the secret-bearing values
|
||||
with freshly randomized ones; a plain `cp` leaves placeholder secrets in
|
||||
place. See [Environment Variables](docs/web/environment-variables.md#generating-an-env-file)
|
||||
for details.
|
||||
|
||||
Access:
|
||||
|
||||
- Website: http://localhost:3000
|
||||
@@ -131,9 +129,8 @@ tooling/
|
||||
pipeline/ C++20 seed-data generation CLI (CMake)
|
||||
|
||||
docs/
|
||||
architecture.md High-level architecture overview
|
||||
website/ Backend/frontend setup, docker, testing, diagrams
|
||||
pipeline/ Pipeline docs, ethics notes, PlantUML diagrams
|
||||
web/ Architecture, setup, docker, testing, diagrams
|
||||
pipeline/ Pipeline docs, ethics notes, diagrams
|
||||
|
||||
archive/
|
||||
next-js-web-app/ Older Next.js frontend (reference only)
|
||||
@@ -145,13 +142,13 @@ Create a test environment file, then run the full test stack (backend and
|
||||
frontend) with Docker:
|
||||
|
||||
```bash
|
||||
cp web/.env.example web/.env.test
|
||||
cd web && ./generate-env.sh .env.test && cd ..
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
||||
```
|
||||
|
||||
The same command runs in CI via
|
||||
[`.github/workflows/tests.yml`](.github/workflows/tests.yml) on every push
|
||||
and pull request to `main`. See [Testing](docs/website/testing.md) for the
|
||||
and pull request to `main`. See [Testing](docs/web/testing.md) for the
|
||||
full command list.
|
||||
|
||||
## Configuration
|
||||
@@ -163,14 +160,14 @@ Common active variables:
|
||||
`WEBSITE_BASE_URL`
|
||||
- Frontend runtime: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
|
||||
|
||||
See [Environment Variables](docs/website/environment-variables.md) for details.
|
||||
See [Environment Variables](docs/web/environment-variables.md) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Run the checks in [Testing](docs/website/testing.md) (backend tests,
|
||||
4. Run the checks in [Testing](docs/web/testing.md) (backend tests,
|
||||
`npm run lint`, `npm run typecheck`)
|
||||
5. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
6. Open a Pull Request
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the active architecture of The Biergarten App.
|
||||
|
||||
## High-level overview
|
||||
|
||||
The Biergarten App is a monorepo with a clear split between the backend and the
|
||||
active website:
|
||||
|
||||
- **Backend**: .NET 10 Web API with SQL Server, organized as vertical feature
|
||||
slices with MediatR
|
||||
- **Frontend**: React 19 + React Router 7 website in `web/frontend`
|
||||
- **Architecture Style**: Vertical-slice backend plus server-rendered React
|
||||
frontend
|
||||
|
||||
The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for
|
||||
reference only.
|
||||
|
||||
## Diagrams
|
||||
|
||||
For visual representations, see:
|
||||
|
||||
- [architecture.svg](website/diagrams-out/architecture.svg) - Vertical-slice
|
||||
architecture diagram
|
||||
- [deployment.svg](website/diagrams-out/deployment.svg) - Docker deployment
|
||||
diagram
|
||||
- [authentication-flow.svg](website/diagrams-out/authentication-flow.svg) -
|
||||
Authentication workflow
|
||||
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
|
||||
relationships
|
||||
|
||||
## Backend architecture
|
||||
|
||||
### Vertical-slice architecture pattern
|
||||
|
||||
The backend organizes business capabilities as feature slices instead of
|
||||
technical layers. Each feature (`Features.Users`, `Features.Breweries`,
|
||||
`Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) is a single
|
||||
project that owns its own MediatR commands/queries/handlers, validators, and
|
||||
repository end to end. `Features.Emails`, `Features.Locations`, and
|
||||
`Features.PhotoUpload` have no `Controllers/` folder — all three are invoked
|
||||
internally by other slices via MediatR/DI, never over HTTP:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ API.Core (thin host) │
|
||||
│ - Program.cs wiring: MediatR + AddApplicationPart per slice │
|
||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
↓ discovers controllers via AddApplicationPart
|
||||
┌────────────────┬───────────────┬──────────────────┬──────────────────┬──────────────────┐
|
||||
│ Features.Users │Features. │ Features.Emails │ Features. │ Features. │
|
||||
│ │Breweries │ (no controller, │ Locations │ PhotoUpload │
|
||||
│ Controllers │ Controller │ internal only) │ (no controller, │ (no controller, │
|
||||
│ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │
|
||||
│ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │
|
||||
│ Handlers │ Handlers │ │ │ Handlers │
|
||||
│ Repository │ Repository │ EmailDispatcher │ │ Repository │
|
||||
└────────────────┴───────────────┴──────────────────┴──────────────────┴──────────────────┘
|
||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||
│ (ResponseBody envelope) │ (ValidationBehavior, │ Domain.Exceptions │
|
||||
│ │ cross-slice email cmds) │ │
|
||||
└─────────────────────────┴─────────────────────────┴────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Database.Connection, Infrastructure.Jwt, Infrastructure.PasswordHashing,│
|
||||
│ Infrastructure.Email, Infrastructure.Email.Templates │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Database (SQL Server) │
|
||||
│ - Tables & constraints │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Slices never reference each other's project. The one cross-slice interaction
|
||||
(`Features.Users` triggering a confirmation email handled by `Features.Emails`)
|
||||
goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
|
||||
lives in `Shared.Application`, so neither slice takes a project reference on the
|
||||
other.
|
||||
|
||||
### Layer responsibilities
|
||||
|
||||
#### API layer (`API.Core`)
|
||||
|
||||
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
|
||||
own
|
||||
|
||||
**Components**:
|
||||
|
||||
- `Program.cs`: registers MediatR (scanning every `Features.*` assembly),
|
||||
FluentValidation, and uses `AddApplicationPart` so each slice's controllers
|
||||
are discovered by MVC
|
||||
- `GlobalException.cs`: global exception filter (host-level cross-cutting
|
||||
concern)
|
||||
- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware
|
||||
- Swagger/OpenAPI documentation, health check endpoints
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- Every `Features.*` project (for controller/MediatR discovery)
|
||||
- `Shared.Contracts`, `Shared.Application`
|
||||
- `Infrastructure.Jwt` (for the auth middleware)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- No controllers, no business logic, no feature-specific contracts
|
||||
- Exists purely to host and wire up the feature slices
|
||||
|
||||
#### Feature slices (`Features.Users`, `Features.Breweries`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`)
|
||||
|
||||
**Purpose**: Each slice is the complete vertical for one business capability
|
||||
|
||||
**Components** (per slice):
|
||||
|
||||
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the
|
||||
request contract
|
||||
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
|
||||
each containing the Command/Query record, its `IRequestHandler`, and (for
|
||||
commands) a FluentValidation validator
|
||||
- `Repository/`: the slice's own Dapper repository implementation
|
||||
- `Dtos/`: response shapes returned by query handlers (never the raw domain
|
||||
entity)
|
||||
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
|
||||
slice's repository/services
|
||||
|
||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||
commands sent from other slices, never over HTTP. `Features.Locations` also has
|
||||
no `Controllers/` folder and no MediatR handlers — it exposes
|
||||
`ILocationRepository` directly as a plain service, currently consumed only by
|
||||
`Database.Seed` to look up cities while seeding brewery locations. Other slices
|
||||
(for example, `Features.Breweries`) do not yet depend on it; they run their own
|
||||
`CityID` existence checks inline. `Features.PhotoUpload` also has no
|
||||
`Controllers/` folder — its `UploadPhotoCommand` is sent by other features'
|
||||
handlers (for example, a future brewery photo upload command) rather than
|
||||
bound to an HTTP route directly. The one exception today is the `/upload-test`
|
||||
minimal API endpoint wired directly in `API.Core/Program.cs`, a temporary route
|
||||
for exercising the upload path end to end.
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- `Domain.Entities`, `Domain.Exceptions`
|
||||
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
|
||||
project the slice needs (`Infrastructure.Jwt`/
|
||||
`Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/
|
||||
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
|
||||
PhotoUpload)
|
||||
- `Shared.Contracts`, `Shared.Application`
|
||||
- **Never** another `Features.*` project
|
||||
|
||||
**Rules**:
|
||||
|
||||
- All business logic for that feature lives in its command/query handlers
|
||||
- No direct controller-to-repository calls; everything flows through MediatR
|
||||
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
||||
|
||||
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
|
||||
|
||||
**Purpose**: The minimum cross-slice surface area required because every slice
|
||||
needs it, or because duplicating it four times would be worse than sharing it
|
||||
|
||||
**Components**:
|
||||
|
||||
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
|
||||
envelope every controller returns
|
||||
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR
|
||||
pipeline behavior that runs FluentValidation before a handler executes) and
|
||||
the cross-slice email commands (`SendRegistrationEmailCommand`,
|
||||
`SendResendConfirmationEmailCommand`)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Kept deliberately small: this is the exception to "no slice depends on another
|
||||
slice," not a general-purpose dumping ground
|
||||
|
||||
#### Infrastructure layer
|
||||
|
||||
**Purpose**: Technical capabilities and external integrations, shared by
|
||||
whichever slices need them
|
||||
|
||||
**Components**:
|
||||
|
||||
- **Database.Connection**: generic ADO.NET connection/command plumbing
|
||||
(`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not
|
||||
domain-specific; each slice's own `Repository/` folder builds on this
|
||||
- **Infrastructure.Jwt**: JWT token generation and validation
|
||||
- **Infrastructure.PasswordHashing**: Argon2id password hashing
|
||||
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
|
||||
- **Infrastructure.Email.Templates**: Email template rendering (Razor
|
||||
components)
|
||||
- **Infrastructure.FileUpload**: `IFileStorageProvider` abstraction over
|
||||
S3-compatible object storage, implemented by `S3FileStorageProvider`
|
||||
(targets the SeaweedFS container in dev; any S3-compatible endpoint in
|
||||
other environments) using the AWS SDK's `AmazonS3Client` with
|
||||
`ForcePathStyle = true`
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- Domain entities
|
||||
- External libraries (ADO.NET, JWT, Argon2, MailKit, AWSSDK.S3, and so on)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Implements technical concerns only, no business logic
|
||||
- Reusable across slices
|
||||
|
||||
#### Domain layer (`Domain.Entities`)
|
||||
|
||||
**Purpose**: Core business entities and models
|
||||
|
||||
**Components**:
|
||||
|
||||
- `UserAccount` - User profile data
|
||||
- `UserCredential` - Authentication credentials
|
||||
- `UserVerification` - Account verification state
|
||||
- `BreweryPost` - A user-submitted brewery listing
|
||||
- `BreweryPostLocation` - A brewery's address and coordinates
|
||||
- `City` - A city referenced by a brewery location
|
||||
- `StateProvince` - A state/province referenced by a city
|
||||
- `Country` - A country referenced by a state/province
|
||||
- `BeerStyle` - A beer style/category (schema table exists; no repository yet)
|
||||
- `BeerPost` - A beer listing tied to a brewery and style (schema table
|
||||
exists; no repository yet)
|
||||
- `Photo` - A photo uploaded by a user account, written via
|
||||
`Features.PhotoUpload`'s `IPhotoUploadRepository`
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- None (pure domain)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Plain Old CLR Objects (POCOs)
|
||||
- No framework dependencies
|
||||
- No infrastructure references
|
||||
- Represents business concepts
|
||||
|
||||
### Design patterns
|
||||
|
||||
#### Vertical slice + MediatR
|
||||
|
||||
**Purpose**: Organize code by feature instead of by technical layer, so
|
||||
everything needed to understand or change one capability lives in one project
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
|
||||
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||
`Query` (for example, `GetBreweryByIdQuery`)
|
||||
- Controllers bind directly to the Command/Query as the request body; there is
|
||||
no separate request DTO + mapping step for writes
|
||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
||||
MediatR pipeline before any handler executes
|
||||
- Query handlers map to a dedicated response `Dto`, so domain entities never
|
||||
leak over the wire
|
||||
|
||||
**Example**:
|
||||
|
||||
```csharp
|
||||
public record CreateBreweryCommand(Guid PostedById, string BreweryName, string Description, ...)
|
||||
: IRequest<BreweryDto>;
|
||||
|
||||
public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||
{
|
||||
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Repository pattern
|
||||
|
||||
**Purpose**: Abstract database access behind interfaces
|
||||
|
||||
**Implementation**: each slice owns its own repository, scoped to that feature
|
||||
only:
|
||||
|
||||
- `Features.Users/Repository/IUserListRepository.cs`,
|
||||
`Features.Users/Repository/IUserProfileRepository.cs`
|
||||
- `Features.Breweries/Repository/IBreweryRepository.cs`
|
||||
- `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs`
|
||||
- `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection
|
||||
factory every slice's repository builds on
|
||||
- `Database.Connection/DapperRepository.cs`: shared abstract base class every
|
||||
repository extends, providing `CreateConnection()` and a
|
||||
`RollbackQuietlyAsync()` helper that rolls back a transaction while
|
||||
swallowing any exception the rollback itself raises, so the exception that
|
||||
triggered the rollback is what propagates
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Testable (easy to mock)
|
||||
- Each slice's data access logic is self-contained
|
||||
|
||||
**Example**:
|
||||
|
||||
```csharp
|
||||
public interface IUserListRepository
|
||||
{
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
}
|
||||
```
|
||||
|
||||
#### Dependency injection
|
||||
|
||||
**Purpose**: Loose coupling and testability
|
||||
|
||||
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
|
||||
`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
|
||||
method that registers its repository and slice-internal services
|
||||
|
||||
**Lifetimes**:
|
||||
|
||||
- Scoped: Repositories, slice-internal services (per request)
|
||||
- Singleton: `DefaultSqlConnectionFactory`
|
||||
- Transient: Utilities, helpers
|
||||
|
||||
#### Direct SQL via Dapper/ADO.NET
|
||||
|
||||
**Purpose**: Keep data access explicit and colocated with the slice that owns
|
||||
it, without an ORM's change-tracking or mapping magic
|
||||
|
||||
**Strategy**:
|
||||
|
||||
- No ORM (Entity Framework not used); each repository issues inline SQL
|
||||
through Dapper, with no manual `IDbCommand` parameter binding. The one
|
||||
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
|
||||
can't deserialize as a UDT: reads select `CONVERT(varbinary(max),
|
||||
Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind.
|
||||
Proximity search (`GET /api/brewery/locations/nearby`) filters and orders
|
||||
rows server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`,
|
||||
avoiding a client-side distance calculation over every row. No spatial
|
||||
index backs the query yet; `schema.sql` sketches one, commented out, for
|
||||
when the table grows large enough to need it
|
||||
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
||||
repository method, run inside the same transaction as the write
|
||||
- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
|
||||
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
|
||||
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
||||
`ConflictException`, ...) directly when a check fails, rather than relying on
|
||||
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
|
||||
exception types to HTTP status codes
|
||||
- Application focuses on orchestration; the database enforces integrity via
|
||||
keys, `CHECK` constraints, and cascades
|
||||
|
||||
See [Database](website/database.md) for the schema and the app's current SQL
|
||||
error-handling approach in more detail.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
### Active website (`web/frontend`)
|
||||
|
||||
The current website is a React Router 7 application with server-side rendering
|
||||
enabled.
|
||||
|
||||
```text
|
||||
web/frontend/
|
||||
├── app/
|
||||
│ ├── components/ Shared UI: Navbar, FormField, SubmitButton, ToastProvider,
|
||||
│ │ RouteErrorState, ClientOnly (defers client-only children,
|
||||
│ │ for example Leaflet maps, past hydration)
|
||||
│ ├── features/ One folder per feature slice, each owning its own
|
||||
│ │ routes/, components/, hooks/, schemas, and server-side
|
||||
│ │ loader helpers:
|
||||
│ │ - account profile/dashboard management
|
||||
│ │ - auth login, register, confirm, logout
|
||||
│ │ - breweries index/directory/detail routes, Leaflet
|
||||
│ │ map components, nearby-search resource
|
||||
│ │ route, distance/address utils
|
||||
│ │ - catalog beers, beer styles
|
||||
│ │ - home landing page
|
||||
│ │ - theme theme switcher/guide
|
||||
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
|
||||
│ ├── routes.ts Route table (React Router 7 config-based routing)
|
||||
│ ├── root.tsx App shell and global providers
|
||||
│ └── app.css Theme tokens and global styling
|
||||
├── .storybook/ Storybook config and preview setup
|
||||
├── stories/ Storybook stories for shared UI and themes
|
||||
├── tests/playwright/ Storybook Playwright coverage
|
||||
└── package.json Frontend scripts and dependencies
|
||||
```
|
||||
|
||||
### Frontend responsibilities
|
||||
|
||||
- Render brewery discovery routes (`/breweries` index, `/breweries/directory`,
|
||||
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
|
||||
search
|
||||
- Render the auth demo, account dashboard, and theme guide routes
|
||||
- Manage cookie-backed website session state
|
||||
- Call the .NET API for login, registration, token refresh, confirmation, and
|
||||
brewery/location data
|
||||
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||
- Supply Storybook documentation and browser-based component verification
|
||||
|
||||
### Theme system
|
||||
|
||||
The active website uses semantic DaisyUI theme tokens backed by four Biergarten
|
||||
themes:
|
||||
|
||||
- Biergarten Lager
|
||||
- Biergarten Stout
|
||||
- Biergarten Cassis
|
||||
- Biergarten Weizen
|
||||
|
||||
All component styling should prefer semantic tokens such as `primary`,
|
||||
`success`, `surface`, and `highlight` instead of hard-coded color values.
|
||||
|
||||
### Legacy frontend
|
||||
|
||||
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
|
||||
for reference only. Active product and engineering documentation should point to
|
||||
`web/frontend`.
|
||||
|
||||
## Security architecture
|
||||
|
||||
### Authentication flow
|
||||
|
||||
1. **Registration**:
|
||||
- User submits credentials
|
||||
- Password hashed with Argon2id
|
||||
- User account created (unverified) and a confirmation email is dispatched
|
||||
via `Features.Emails`
|
||||
|
||||
2. **Email confirmation**:
|
||||
- User follows the confirmation link/token from the email
|
||||
- `Features.Users`' `ConfirmUser` command marks the account verified
|
||||
|
||||
3. **Login**:
|
||||
- User submits credentials
|
||||
- Password verified against hash
|
||||
- Access and refresh JWTs are issued; the refresh token is stored server-side
|
||||
against the user's credential row
|
||||
|
||||
4. **Token refresh**:
|
||||
- Client exchanges a valid, unexpired refresh token for a new access/refresh
|
||||
pair via `Features.Users`' `RefreshToken` command
|
||||
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
|
||||
refresh token is not invalidated and remains usable until its own
|
||||
expiration
|
||||
|
||||
5. **API Requests**:
|
||||
- Client sends the access JWT in the `Authorization` header
|
||||
- `JwtAuthenticationHandler` validates the token
|
||||
- Request proceeds if valid
|
||||
|
||||
### Password security
|
||||
|
||||
**Algorithm**: Argon2id
|
||||
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Parallelism: CPU core count
|
||||
- Salt: 128-bit (16 bytes)
|
||||
- Hash: 256-bit (32 bytes)
|
||||
|
||||
### JWT tokens
|
||||
|
||||
**Algorithm**: HS256 (HMAC-SHA256)
|
||||
|
||||
**Claims**:
|
||||
|
||||
- `sub` - User ID
|
||||
- `unique_name` - Username
|
||||
- `jti` - Unique token ID
|
||||
- `iat` - Issued at timestamp
|
||||
- `exp` - Expiration timestamp
|
||||
|
||||
**Configuration** (appsettings.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"Jwt": {
|
||||
"ExpirationMinutes": 60,
|
||||
"Issuer": "biergarten-api",
|
||||
"Audience": "biergarten-users"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database architecture
|
||||
|
||||
For the table-by-table schema and the custom SQL error code scheme, see
|
||||
[Database](website/database.md).
|
||||
|
||||
### SQL-first philosophy
|
||||
|
||||
**Principles**:
|
||||
|
||||
1. Database is source of truth
|
||||
2. Queries live in the owning repository, not scattered across the app
|
||||
3. Database handles referential integrity
|
||||
4. Application orchestrates, database executes
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Performance optimization via execution plans
|
||||
- Query logic scoped to the repository that owns it, not spread across layers
|
||||
- Version-controlled schema (migrations)
|
||||
- Easier query profiling and tuning
|
||||
|
||||
### Migration strategy
|
||||
|
||||
**Tool**: DbUp
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Write a SQL script under `Database.Migrations/scripts/`
|
||||
2. Embed it in the `Database.Migrations` project
|
||||
3. `Database.Migrations` runs each script against the target database on
|
||||
startup, tracked so a script never re-runs once applied
|
||||
|
||||
**Migration Files**:
|
||||
|
||||
```
|
||||
scripts/
|
||||
└── 01-schema/
|
||||
├── 01-UserAccount.sql
|
||||
├── 02-Photo.sql
|
||||
├── ...
|
||||
├── 16-UserProfile.sql
|
||||
└── 17-UserAvatar.sql # one script per CREATE TABLE
|
||||
```
|
||||
|
||||
Each table has its own script, numbered so a table's foreign keys always point
|
||||
at an earlier-numbered script. As the schema evolves, new numbered scripts get
|
||||
added rather than editing an existing one in place, so DbUp's already-applied
|
||||
tracking stays valid.
|
||||
|
||||
### Data seeding
|
||||
|
||||
**Purpose**: Populate development/test databases with realistic data
|
||||
|
||||
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
||||
|
||||
**Seed Data**:
|
||||
|
||||
- Countries, state/provinces, and cities (via `Features.Locations`'
|
||||
`ILocationRepository`, which creates `Country`/`StateProvince` rows on
|
||||
demand while resolving a city)
|
||||
- User accounts (via `Features.Users`' repository)
|
||||
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||
|
||||
`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`,
|
||||
but `Database.Seed` doesn't call it, so photos aren't part of the seed data.
|
||||
Tables that exist in the schema but have no repository yet (`UserAvatar`,
|
||||
`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
|
||||
`BreweryPostPhoto`) are not seeded either.
|
||||
|
||||
## Deployment architecture
|
||||
|
||||
### Docker containerization
|
||||
|
||||
**Container Structure**:
|
||||
|
||||
- `sqlserver` - SQL Server 2022
|
||||
- `database.migrations` - Schema migration runner
|
||||
- `database.seed` - Data seeder
|
||||
- `api.core` - ASP.NET Core Web API
|
||||
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
|
||||
- `seaweedfs` - S3-compatible object storage for uploaded photos (dev only)
|
||||
|
||||
**Environments**:
|
||||
|
||||
- Development (`docker-compose.dev.yaml`)
|
||||
- Testing (`docker-compose.test.yaml`)
|
||||
- Production (`docker-compose.prod.yaml`)
|
||||
|
||||
For details, see [Docker Guide](website/docker.md).
|
||||
|
||||
### Health checks
|
||||
|
||||
- **SQL Server**: Validates database connectivity
|
||||
- **API**: Checks service health and dependencies
|
||||
|
||||
**Configuration**:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "sqlcmd health check"]
|
||||
interval: 10s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
```
|
||||
|
||||
## Testing architecture
|
||||
|
||||
### Test pyramid
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Integration │ ← API.Specs (Reqnroll)
|
||||
│ Tests │
|
||||
├──────────────┤
|
||||
│ Unit Tests │ ← Features.Users.Tests, Features.Breweries.Tests,
|
||||
│ (per slice, │ Features.Emails.Tests, Features.PhotoUpload.Tests
|
||||
│ handler │ (commands/queries/handlers, with repository/service
|
||||
│ tests) │ dependencies mocked via Moq)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**Strategy**:
|
||||
|
||||
- Many unit tests (fast, isolated)
|
||||
- Fewer integration tests (slower, e2e)
|
||||
- Mock external dependencies
|
||||
- Test database for integration tests
|
||||
|
||||
For details, see [Testing Guide](website/testing.md).
|
||||
33
docs/index.md
Normal file
33
docs/index.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: The Biergarten App
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- overview
|
||||
- index
|
||||
---
|
||||
|
||||
Welcome to the documentation for The Biergarten App.
|
||||
|
||||
## Documentation Sections
|
||||
|
||||
- **[Architecture](web/architecture.md)** — System design and components
|
||||
- **[Website](web/getting-started.md)** — Backend and frontend documentation
|
||||
- **[Pipeline](pipeline/README.md)** — Data generation pipeline
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
Parts of this documentation are drafted and maintained with AI assistance
|
||||
(Claude, Sonnet or Opus, via Claude Code), including prose (architecture
|
||||
notes, guides) and diagrams (the Mermaid sources under `diagrams/`).
|
||||
AI-authored changes are reviewed by a maintainer before merging, same as any
|
||||
other contribution.
|
||||
|
||||
Staleness checks are partly automated too, to guard against doc-rot: the
|
||||
repo's `doc-drift-web` and `doc-drift-pipeline` Claude Code skills
|
||||
(`.claude/skills/`) diff recent code changes against these docs and flag
|
||||
claims that may need updating.
|
||||
|
||||
This is separate from the pipeline's own use of AI to *generate seed data*
|
||||
(brewery names, descriptions, user profiles) — see
|
||||
[ETHICS-AND-KNOWN-ISSUES.md](pipeline/ETHICS-AND-KNOWN-ISSUES.md) for that
|
||||
disclosure.
|
||||
43
docs/makefile
Normal file
43
docs/makefile
Normal file
@@ -0,0 +1,43 @@
|
||||
# Makefile for rendering all Mermaid (.mmd) diagrams to SVG,
|
||||
# preserving the source directory structure under OUT_ROOT.
|
||||
#
|
||||
# Usage:
|
||||
# make # render all diagrams
|
||||
# make clean # remove all rendered output
|
||||
# make FORMAT=png # render as PNG instead of SVG
|
||||
# make FORMAT=pdf # render as PDF instead of SVG
|
||||
#
|
||||
# Requires: @mermaid-js/mermaid-cli (mmdc) on PATH
|
||||
# npm install -g @mermaid-js/mermaid-cli
|
||||
|
||||
SRC_ROOT := .
|
||||
OUT_ROOT := rendered-diagrams
|
||||
FORMAT := svg
|
||||
|
||||
# Optional: point to a shared Mermaid config/theme file if you have one.
|
||||
# CONFIG := ./mermaidconfig.json
|
||||
# CONFIG_FLAG := -c $(CONFIG)
|
||||
CONFIG_FLAG :=
|
||||
|
||||
# Find every .mmd file under SRC_ROOT, excluding the output directory itself.
|
||||
SRC_FILES := $(shell find $(SRC_ROOT) -type f -name "*.mmd" -not -path "./$(OUT_ROOT)/*")
|
||||
|
||||
# Map each source file to its corresponding output file, preserving subdirectories.
|
||||
OUT_FILES := $(patsubst $(SRC_ROOT)/%.mmd,$(OUT_ROOT)/%.$(FORMAT),$(SRC_FILES))
|
||||
|
||||
.PHONY: all clean list
|
||||
|
||||
all: $(OUT_FILES)
|
||||
|
||||
# Pattern rule: build any output file from its matching .mmd source file,
|
||||
# creating the destination subdirectory as needed.
|
||||
$(OUT_ROOT)/%.$(FORMAT): $(SRC_ROOT)/%.mmd
|
||||
@mkdir -p $(dir $@)
|
||||
mmdc $(CONFIG_FLAG) -i $< -o $@
|
||||
|
||||
clean:
|
||||
rm -rf $(OUT_ROOT)
|
||||
|
||||
# Debug helper: print which source files were discovered.
|
||||
list:
|
||||
@echo "$(SRC_FILES)" | tr ' ' '\n'
|
||||
@@ -1,4 +1,13 @@
|
||||
# Ethics, bias, and known issues
|
||||
---
|
||||
title: Pipeline ethics, bias, and known issues
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- pipeline
|
||||
- ethics
|
||||
- bias
|
||||
- known-issues
|
||||
- ai-generated
|
||||
---
|
||||
|
||||
This document covers the ethical context of the Biergarten Pipeline's output,
|
||||
the model's biases, and known issues including hallucinated brewing science and
|
||||
@@ -47,7 +56,7 @@ as a source of truth for brewing techniques or cultural representation.**
|
||||
|
||||
**Natural language processing is a powerful tool for data analysis and
|
||||
generation, but its output should be treated with scrutiny. Human language is
|
||||
not simply data points to be analyzed — it carries cultural and human meaning
|
||||
not simply data points to be analyzed: it carries cultural and human meaning
|
||||
that artificial intelligence cannot capture.**
|
||||
|
||||
---
|
||||
@@ -102,7 +111,7 @@ vendored verbatim, unmodified, from
|
||||
(the `common-forenames-by-country.json` / `common-surnames-by-country.json`
|
||||
release assets), released under **CC0** (public domain). That dataset's own
|
||||
forename/surname lists are pulled from Wikipedia's "Lists of most common
|
||||
surnames" and "List of most popular given names" as of the week of 2023-07-08 —
|
||||
surnames" and "List of most popular given names" as of the week of 2023-07-08;
|
||||
see that project's README for full provenance. Names are not LLM-generated; this
|
||||
is curated fixture data per ROADMAP.md §2. Per-forename gender from the source
|
||||
data is preserved through to the sampled `Name` (rather than discarded during
|
||||
@@ -113,7 +122,7 @@ for surnames) rather than trimmed to `locations.json`'s current country list, so
|
||||
it doesn't need re-sourcing if more countries are added later. `SampleName()` (a
|
||||
free helper in `generate_users.cc`) returns no result for a country present in
|
||||
neither file; of the countries in `locations.json`, that's currently `KE`, `SE`,
|
||||
`SG`, `TH`, `VN`, and `ZA` — `GenerateUsers` skips cities in those countries the
|
||||
`SG`, `TH`, `VN`, and `ZA`; `GenerateUsers` skips cities in those countries the
|
||||
same way brewery generation skips cities whose enrichment lookup fails.
|
||||
|
||||
---
|
||||
@@ -290,11 +299,20 @@ Each brewery and user address carries a longitude/latitude pair, sampled
|
||||
uniformly at random within a 5 km disc centred on the city's curated
|
||||
`latitude`/`longitude` from `locations.json`.
|
||||
|
||||
The resulting point is **plausible by construction** — it always falls within
|
||||
the city's vicinity — but it is not looked up from any real address registry.
|
||||
It does not correspond to a real address, business, or resident, and should
|
||||
not be treated as accurate geocoding data. This is consistent with the rest of
|
||||
the dataset: fixture data for a proof-of-concept, not a source of truth.
|
||||
The coordinate pair itself is **plausible by construction**, always falling
|
||||
within the city's vicinity, but does not correspond to a real business or
|
||||
resident, and should not be treated as accurate geocoding data. This is
|
||||
consistent with the rest of the dataset: fixture data for a proof-of-concept,
|
||||
not a source of truth.
|
||||
|
||||
For brewery addresses specifically (not user addresses, which carry no street
|
||||
address at all), `address_line1`/`postal_code` _are_ looked up from a real
|
||||
address registry — `NominatimAddressService` reverse-geocodes the synthetic
|
||||
coordinate pair against the live OpenStreetMap/Nominatim database, so the street
|
||||
address is a genuine nearby address, just not the address of an actual brewery.
|
||||
This lookup only happens for non-`--mocked` runs; `--mocked` runs use
|
||||
`MockAddressService`'s fixed placeholder address instead, which is not looked up
|
||||
from any real registry.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
# Biergarten Pipeline
|
||||
---
|
||||
title: Biergarten Pipeline — C++ CLI for generating seed data
|
||||
last-updated: 2026-09-02
|
||||
tags:
|
||||
- pipeline
|
||||
- cpp
|
||||
- llm
|
||||
- seed-data
|
||||
- docker
|
||||
---
|
||||
|
||||
A C++20 command-line pipeline that samples city records from local JSON,
|
||||
enriches each with Wikipedia context, and generates bilingual brewery names and
|
||||
@@ -7,12 +16,12 @@ deterministic mock.
|
||||
|
||||
> **This pipeline produces AI-generated data.** It is not a source of truth for
|
||||
> brewing techniques, cultural representation, or local-language accuracy. See
|
||||
> [ETHICS-AND-KNOWN-ISSUES.md](./ETHICS-AND-KNOWN-ISSUES.md) for a full
|
||||
> [ETHICS-AND-KNOWN-ISSUES.md](./ETHICS-AND-KNOWN-ISSUES.md) for full
|
||||
> documentation of limitations, hallucination patterns, and bias.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
## Table of contents
|
||||
|
||||
- [How it fits the main app](#how-it-fits-the-main-app)
|
||||
- [Quick start](#quick-start)
|
||||
@@ -101,25 +110,27 @@ Each run writes a fresh dated SQLite file such as
|
||||
|
||||
#### CLI flags
|
||||
|
||||
| Flag | Purpose |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `--mocked` | Deterministic mock generator, no model required. |
|
||||
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
||||
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
||||
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
||||
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
|
||||
| `--help, -h` | Print usage and exit. |
|
||||
| Flag | Purpose |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `--mocked` | Deterministic mock generator, no model required. |
|
||||
| `--model, -m` | Path to a GGUF file. Selects the local Llama.cpp generator. |
|
||||
| `--openai` | Use the OpenAI API (Chat Completions) for brewery/user data. Requires the `OPENAI_API_KEY` environment variable. |
|
||||
| `--openai-model` | OpenAI model ID used when `--openai` is set. Default: `gpt-4o-mini`. |
|
||||
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
||||
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
||||
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
|
||||
| `--help, -h` | Print usage and exit. |
|
||||
|
||||
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an
|
||||
error before the pipeline starts. Sampling flags are ignored when `--mocked` is
|
||||
set.
|
||||
`--mocked`, `--model`, and `--openai` are mutually exclusive. Omitting all three
|
||||
exits with an error before the pipeline starts. Sampling flags are ignored
|
||||
unless `--model` is set (Llama).
|
||||
|
||||
The post-build step copies `prompts/` into `build/prompts/`. Rebuild after
|
||||
editing any prompt file.
|
||||
@@ -176,7 +187,7 @@ output to confirm the fast path was taken.
|
||||
### Run the container
|
||||
|
||||
The container always runs the model-backed path; there is no `--mocked`
|
||||
container mode (use a native build for that — see [Quick start](#quick-start)).
|
||||
container mode (use a native build for that; see [Quick start](#quick-start)).
|
||||
The entrypoint, `runpod/start.sh`, downloads the GGUF model automatically if it
|
||||
is not already present at the configured path.
|
||||
|
||||
@@ -191,7 +202,7 @@ docker run --rm \
|
||||
|
||||
By default this downloads `google_gemma-4-E4B-it-Q6_K.gguf` to `./models/` on
|
||||
first run if it isn't already there. To use a pre-downloaded model, place it at
|
||||
that path first — see [Model](#model) above.
|
||||
that path first; see [Model](#model).
|
||||
|
||||
#### Environment variables
|
||||
|
||||
@@ -211,8 +222,9 @@ configurable via environment variable.
|
||||
|
||||
Use a GPU pod template. Mount persistent storage for `/workspace/models`,
|
||||
`/workspace/output`, and `/workspace/logs`. See
|
||||
`tooling/pipeline/runpod/pod-template.yaml` for a starter template — set the
|
||||
environment variables listed above to match your run.
|
||||
`tooling/pipeline/runpod/pod-template.yaml` for a starter template; set the
|
||||
environment variables listed in [Environment variables](#environment-variables)
|
||||
to match your run.
|
||||
|
||||
---
|
||||
|
||||
@@ -226,8 +238,8 @@ environment variables listed above to match your run.
|
||||
| Sample | `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()` samples `--location-count` locations per run (default `10`). |
|
||||
| Enrich | `WikipediaEnrichmentService` fetches brewing and beer-related context. Keeps going when a lookup fails. `--mocked` runs use `MockEnrichmentService` instead and skip Wikipedia entirely. |
|
||||
| Generate Users | `GenerateUsers()` samples a persona and a forename/surname pair per enriched city (skipping countries with no name data), then `MockGenerator` or `LlamaGenerator` produces a username, bio, and activity weight around the sampled name. |
|
||||
| Generate Breweries | `MockGenerator` or `LlamaGenerator` produces brewery names and descriptions in English and the local language. |
|
||||
| Store | `SqliteExportService` writes each successful user and brewery into a fresh dated `.sqlite` database with normalized `locations`, `users`, and `breweries` tables. |
|
||||
| Generate Breweries | `MockGenerator`, `LlamaGenerator`, or `OpenAIGenerator` produces brewery names and descriptions in English and the local language, then `IAddressService::ReverseGeocode()` resolves a street address for the brewery's jittered coordinates. |
|
||||
| Store | `SqliteExportService` writes each successful user and brewery into a fresh dated `.sqlite` database with normalized `cities`, `breweries`, `brewery_addresses`, `users`, and `user_addresses` tables. |
|
||||
| Log | `spdlog` writes results and warnings to the console. |
|
||||
|
||||
If name sampling, enrichment, or generation fails for a city, that city is
|
||||
@@ -236,67 +248,36 @@ skipped and the pipeline continues. `GenerateUsers()` runs before
|
||||
|
||||
### Key components
|
||||
|
||||
- `src/main.cc` — argument parsing and Boost.DI composition root.
|
||||
- `CuratedJsonDataService` — implements `ICuratedDataService`; takes a
|
||||
`CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in
|
||||
its constructor, then parses and validates curated location, persona, and
|
||||
forename/surname JSON, memoizing each result after its first load on a given
|
||||
instance. `MockCuratedDataService` is the in-memory substitute (4 fixed
|
||||
locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in
|
||||
`--mocked` runs.
|
||||
- `WikipediaEnrichmentService` — queries Wikipedia extracts, caches results,
|
||||
returns empty context on failure. `MockEnrichmentService` is the no-op
|
||||
substitute used in `--mocked` runs.
|
||||
- `LlamaGenerator` — formats prompts for Gemma 4, validates JSON output for both
|
||||
`GenerateBrewery` and `GenerateUser`, retries malformed responses up to three
|
||||
times with corrective feedback in the retry prompt. The token budget is fixed
|
||||
across attempts; it is not raised automatically on truncation.
|
||||
- `MockGenerator` — stable hash-based output so the same city/persona/name input
|
||||
always produces the same brewery or user.
|
||||
- `SqliteExportService` — creates a dated SQLite file per run and persists each
|
||||
successful user and brewery into normalized tables.
|
||||
- Brewery payloads include English and local-language name and description
|
||||
fields. User payloads carry a sampled first/last name and gender, an
|
||||
LLM-generated username/bio/activity weight, and a programmatically generated
|
||||
(not LLM-authored) unique email and date of birth.
|
||||
| Component | Description |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main.cc` | Argument parsing and the Boost.DI composition root. |
|
||||
| `CuratedJsonDataService` | Implements `ICuratedDataService`; takes a `CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in its constructor, parses and validates curated location, persona, and forename/surname JSON, and memoizes each result after its first load on a given instance. `MockCuratedDataService` is the in-memory substitute (4 fixed locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in `--mocked` runs. |
|
||||
| `WikipediaEnrichmentService` | Queries Wikipedia extracts, caches results, and returns empty context on failure. `MockEnrichmentService` is the no-op substitute used in `--mocked` runs. |
|
||||
| `LlamaGenerator` | Formats prompts for Gemma 4, validates JSON output for both `GenerateBrewery` and `GenerateUser`, and retries malformed responses up to three times with corrective feedback in the retry prompt. The token budget is fixed across attempts; it is not raised automatically on truncation. |
|
||||
| `MockGenerator` | Uses stable hash-based output so the same city/persona/name input always produces the same brewery or user. |
|
||||
| `NominatimAddressService` | Reverse-geocodes each brewery's jittered coordinates into a street address (`address_line1`, `postal_code`) via the public [Nominatim API](https://nominatim.openstreetmap.org), respecting its one-request-per-second usage policy. Falls back to a placeholder `address_line1` ("Address unavailable") and empty `postal_code` when the lookup fails or returns no usable street address. `MockAddressService` is the no-network substitute (`"123 Mock Street"`, `"00000"`) used in `--mocked` runs. Only breweries get a resolved street address; generated users carry a coordinate pair with no address fields. |
|
||||
| `SqliteExportService` | Creates a dated SQLite file per run and persists each successful user and brewery into normalized tables. |
|
||||
| Brewery/user payloads | Brewery payloads include English and local-language name and description fields. User payloads carry a sampled first/last name and gender, an LLM-generated username/bio/activity weight, and a programmatically generated (not LLM-authored) unique email and date of birth. |
|
||||
|
||||
### Runtime behaviour
|
||||
|
||||
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
|
||||
"brewing" extract and a "beer in `{country}`" extract. It does not currently
|
||||
query a city- or region-specific page. Each query string is cached after its
|
||||
first successful (or empty) lookup.
|
||||
|
||||
`GetLocationContext()` returns an empty string when the web client is
|
||||
unavailable or when lookup/parsing fails.
|
||||
|
||||
`LlamaGenerator` validates model output as structured JSON. On validation
|
||||
failure it retries up to three times, replaying the previous error message in
|
||||
the next prompt so the model can self-correct. All runs to date have produced
|
||||
valid output on the first pass; the retry path is kept for resilience.
|
||||
|
||||
`MockGenerator` uses stable hashes for repeatable output in demos and Storybook
|
||||
runs.
|
||||
|
||||
`CuratedJsonDataService` memoizes each of `LoadLocations()`, `LoadPersonas()`,
|
||||
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the
|
||||
first time each is called, since `BiergartenPipelineOrchestrator` owns a single
|
||||
`ICuratedDataService` instance for the whole run — later calls return the cached
|
||||
result instead of re-parsing.
|
||||
|
||||
`GenerateUsers()` samples a forename/surname pair per city via `SampleName()`,
|
||||
keyed by the city's ISO 3166-1 code. Countries present in `locations.json` but
|
||||
absent from either name fixture (currently `KE`, `SE`, `SG`, `TH`, `VN`, `ZA`)
|
||||
are skipped, the same way a failed enrichment or generation call skips a city —
|
||||
see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section.
|
||||
| Component / behavior | Details |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `WikipediaEnrichmentService` | Fetches two Wikipedia extracts per city: a generic "brewing" extract and a "beer in `{country}`" extract. It does not currently query a city- or region-specific page. Each query string is cached after its first successful (or empty) lookup. |
|
||||
| `GetLocationContext()` | Returns an empty string when the web client is unavailable or when lookup/parsing fails. |
|
||||
| `LlamaGenerator` | Validates model output as structured JSON. On validation failure it retries up to three times, replaying the previous error message in the next prompt so the model can self-correct. All runs to date have produced valid output on the first pass; the retry path is kept for resilience. |
|
||||
| `MockGenerator` | Uses stable hashes for repeatable output in demos and Storybook runs. |
|
||||
| `NominatimAddressService.ReverseGeocode()` | Sleeps one second after every request to respect Nominatim's usage policy, and returns `std::nullopt` (logged as a warning, not a skipped city) if the request fails, the response has no `address` object, or neither `house_number` nor `road` is present — the brewery is still generated and exported, just with a placeholder address. |
|
||||
| `CuratedJsonDataService` | Memoizes each of `LoadLocations()`, `LoadPersonas()`, `LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the first time each is called, since `BiergartenPipelineOrchestrator` owns a single `ICuratedDataService` instance for the whole run; later calls return the cached result instead of re-parsing. |
|
||||
| `GenerateUsers()` | Samples a forename/surname pair per city via `SampleName()`, keyed by the city's ISO 3166-1 code. Countries present in `locations.json` but absent from either name fixture (currently `KE`, `SE`, `SG`, `TH`, `VN`, `ZA`) are skipped, the same way a failed enrichment or generation call skips a city; see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section. |
|
||||
|
||||
### Process flow - activity diagram
|
||||
|
||||

|
||||
See [Activity diagram](./diagrams/current/activity.md).
|
||||
|
||||
### Architectural overview - class diagram
|
||||
|
||||

|
||||
See [Class diagram](./diagrams/current/class.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -307,12 +288,14 @@ Each successful run stores a `BreweryRecord` pair with the source location and a
|
||||
`UserResult` payload. The same generated records are also written to a fresh
|
||||
SQLite export file named with the current UTC timestamp.
|
||||
|
||||
| Field | Meaning |
|
||||
| ------------------- | ------------------------------------------ |
|
||||
| `name_en` | Brewery name in English. |
|
||||
| `description_en` | Brewery description in English. |
|
||||
| `name_local` | Brewery name in the local language. |
|
||||
| `description_local` | Brewery description in the local language. |
|
||||
| Field | Meaning |
|
||||
| ------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `name_en` | Brewery name in English. |
|
||||
| `description_en` | Brewery description in English. |
|
||||
| `name_local` | Brewery name in the local language. |
|
||||
| `description_local` | Brewery description in the local language. |
|
||||
| `address_line1` | Street address resolved via reverse geocoding, or a placeholder if resolution failed. |
|
||||
| `postal_code` | Postal code resolved via reverse geocoding, or empty if resolution failed. |
|
||||
|
||||
| Field | Meaning |
|
||||
| ----------------- | -------------------------------------------------------------------------------------- |
|
||||
@@ -357,15 +340,14 @@ The build fetches Boost.DI, spdlog, and SQLite via CMake. llama.cpp is fetched
|
||||
only when a system installation is not detected. Metal is enabled on Apple
|
||||
Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
||||
|
||||
> **Code Style:** Code style generally follows the Google Style Guide and
|
||||
> features modern C++20 throughout. Formatting follows the Google C++ Style
|
||||
> Guide, with the exception of indentation width (3 spaces vs. Google's 2).
|
||||
> **Code style:** The code follows the Google C++ Style Guide, except that
|
||||
> indentation is 3 spaces instead of Google's 2, and uses C++20 throughout.
|
||||
|
||||
---
|
||||
|
||||
## Tested hardware
|
||||
|
||||
### ARM macOS — M1 Pro
|
||||
### ARM macOS (M1 Pro)
|
||||
|
||||
| | |
|
||||
| --------- | --------------------------------- |
|
||||
@@ -376,7 +358,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
||||
| Model | Gemma 4 E4B |
|
||||
| Inference | llama.cpp with Metal |
|
||||
|
||||
### x86_64 Linux — NVIDIA RTX 2000
|
||||
### x86_64 Linux (NVIDIA RTX 2000)
|
||||
|
||||
| | |
|
||||
| --------- | ------------------------------ |
|
||||
@@ -387,7 +369,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
||||
| Model | Gemma 4 E4B |
|
||||
| Inference | llama.cpp with CUDA 12.x |
|
||||
|
||||
### x86_64 Linux — Docker / RunPod (NVIDIA CUDA)
|
||||
### x86_64 Linux (Docker / RunPod, NVIDIA CUDA)
|
||||
|
||||
| | |
|
||||
| --------- | ------------------------------------------- |
|
||||
@@ -407,7 +389,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
||||
- Keep `locations.json` structured enough to support discovery and future
|
||||
filtering.
|
||||
- `personas.json`, `forenames-by-country.json`, and `surnames-by-country.json`
|
||||
are curated/vendored fixture data, not LLM-generated — see
|
||||
are curated/vendored fixture data, not LLM-generated; see
|
||||
ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section for provenance.
|
||||
- Treat SQLite output as seed material for the app's brewery and user domains,
|
||||
not production data.
|
||||
@@ -435,30 +417,32 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
||||
|
||||
Paths below are relative to `tooling/pipeline/`.
|
||||
|
||||
- `src/main.cc` — argument parsing and DI composition root.
|
||||
- `src/biergarten_pipeline_orchestrator/` — orchestration, sampling, logging,
|
||||
and export.
|
||||
- `src/services/curated_data/` — `CuratedJsonDataService`, the file-backed
|
||||
`ICuratedDataService`, and `MockCuratedDataService`, the in-memory
|
||||
`ICuratedDataService` used in `--mocked` runs.
|
||||
- `src/services/enrichment/wikipedia/` — enrichment service and cache.
|
||||
- `src/services/sqlite/` — SQLite export implementation.
|
||||
- `src/data_generation/llama/` — local inference, prompt loading, output
|
||||
validation.
|
||||
- `src/data_generation/mock/` — deterministic fallback.
|
||||
- `runpod/` — container build and runtime launcher.
|
||||
| Path | Purpose |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main.cc` | Argument parsing and DI composition root. |
|
||||
| `src/biergarten_pipeline_orchestrator/` | Orchestration, sampling, logging, and export. |
|
||||
| `src/services/curated_data/` | `CuratedJsonDataService`, the file-backed `ICuratedDataService`, and `MockCuratedDataService`, the in-memory `ICuratedDataService` used in `--mocked` runs. |
|
||||
| `src/services/enrichment/wikipedia/` | Enrichment service and cache. |
|
||||
| `src/services/sqlite/` | SQLite export implementation. |
|
||||
| `src/data_generation/llama/` | Local inference, prompt loading, and output validation. |
|
||||
| `src/data_generation/mock/` | Deterministic fallback. |
|
||||
| `runpod/` | Container build and runtime launcher. |
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
The pipeline currently produces city-aware brewery and user records and dated
|
||||
SQLite exports. The next passes add additional fixture types so the app can
|
||||
exercise the full brewery and social domains without live data. For the detailed
|
||||
engineering breakdown of what's needed to reach the architecture in
|
||||
[`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md).
|
||||
SQLite exports.
|
||||
|
||||
### Testing — very high priority
|
||||
The next passes add additional fixture types so the app can exercise the full
|
||||
brewery and social domains without live data.
|
||||
|
||||
For the detailed engineering breakdown of what's needed to reach the
|
||||
architecture in [`diagrams/planned/`](./diagrams/planned/), see
|
||||
[ROADMAP.md](./ROADMAP.md).
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit test JSON validation and retry logic against malformed, truncated, and
|
||||
empty model outputs.
|
||||
@@ -481,7 +465,7 @@ enough to exercise search, sort, and category filters.
|
||||
### Check-in system
|
||||
|
||||
Produce timestamped check-in events between users and breweries. Use a J-curve
|
||||
activity profile — a small set of users accounts for most check-ins, the rest
|
||||
activity profile: a small set of users accounts for most check-ins, the rest
|
||||
appear occasionally. Add bursty behaviour around weekends and travel periods.
|
||||
|
||||
### Beer ratings
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
# Pipeline Roadmap — Reaching the Planned Architecture
|
||||
---
|
||||
title: Pipeline roadmap — reaching the planned architecture
|
||||
last-updated: 2026-09-01
|
||||
tags:
|
||||
- pipeline
|
||||
- roadmap
|
||||
- cpp
|
||||
- concurrency
|
||||
---
|
||||
|
||||
This is the engineering breakdown for closing the gap between the **current**
|
||||
pipeline and the **planned** architecture documented in
|
||||
[`diagrams/planned/class.puml`](./diagrams/planned/class.puml) and
|
||||
[`diagrams/planned/activity.puml`](./diagrams/planned/activity.puml). Nothing in
|
||||
`diagrams/planned/` is implemented yet — this file tracks what it would take to
|
||||
get there. For the current implementation, see [README.md](./README.md).
|
||||
Engineering breakdown for closing the gap between the **current** pipeline and
|
||||
the **planned** architecture in
|
||||
[`diagrams/planned/class.md`](./diagrams/planned/class.md) /
|
||||
[`diagrams/planned/activity.md`](./diagrams/planned/activity.md). Items are
|
||||
grouped by layer, roughly by dependency order. For the current implementation,
|
||||
see [README.md](./README.md).
|
||||
|
||||
Items are grouped by layer and roughly ordered by dependency: later groups build
|
||||
on types and services introduced earlier.
|
||||
|
||||
The only concurrency in the current pipeline is the log dispatcher thread
|
||||
(`main.cc` spawns it to drain a `BoundedChannel<LogEntry>` into spdlog for the
|
||||
whole run). Sampling, enrichment, generation, and export all happen
|
||||
synchronously on the main thread inside `BiergartenPipelineOrchestrator::Run()`
|
||||
— §6 below is what introduces the additional worker threads the planned
|
||||
architecture needs.
|
||||
The only concurrency today is a log dispatcher thread draining a
|
||||
`BoundedChannel<LogEntry>` into spdlog; sampling, enrichment, generation, and
|
||||
export run synchronously on the main thread — §6 introduces the planned worker
|
||||
threads.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,222 +26,183 @@ architecture needs.
|
||||
|
||||
`tooling/pipeline/includes/data_model/generated_models.h` and `models.h`.
|
||||
|
||||
- [ ] Add `Completeness` enum (`Full`, `Partial`, `Absent`) and a
|
||||
`LocationContext` struct (`text`, `completeness`, `char_count`). Replace
|
||||
`EnrichedCity::region_context` (currently a plain `std::string`) with
|
||||
`context : LocationContext`.
|
||||
- [ ] Add `Completeness` enum (`Full`/`Partial`/`Absent`) and a
|
||||
`LocationContext` struct (`text`, `completeness`, `char_count`); replace
|
||||
`EnrichedCity::region_context` (a plain `string` today) with it.
|
||||
- [ ] Add `BeerStyle` (`name`, `description`, `min_abv`, `max_abv`, `min_ibu`,
|
||||
`max_ibu`).
|
||||
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` result payloads.
|
||||
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` payloads.
|
||||
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
|
||||
`context_provided`, `generated_with`).
|
||||
- [x] Add `first_name`, `last_name`, `gender`, and `activity_weight` to
|
||||
`UserResult` (currently just `username`, `bio`). `first_name`/
|
||||
`last_name`/`gender` are copied from the sampled `Name` (see below), not
|
||||
- [x] `UserResult` gained `first_name`/`last_name`/`gender`/`activity_weight`
|
||||
(was just `username`/`bio`), copied from the sampled `Name`, not
|
||||
LLM-invented.
|
||||
- [x] Add `Name` (`first_name`, `last_name`, `gender`) — the sampled result
|
||||
handed to `DataGenerator::GenerateUser`. Add `ForenameEntry` (`name`,
|
||||
`gender`). Landed as a flatter shape than originally planned here: no
|
||||
`NamesByCountry` wrapper class. `curated_data_service.h` instead declares
|
||||
`ForenameList = std::vector<ForenameEntry>` and
|
||||
`SurnameList = std::vector<std::string>`, and `ICuratedDataService`
|
||||
exposes two maps keyed by ISO 3166-1 code —
|
||||
`ForenamesByCountryMap = unordered_map<string, ForenameList>` and
|
||||
`SurnamesByCountryMap = unordered_map<string, SurnameList>` — directly
|
||||
(see §2). Sampling is a free function,
|
||||
`SampleName(forenames_by_country, surnames_by_country, iso3166_1, rng)`,
|
||||
in `generate_users.cc`'s anonymous namespace, not a method on a class.
|
||||
Pairing a forename with a surname happens at sample time so gender
|
||||
(present per-forename in the source data) is never discarded the way a
|
||||
pre-flattened `{first_name, last_name}` list would lose it; see §2.
|
||||
- [x] Added `Name` (`first_name`, `last_name`, `gender`) and `ForenameEntry`
|
||||
(`name`, `gender`). Flatter than planned: no `NamesByCountry` wrapper —
|
||||
`ICuratedDataService` exposes `ForenamesByCountryMap`/
|
||||
`SurnamesByCountryMap` (keyed by ISO 3166-1) directly, and sampling is a
|
||||
free function, `SampleName(forenames, surnames, iso3166_1, rng)`, not a
|
||||
class method.
|
||||
- [ ] Extend `GeneratedBrewery` with `brewery_id`, `context_completeness`,
|
||||
`metadata` (currently just `{location, brewery}`).
|
||||
- [ ] Add `GeneratedBeer`, `GeneratedCheckin`, `GeneratedRating`,
|
||||
`GeneratedFollow` aggregate structs.
|
||||
- [x] Add `UserRecord` (`location`, `user : UserResult`, `email`,
|
||||
`date_of_birth`) as the current stand-in for the planned `GeneratedUser` —
|
||||
`email` and `date_of_birth` are programmatically generated by the
|
||||
orchestrator, never LLM-authored, so a downstream auth-account seeding
|
||||
consumer can register real accounts from the pipeline's SQLite export. No
|
||||
`password`, `user_id`, or `metadata` field yet, matching `BreweryRecord`'s
|
||||
equally pre-`metadata` shape. Already wired into
|
||||
`IExportService`/`SqliteExportService`: a `users` table exists and
|
||||
`GenerateUsers()` exports each successful record via
|
||||
`ProcessRecord(const UserRecord&)` (see §5) in addition to logging and
|
||||
holding `generated_users_` in memory. Still missing vs. the planned
|
||||
`GeneratedUser`: `user_id`, `password`, and `metadata`.
|
||||
- [x] Add `UserPersona` (`name`, `description`, `style_affinities`).
|
||||
- [x] Added `UserRecord` (`location`, `user`, `email`, `date_of_birth`), the
|
||||
current stand-in for the planned `GeneratedUser`. `email`/ `date_of_birth`
|
||||
are programmatic (never LLM-authored), for downstream auth-account
|
||||
seeding. Missing `user_id`/`password`/`metadata`; already exported via
|
||||
`ProcessRecord(const UserRecord&)` (§5).
|
||||
- [x] Added `UserPersona` (`name`, `description`, `style_affinities`).
|
||||
|
||||
## 2. Data Preloading
|
||||
|
||||
`tooling/pipeline/includes/services/curated_data/`, fixture files.
|
||||
|
||||
- [x] Extract an interface; have `CuratedJsonDataService` implement it. Landed
|
||||
as `ICuratedDataService` (`services/curated_data/curated_data_service.h`),
|
||||
not `DataPreloader` — `LoadLocations()`, `LoadPersonas()`,
|
||||
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` are all virtual
|
||||
methods returning `const&` and take no arguments. `CuratedJsonDataService`
|
||||
(`services/curated_data/curated_json_data_service.h`, originally landed as
|
||||
`JsonLoader` under `json_handling/` before being renamed and moved) takes
|
||||
a `CuratedDataFilePaths` DTO (`locations_path`, `personas_path`,
|
||||
`forenames_path`, `surnames_path`) in its constructor rather than a path
|
||||
per `Load*()` call, and memoizes each result in a private `cache` struct
|
||||
(`locations`, `personas`, `forenames_by_country`, `surnames_by_country`),
|
||||
so a second call on the same instance returns the cached result instead of
|
||||
re-parsing — safe because `CuratedJsonDataService` outlives every call
|
||||
site (owned by `BiergartenPipelineOrchestrator` via
|
||||
`unique_ptr<ICuratedDataService>` for the whole run). `main.cc`'s DI
|
||||
injector also gained a `MockCuratedDataService` (fixed in-memory data: 4
|
||||
locations across `US`/`DE`/`FR`/`BE`, 3 personas, and matching
|
||||
forename/surname sets for those four countries), bound in place of
|
||||
`CuratedJsonDataService` under `--mocked`, mirroring the existing
|
||||
`MockEnrichmentService`/`MockGenerator` pattern.
|
||||
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` already exists in the repo and
|
||||
is already copied into the Docker image (`runpod/Dockerfile`), but no
|
||||
loader reads it yet, and the native CMake build doesn't copy it into
|
||||
`build/` at all — `CMakeLists.txt`'s "Runtime Assets" step only copies
|
||||
`locations.json` and `prompts/`.
|
||||
- [x] Add `LoadPersonas()` and author `personas.json` (doesn't exist yet).
|
||||
- [x] Add name-by-country loading, parsing
|
||||
`tooling/pipeline/forenames-by-country.json` and
|
||||
`surnames-by-country.json`. Landed as two separate methods —
|
||||
`LoadForenamesByCountry()` and `LoadSurnamesByCountry()` — each returning
|
||||
a flat `ForenamesByCountryMap` / `SurnamesByCountryMap` (aliases for
|
||||
`unordered_map<string, ForenameList>` /
|
||||
`unordered_map<string, SurnameList>`) keyed by ISO 3166-1 code, rather
|
||||
than one combined `LoadNamesByCountry() : NamesByCountry` call. Both files
|
||||
are vendored verbatim (unmodified, full multinational coverage) from
|
||||
`sigpwned/popular-names-by-country-dataset` (CC0) — see
|
||||
ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section for
|
||||
provenance. Deliberately not pre-paired or filtered down to just the
|
||||
countries in `locations.json`: keeping the source shape (including
|
||||
per-forename gender) intact means the loader can support more countries
|
||||
later, or gender-aware persona/bio generation, without re-sourcing data.
|
||||
- [x] Extracted `ICuratedDataService` (not `DataPreloader`); `Load*()` methods
|
||||
take no arguments, return `const&`. `CuratedJsonDataService` (renamed from
|
||||
`JsonLoader`) takes a `CuratedDataFilePaths` DTO at construction and
|
||||
memoizes each `Load*()` result. `MockCuratedDataService` (fixed data for
|
||||
`US`/`DE`/`FR`/`BE`) binds in its place under `--mocked`.
|
||||
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` exists and is copied into the
|
||||
Docker image, but no loader reads it and the native CMake build doesn't
|
||||
copy it into `build/` at all.
|
||||
- [x] Added `LoadPersonas()` and authored `personas.json`.
|
||||
- [x] Added `LoadForenamesByCountry()` / `LoadSurnamesByCountry()` (two methods,
|
||||
not one combined `LoadNamesByCountry()`), parsing vendored CC0 data from
|
||||
`sigpwned/popular-names-by-country-dataset` — see
|
||||
ETHICS-AND-KNOWN-ISSUES.md for provenance. Deliberately not pre-paired or
|
||||
filtered to `locations.json`'s countries, to keep per-forename gender and
|
||||
support more countries later without re-sourcing.
|
||||
|
||||
## 3. Policy / Strategy Layer
|
||||
|
||||
Entirely new — no `includes/policy/` (or equivalent) directory exists today.
|
||||
Entirely new — no `includes/policy/` (or equivalent) exists today.
|
||||
|
||||
- [ ] `ContextStrategy` interface + `BreweryContextStrategy` /
|
||||
`BeerContextStrategy`. Today
|
||||
`WikipediaEnrichmentService::GetLocationContext` hardcodes a generic
|
||||
`"brewing"` query and a `"beer in {country}"` query directly — no
|
||||
per-phase strategy selection.
|
||||
- [ ] `SamplingStrategy` interface + `UniformSamplingStrategy`, replacing the
|
||||
inline `std::ranges::sample(...)` call in
|
||||
`BiergartenPipelineOrchestrator::QueryCitiesWithCountries()`.
|
||||
- [ ] `BeerSelectionStrategy` interface + `RandomBeerSelectionStrategy`, to pick
|
||||
styles per brewery from the `BeerStyle` palette (depends on §1 and §2).
|
||||
- [ ] `CheckinDistributionStrategy` interface + `JCurveCheckinStrategy` /
|
||||
`RandomCheckinStrategy` — this is the "Check-In System" item already
|
||||
called out in README.md's Next Steps, made concrete.
|
||||
- [ ] `FollowGenerationStrategy` interface + `RandomFollowStrategy` /
|
||||
- [ ] `ContextStrategy` + `BreweryContextStrategy`/`BeerContextStrategy`. Today
|
||||
`WikipediaEnrichmentService::GetLocationContext` hardcodes a `"brewing"`
|
||||
query and a `"beer in {country}"` query directly.
|
||||
- [ ] `SamplingStrategy` + `UniformSamplingStrategy`, replacing the inline
|
||||
`std::ranges::sample(...)` in `QueryCitiesWithCountries()`.
|
||||
- [ ] `BeerSelectionStrategy` + `RandomBeerSelectionStrategy` (depends on §1,
|
||||
§2).
|
||||
- [ ] `CheckinDistributionStrategy` + `JCurveCheckinStrategy` /
|
||||
`RandomCheckinStrategy` — the "Check-In System" README next step, made
|
||||
concrete.
|
||||
- [ ] `FollowGenerationStrategy` + `RandomFollowStrategy` /
|
||||
`ActivityWeightedFollowStrategy`.
|
||||
|
||||
## 4. Data Generation
|
||||
|
||||
`tooling/pipeline/includes/data_generation/`, `src/data_generation/`.
|
||||
|
||||
- [ ] Extend the `DataGenerator` interface with `GenerateBeer`,
|
||||
`GenerateCheckin`, `GenerateRating` (today: only `GenerateBrewery` and
|
||||
`GenerateUser`).
|
||||
- [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop
|
||||
mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3
|
||||
attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`.
|
||||
Changed the `DataGenerator::GenerateUser` signature to
|
||||
`(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult`,
|
||||
matching what the activity diagram actually passes.
|
||||
`MockGenerator::GenerateUser` updated to match the new signature too.
|
||||
- [ ] Implement `MockGenerator::GenerateBeer` / `GenerateCheckin` /
|
||||
`GenerateRating`.
|
||||
- [ ] Extend `DataGenerator` with `GenerateBeer`, `GenerateCheckin`,
|
||||
`GenerateRating` (today: only `GenerateBrewery`/`GenerateUser`).
|
||||
- [x] Added `OpenAIGenerator`, a third backend calling the OpenAI Chat
|
||||
Completions API with Structured Outputs (`json_schema`, `strict`) —
|
||||
schema-valid JSON with no `IPromptFormatter`/GBNF grammar needed. Selected
|
||||
via `--openai`/`GeneratorMode::kOpenAI`; landed ahead of §3/§4. Now shown
|
||||
in `diagrams/planned/class.md` alongside `MockGenerator`/`LlamaGenerator`,
|
||||
noting only `GenerateBrewery`/`GenerateUser` are implemented so far.
|
||||
- [x] Implemented `LlamaGenerator::GenerateUser` for real (GBNF grammar,
|
||||
`ValidateUserJson`, 3 retries with corrective feedback,
|
||||
`prompts/USER_GENERATION.md`). Signature is
|
||||
`(city, persona, name) : UserResult`, matching the activity diagram.
|
||||
- [ ] Implement
|
||||
`MockGenerator::GenerateBeer`/`GenerateCheckin`/`GenerateRating`.
|
||||
- [ ] Add `IPromptFormatter::ExpectedArchitecture()` and
|
||||
`LlamaGenerator::ValidateModelArchitecture()` so loading a GGUF that
|
||||
doesn't match the configured chat template fails fast instead of silently
|
||||
producing degraded output.
|
||||
- [ ] Add prompt template files for beer/checkin/rating generation next to the
|
||||
existing `prompts/BREWERY_GENERATION.md`.
|
||||
`LlamaGenerator::ValidateModelArchitecture()` so a mismatched GGUF chat
|
||||
template fails fast instead of degrading silently.
|
||||
- [ ] Add prompt templates for beer/checkin/rating, next to
|
||||
`prompts/BREWERY_GENERATION.md`.
|
||||
|
||||
## 5. Export Service
|
||||
|
||||
`tooling/pipeline/includes/services/database/`, `src/services/sqlite/`.
|
||||
|
||||
- [x] `IExportService::ProcessRecord(const UserRecord&)` and
|
||||
`SqliteExportService`'s `users` table (see `kCreateUsersTableSql` /
|
||||
`kInsertUserSql` in
|
||||
`includes/services/database/sqlite_statement_helpers.h`) are implemented —
|
||||
`GenerateUsers()` exports every successful user alongside its resolved
|
||||
- [x] `IExportService::ProcessRecord(const UserRecord&)` and the `users` table
|
||||
are implemented — every successful user is exported with its resolved
|
||||
`location_id`.
|
||||
- [ ] Extend `IExportService` with `ProcessBeer`, `ProcessCheckin`,
|
||||
`ProcessRating`, `ProcessFollow` (today
|
||||
`ProcessRecord(const BreweryRecord&)` and
|
||||
`ProcessRecord(const UserRecord&)` exist).
|
||||
`ProcessRating`, `ProcessFollow`.
|
||||
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
|
||||
`SqliteExportService::InitializeSchema()` (`locations`, `breweries`, and
|
||||
`users` already exist) — see `kCreateLocationsTableSql` /
|
||||
`kCreateBreweriesTableSql` / `kCreateUsersTableSql` in
|
||||
`includes/services/database/sqlite_statement_helpers.h` for the existing
|
||||
pattern to follow.
|
||||
- [ ] Add a `brewery_cache_` alongside the existing `location_cache_`, and move
|
||||
from one open transaction for the whole run to per-phase batched commits
|
||||
(`BEGIN` / `COMMIT & BEGIN` on a batch-size threshold), as shown in the
|
||||
planned activity diagram.
|
||||
`InitializeSchema()` (`cities`/`breweries`/`brewery_addresses`/`users`/
|
||||
`user_addresses` already exist — see `sqlite_statement_helpers.h` for the
|
||||
pattern).
|
||||
- [ ] Add a `brewery_cache_` alongside `city_cache_`, and move from one open
|
||||
transaction for the whole run to per-phase batched commits (`BEGIN` /
|
||||
`COMMIT & BEGIN` on a batch threshold), per the planned activity diagram.
|
||||
|
||||
## 6. Concurrency & Orchestration
|
||||
|
||||
`tooling/pipeline/includes/concurrency/`,
|
||||
`src/biergarten_pipeline_orchestrator/`.
|
||||
|
||||
- [ ] `BoundedChannel<T>` already exists and is production-tested — but it's
|
||||
only wired to the single log channel today. Stand up the per-phase
|
||||
producer/consumer channels (`loc_ch`, `exp_ch`, etc.) the planned activity
|
||||
diagram describes, each with a dedicated LLM worker thread and SQLite
|
||||
worker thread.
|
||||
- [ ] Rewrite `BiergartenPipelineOrchestrator::Run()` from one synchronous pass
|
||||
over `generated_breweries_` into phased methods — `RunUserPhase` →
|
||||
`RunBreweryAndBeerPhase` (with a `RunBeerPhase` sub-step once
|
||||
`brewery_pool_` is populated) → `RunCheckinPhase` / `RunFollowPhase`
|
||||
(these two can run in parallel) → `RunRatingPhase` — backed by
|
||||
`user_pool_`, `brewery_pool_`, `beer_pool_`, `checkin_pool_`,
|
||||
`follow_pool_`.
|
||||
- [ ] Honor the structural-concurrency requirement already called out in a
|
||||
comment on `BiergartenPipelineOrchestrator::Run()` in
|
||||
`biergarten_pipeline_orchestrator.h`: once real worker threads exist, they
|
||||
must be structurally joined (e.g. via `std::jthread`) before `Run()`
|
||||
returns, so no worker logs to a closed channel during teardown.
|
||||
- [ ] `BoundedChannel<T>` exists and is production-tested but only wired to the
|
||||
log channel. Stand up the per-phase producer/consumer channels (`loc_ch`,
|
||||
`exp_ch`, etc.) with a dedicated LLM worker thread and SQLite worker
|
||||
thread each; the brewery phase needs a third, a Geocode Worker between
|
||||
them on its own `geo_ch` (§9).
|
||||
- [ ] Rewrite `Run()` from one synchronous pass into phased methods —
|
||||
`RunUserPhase` → `RunBreweryAndBeerPhase` (`RunBeerPhase` sub-step once
|
||||
`brewery_pool_` fills) → `RunCheckinPhase`/`RunFollowPhase` (parallel) →
|
||||
`RunRatingPhase` — backed by `user_pool_`, `brewery_pool_`, `beer_pool_`,
|
||||
`checkin_pool_`, `follow_pool_`.
|
||||
- [ ] Structurally join worker threads (e.g. `std::jthread`) before `Run()`
|
||||
returns, per the existing comment in `biergarten_pipeline_orchestrator.h`,
|
||||
so nothing logs to a closed channel during teardown.
|
||||
|
||||
## 7. Enrichment
|
||||
|
||||
- [ ] Decide whether to restore the city/region-specific Wikipedia query that's
|
||||
currently commented out in
|
||||
`src/services/enrichment/wikipedia/get_summary.cc` (`GetLocationContext`).
|
||||
The `ContextStrategy` work in §3 is a natural place to reintroduce it via
|
||||
`BreweryContextStrategy::QueriesFor()`.
|
||||
- [ ] Decide whether to restore the city/region-specific Wikipedia query
|
||||
currently commented out in `get_summary.cc` — the §3 `ContextStrategy`
|
||||
work is a natural place to reintroduce it.
|
||||
- [ ] Pre-warm caches at startup (`PreWarmBeerStyleCache`,
|
||||
`PreWarmLocationCache` in the planned activity diagram) instead of
|
||||
fetching lazily per record, so the streaming phases never block on a cold
|
||||
cache mid-run.
|
||||
`PreWarmLocationCache`) instead of fetching lazily per record.
|
||||
|
||||
## 8. Fixtures / Build
|
||||
|
||||
- [x] Author `personas.json`, `forenames-by-country.json`, and
|
||||
`surnames-by-country.json` (see §2).
|
||||
- [x] Authored `personas.json`, `forenames-by-country.json`,
|
||||
`surnames-by-country.json` (§2).
|
||||
- [ ] Fix the `beer-styles.json` build-tree gap: add it to the "Runtime Assets"
|
||||
`configure_file` step in `CMakeLists.txt` so native builds and the Docker
|
||||
image agree on what's available at runtime.
|
||||
`configure_file` step in `CMakeLists.txt`.
|
||||
|
||||
## 9. Geocoding
|
||||
|
||||
`tooling/pipeline/includes/services/address/`, `src/services/address/` — added
|
||||
entirely after `diagrams/planned/` was authored; wasn't tracked here until now.
|
||||
|
||||
- [x] Added `IAddressService`
|
||||
(`ReverseGeocode(lon, lat) : optional<AddressLookupResult>`),
|
||||
`NominatimAddressService` (public Nominatim API, 1s sleep per request,
|
||||
`nullopt` on failure or no usable address), and `MockAddressService`
|
||||
(fixed placeholder for `--mocked`). Called once per brewery, after
|
||||
`GenerateBrewery` and before export, to resolve
|
||||
`BreweryAddress::address_line1`/`postal_code` — users aren't geocoded.
|
||||
`diagrams/planned/class.md` now models this as an
|
||||
`InfrastructureGeocoding` namespace owned by the orchestrator.
|
||||
- [ ] Give the brewery phase its own **Geocode Worker** thread, between the LLM
|
||||
worker and the SQLite worker (`loc_ch` → LLM Worker → `geo_ch` → Geocode
|
||||
Worker → `exp_ch` → SQLite Worker), rather than calling `ReverseGeocode`
|
||||
inline — Nominatim's rate limit is unrelated to inference speed, so
|
||||
coupling them stalls whichever side is slower and needlessly cools the
|
||||
LLM's KV cache. It never skips a record on lookup failure, only attaches a
|
||||
placeholder; shown as its own subgraph (`B_GEO`) in
|
||||
`diagrams/planned/activity.md`. One worker per phase is safe today since
|
||||
`RunUserPhase` joins before `RunBreweryPhase` starts; concurrent phases
|
||||
plus user-geocoding would need a shared rate-limited client instead, since
|
||||
the throttle is global, not per-thread.
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
The planned activity diagram's own phase notes ("brewery*pool* is now fully
|
||||
populated. Phase 1b may begin.", etc.) imply a dependency order. Roughly:
|
||||
The planned activity diagram's phase notes imply a dependency order:
|
||||
|
||||
1. Domain models (§1) and data preloading (§2) — nothing else compiles without
|
||||
these.
|
||||
2. Export schema (§5) — so every later generation phase has somewhere to land
|
||||
its output.
|
||||
2. Export schema (§5) — so every generation phase has somewhere to land output.
|
||||
3. Policy/strategy layer (§3) and generator interface (§4).
|
||||
4. Concurrency/orchestration rewrite (§6), which is the only piece that actually
|
||||
wires §1–§5 into the phased, parallel pipeline shown in the planned diagrams.
|
||||
5. Enrichment cache pre-warming and the city/region query decision (§7) — useful
|
||||
at any point, but most valuable once phases run concurrently.
|
||||
4. Concurrency/orchestration rewrite (§6) — wires §1–§5 into the phased,
|
||||
parallel pipeline the planned diagrams show.
|
||||
5. Enrichment cache pre-warming and the city/region query decision (§7) — most
|
||||
valuable once phases run concurrently.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
skinparam shadowing false
|
||||
skinparam backgroundColor #FCFCF7
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontColor #14180C
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontColor #14180C
|
||||
skinparam ArrowColor #656F33
|
||||
skinparam NoteBackgroundColor #DBEEDD
|
||||
skinparam NoteFontColor #14180C
|
||||
skinparam NoteBorderColor #4A5837
|
||||
skinparam SwimlaneBorderColor #4A5837
|
||||
skinparam SwimlaneBorderThickness 1
|
||||
skinparam activityStartColor #EBECE3
|
||||
skinparam activityEndColor #4A5837
|
||||
skinparam activityStopColor #4A5837
|
||||
skinparam ActivityBackgroundColor #EBECE3
|
||||
skinparam ActivityBorderColor #4A5837
|
||||
skinparam ActivityDiamondBackgroundColor #CBD2B5
|
||||
skinparam ActivityDiamondBorderColor #4A5837
|
||||
skinparam packageStyle rectangle
|
||||
skinparam packageBackgroundColor #F1F3EA
|
||||
skinparam packageBorderColor #4A5837
|
||||
skinparam packageFontColor #14180C
|
||||
skinparam classBackgroundColor #EBECE3
|
||||
skinparam classBorderColor #4A5837
|
||||
skinparam classFontColor #14180C
|
||||
skinparam classAttributeFontColor #3F4724
|
||||
skinparam classStereotypeFontColor #4A5837
|
||||
skinparam interfaceBackgroundColor #DBEEDD
|
||||
skinparam interfaceBorderColor #4A5837
|
||||
skinparam interfaceFontColor #14180C
|
||||
skinparam enumBackgroundColor #E4E6D8
|
||||
skinparam enumBorderColor #4A5837
|
||||
skinparam enumFontColor #14180C
|
||||
11
docs/pipeline/diagrams/current/activity.md
Normal file
11
docs/pipeline/diagrams/current/activity.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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
|
||||
--8<-- "pipeline/diagrams/current/activity.mmd"
|
||||
```
|
||||
86
docs/pipeline/diagrams/current/activity.mmd
Normal file
86
docs/pipeline/diagrams/current/activity.mmd
Normal file
@@ -0,0 +1,86 @@
|
||||
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["CuratedJsonDataService.LoadLocations()"]
|
||||
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]
|
||||
SkipCity1 ~~~ GenUsers
|
||||
StoreEnriched ~~~ GenUsers
|
||||
|
||||
GenUsers --> LoadUserData["CuratedJsonDataService: 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 city_id or insert City and cache its id; insert User + UserAddress (FK city_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]
|
||||
SkipCity2 ~~~ GenBrew
|
||||
UserGenFail ~~~ GenBrew
|
||||
UserExportWarn ~~~ GenBrew
|
||||
UserExportFail ~~~ GenBrew
|
||||
|
||||
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 --> BrewGeocode["IAddressService.ReverseGeocode(longitude, latitude)<br/>NominatimAddressService hits the live Nominatim API (1 req/sec) and returns nullopt on failure; MockAddressService (--mocked) returns a fixed placeholder address instead"]
|
||||
BrewGeocode --> BrewGeocodeFailed{Geocode resolved?}
|
||||
BrewGeocodeFailed -- no --> BrewPlaceholder["Log warning: could not reverse geocode; use placeholder address_line1, empty postal_code"] --> BrewExport
|
||||
BrewGeocodeFailed -- yes --> BrewExport["SqliteExportService.ProcessRecord(BreweryRecord)<br/>reuse cached city_id or insert City and cache its id; insert Brewery + BreweryAddress (FK city_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"]
|
||||
BrewGenFail ~~~ Finalize
|
||||
BrewExportWarn ~~~ Finalize
|
||||
BrewExportFail ~~~ Finalize
|
||||
|
||||
Finalize --> CloseLog["main.cc: close log_channel, join log dispatcher thread"]
|
||||
CloseLog --> Stop2(["return 0 / stop"])
|
||||
@@ -1,293 +0,0 @@
|
||||
@startuml
|
||||
skinparam style strictuml
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontSize 14
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontSize 20
|
||||
skinparam backgroundColor #FAFCF9
|
||||
skinparam defaultFontColor #28342A
|
||||
skinparam titleFontColor #28342A
|
||||
skinparam ArrowColor #628A5B
|
||||
skinparam NoteBackgroundColor #EAF0E8
|
||||
skinparam NoteBorderColor #547461
|
||||
skinparam ActivityBackgroundColor #FAFCF9
|
||||
skinparam ActivityBorderColor #547461
|
||||
skinparam ActivityDiamondBackgroundColor #FAFCF9
|
||||
skinparam ActivityDiamondBorderColor #628A5B
|
||||
skinparam ActivityBarColor #628A5B
|
||||
skinparam SwimlaneBorderColor #547461
|
||||
skinparam SwimlaneBorderThickness 0.3
|
||||
|
||||
title The Biergarten Data Pipeline (Current — Synchronous Data Path)
|
||||
|
||||
|#F2F6F0|main.cc|
|
||||
start
|
||||
:Create BoundedChannel<LogEntry> log_channel;
|
||||
:Spawn log dispatcher thread\n(LogDispatcher::Run());
|
||||
note right
|
||||
The only concurrency in the current pipeline:
|
||||
a dedicated thread drains log_channel and
|
||||
forwards entries to spdlog for the entire run.
|
||||
Joined during shutdown after log_channel closes.
|
||||
end note
|
||||
:ParseArguments(argc, argv);
|
||||
if (Are arguments valid?) then (no)
|
||||
:Log error / usage info;
|
||||
stop
|
||||
else (yes)
|
||||
endif
|
||||
|
||||
:Init OpenSSL global state & LlamaBackendState;
|
||||
:di::make_injector(...);
|
||||
note right
|
||||
Binds ILogger, IEnrichmentService, DataGenerator,
|
||||
IExportService, IPromptFormatter, WebClient based
|
||||
on --mocked vs. model-backed mode.
|
||||
end note
|
||||
:injector.create<std::unique_ptr<BiergartenPipelineOrchestrator>>();
|
||||
:BiergartenPipelineOrchestrator::Run();
|
||||
note right
|
||||
Run() itself is synchronous — sampling, enrichment,
|
||||
generation, and export all happen on this thread.
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:Initialize SQLite export;
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:GetUtcTimestamp() from SystemDateTimeProvider;
|
||||
:Initialize();
|
||||
note right
|
||||
Builds a fresh biergarten_seed_<UTC datetime>.sqlite filename
|
||||
Appends a numeric suffix if the timestamp already exists
|
||||
Opens DB Connection
|
||||
Executes Schema DDL
|
||||
Begins Transaction
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:QueryLocations();
|
||||
|
||||
|#E2EBDC|JsonLoader|
|
||||
:JsonLoader::LoadLocations("locations.json");
|
||||
:std::ranges::sample(all_locations, --location-count);
|
||||
note right
|
||||
--location-count defaults to 10.
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
while (For each sampled Location?) is (Remaining cities)
|
||||
|#DCE8D8|IEnrichmentService|
|
||||
:GetLocationContext(loc);
|
||||
note right
|
||||
WikipediaEnrichmentService fetches a generic
|
||||
"brewing" extract and a "beer in {country}" extract
|
||||
(not a city/region-specific page). MockEnrichmentService
|
||||
(--mocked) returns an empty string instead.
|
||||
end note
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
if (Lookup failed?) then (yes)
|
||||
:Log warning, skip city;
|
||||
else (no)
|
||||
:Store EnrichedCity{Location, region_context};
|
||||
endif
|
||||
endwhile (Done)
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:GenerateUsers(enriched_cities);
|
||||
|
||||
|#E2EBDC|JsonLoader|
|
||||
:LoadPersonas("personas.json");
|
||||
:LoadForenamesByCountry("forenames-by-country.json");
|
||||
:LoadSurnamesByCountry("surnames-by-country.json");
|
||||
note right
|
||||
Each call is cached after its first parse.
|
||||
MockCuratedDataService (--mocked) returns a
|
||||
fixed in-memory dataset instead and skips
|
||||
these three JSON files entirely.
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
while (For each EnrichedCity?) is (Remaining cities)
|
||||
:SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1);
|
||||
if (Names available for country?) then (no)
|
||||
:Log warning, skip city;
|
||||
else (yes)
|
||||
:Sample UserPersona (uniform random);
|
||||
|
||||
|#E5EDE1|DataGenerator|
|
||||
if (Generator Mode) then (MockGenerator)
|
||||
:DeterministicHash & Format;
|
||||
elseif (Generator Mode) then (LlamaGenerator)
|
||||
:prompt_directory_->Load("USER_GENERATION");
|
||||
note right
|
||||
Resolves to USER_GENERATION.md inside --prompt-dir.
|
||||
end note
|
||||
repeat
|
||||
:Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar);
|
||||
:ValidateUserJson(raw, user);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 3?) is (yes)
|
||||
else (OpenAIGenerator)
|
||||
:prompt_directory_->Load("USER_GENERATION");
|
||||
note right
|
||||
Same prompt file as LlamaGenerator - content
|
||||
is backend-agnostic.
|
||||
end note
|
||||
repeat
|
||||
:CallChatCompletionsApi(system_prompt, user_prompt,\nkUserJsonSchema, max_tokens);
|
||||
:ValidateUserJson(raw, user);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 2?) is (yes)
|
||||
note right
|
||||
Structured Outputs (response_format:
|
||||
json_schema, strict: true) already
|
||||
guarantees schema shape, so only 2
|
||||
attempts are needed - retries here cover
|
||||
transient failures / placeholder text,
|
||||
not malformed JSON.
|
||||
end note
|
||||
endif
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
if (Generation successful?) then (yes)
|
||||
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
|
||||
:BuildEmail(name, used_email_local_parts);
|
||||
:GenerateDateOfBirth(rng);
|
||||
note right
|
||||
email and date_of_birth are generated
|
||||
programmatically, never LLM-authored.
|
||||
Generation + coordinate jitter run inside
|
||||
one try/catch per record. UserRecord.address
|
||||
now carries {city, longitude, latitude}.
|
||||
end note
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:ProcessRecord(UserRecord);
|
||||
if (Location in cache?) then (yes)
|
||||
:Reuse location_id;
|
||||
else (no)
|
||||
:Insert Location & Cache ID;
|
||||
endif
|
||||
:Insert User (FK: location_id);
|
||||
|
||||
if (Exception caught during insert?) then (yes)
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:Log warning "SQLite export failed";
|
||||
else (no)
|
||||
endif
|
||||
else (no)
|
||||
:Log warning "Generation failed, skipping...";
|
||||
endif
|
||||
endif
|
||||
endwhile (Done)
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:GenerateBreweries(enriched_cities);
|
||||
|
||||
|#E5EDE1|DataGenerator|
|
||||
while (For each EnrichedCity?) is (Remaining cities)
|
||||
if (Generator Mode) then (MockGenerator)
|
||||
:DeterministicHash & Format;
|
||||
elseif (Generator Mode) then (LlamaGenerator)
|
||||
:PrepareRegionContext;
|
||||
:prompt_directory_->Load("BREWERY_GENERATION");
|
||||
note right
|
||||
Resolves to BREWERY_GENERATION.md inside --prompt-dir.
|
||||
end note
|
||||
repeat
|
||||
:Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar);
|
||||
:ValidateBreweryJson(raw, brewery);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 3?) is (yes)
|
||||
note right
|
||||
max_tokens is fixed across retries —
|
||||
it is not raised on truncation.
|
||||
end note
|
||||
else (OpenAIGenerator)
|
||||
:PrepareRegionContext;
|
||||
:prompt_directory_->Load("BREWERY_GENERATION");
|
||||
note right
|
||||
Same prompt file as LlamaGenerator - content
|
||||
is backend-agnostic.
|
||||
end note
|
||||
repeat
|
||||
:CallChatCompletionsApi(system_prompt, user_prompt,\nkBreweryJsonSchema, max_tokens);
|
||||
:ValidateBreweryJson(raw, brewery);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 2?) is (yes)
|
||||
note right
|
||||
Structured Outputs already guarantees schema
|
||||
shape, so only 2 attempts are needed - retries
|
||||
here cover transient failures / placeholder
|
||||
text, not malformed JSON.
|
||||
end note
|
||||
endif
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
if (Generation successful?) then (yes)
|
||||
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
|
||||
note right
|
||||
Generation + coordinate jitter run inside one
|
||||
try/catch per record. BreweryRecord.address
|
||||
now carries {city, longitude, latitude}.
|
||||
end note
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:ProcessRecord(GeneratedBrewery);
|
||||
if (Location in cache?) then (yes)
|
||||
:Reuse location_id;
|
||||
else (no)
|
||||
:Insert Location & Cache ID;
|
||||
endif
|
||||
:Insert Brewery (FK: location_id);
|
||||
|
||||
if (Exception caught during insert?) then (yes)
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:Log warning "SQLite export failed";
|
||||
note right
|
||||
Data loss is prevented per-record.
|
||||
The pipeline continues running.
|
||||
end note
|
||||
else (no)
|
||||
endif
|
||||
else (no)
|
||||
:Log warning "Generation failed, skipping...";
|
||||
endif
|
||||
|#E5EDE1|DataGenerator|
|
||||
endwhile (Done)
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:Finalize();
|
||||
note right
|
||||
Commits Transaction
|
||||
Closes Database Connection
|
||||
end note
|
||||
|
||||
|#F2F6F0|main.cc|
|
||||
:Close log_channel;
|
||||
:Join log dispatcher thread;
|
||||
:Return 0;
|
||||
stop
|
||||
|
||||
@enduml
|
||||
5
docs/pipeline/diagrams/current/class.md
Normal file
5
docs/pipeline/diagrams/current/class.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# The Biergarten Data Pipeline — Class Diagram (Current)
|
||||
|
||||
```mermaid
|
||||
--8<-- "pipeline/diagrams/current/class.mmd"
|
||||
```
|
||||
285
docs/pipeline/diagrams/current/class.mmd
Normal file
285
docs/pipeline/diagrams/current/class.mmd
Normal file
@@ -0,0 +1,285 @@
|
||||
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_
|
||||
-unique_ptr~IAddressService~ address_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. GenerateBreweries additionally reverse-geocodes the jittered coordinates via address_service_."
|
||||
|
||||
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() vector~Location~
|
||||
+LoadPersonas() vector~UserPersona~
|
||||
+LoadForenamesByCountry() unordered_map~string, forename_list~
|
||||
+LoadSurnamesByCountry() unordered_map~string, surname_list~
|
||||
}
|
||||
|
||||
class CuratedJsonDataService {
|
||||
-CuratedDataFilePaths filepaths_
|
||||
-cache cache_
|
||||
+LoadLocations() vector~Location~
|
||||
+LoadPersonas() vector~UserPersona~
|
||||
+LoadForenamesByCountry() unordered_map~string, forename_list~
|
||||
+LoadSurnamesByCountry() unordered_map~string, surname_list~
|
||||
}
|
||||
note for CuratedJsonDataService "Takes a CuratedDataFilePaths DTO (locations/personas/forenames/surnames paths) once at construction, not per Load* call. 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() vector~Location~
|
||||
+LoadPersonas() vector~UserPersona~
|
||||
+LoadForenamesByCountry() unordered_map~string, forename_list~
|
||||
+LoadSurnamesByCountry() unordered_map~string, surname_list~
|
||||
}
|
||||
note for MockCuratedDataService "Fixed 4-location/3-persona dataset (US, DE, FR, BE) for --mocked runs"
|
||||
|
||||
class IAddressService {
|
||||
<<interface>>
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
|
||||
class NominatimAddressService {
|
||||
-unique_ptr~WebClient~ client_
|
||||
-shared_ptr~ILogger~ logger_
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
note for NominatimAddressService "Queries the public Nominatim API; sleeps 1s per request per its usage policy; returns nullopt on failure or when no usable street address is found"
|
||||
|
||||
class MockAddressService {
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
note for MockAddressService "Returns a fixed placeholder address ('123 Mock Street', '00000') for --mocked runs, no network call"
|
||||
|
||||
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_city_stmt_
|
||||
-SqliteStatementHandle insert_brewery_stmt_
|
||||
-SqliteStatementHandle insert_brewery_address_stmt_
|
||||
-SqliteStatementHandle insert_user_stmt_
|
||||
-SqliteStatementHandle insert_user_address_stmt_
|
||||
-bool transaction_open_
|
||||
-unordered_map~string, sqlite3_int64~ city_cache_
|
||||
+Initialize() void
|
||||
+ProcessRecord(brewery) uint64_t
|
||||
+ProcessRecord(user) uint64_t
|
||||
+Finalize() void
|
||||
-InitializeSchema() void
|
||||
-ResolveCityId(city) 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
|
||||
BiergartenPipelineOrchestrator *-- IAddressService : 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 <|.. CuratedJsonDataService : implements
|
||||
ICuratedDataService <|.. MockCuratedDataService : implements
|
||||
|
||||
IAddressService <|.. NominatimAddressService : implements
|
||||
IAddressService <|.. MockAddressService : implements
|
||||
|
||||
IExportService <|.. SqliteExportService : implements
|
||||
SqliteExportService *-- IDateTimeProvider : owns
|
||||
IDateTimeProvider <|.. SystemDateTimeProvider : implements
|
||||
@@ -1,301 +0,0 @@
|
||||
@startuml
|
||||
skinparam style strictuml
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontSize 14
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontSize 20
|
||||
skinparam backgroundColor #FAFCF9
|
||||
skinparam defaultFontColor #28342A
|
||||
skinparam titleFontColor #28342A
|
||||
skinparam ArrowColor #628A5B
|
||||
|
||||
skinparam class {
|
||||
BackgroundColor #FAFCF9
|
||||
HeaderBackgroundColor #EAF0E8
|
||||
BorderColor #547461
|
||||
ArrowColor #628A5B
|
||||
FontColor #28342A
|
||||
}
|
||||
|
||||
skinparam note {
|
||||
BackgroundColor #EAF0E8
|
||||
BorderColor #547461
|
||||
FontColor #28342A
|
||||
}
|
||||
|
||||
title The Biergarten Data Pipeline - Class Diagram
|
||||
|
||||
class BiergartenPipelineOrchestrator {
|
||||
- logger_ : std::shared_ptr<ILogger>
|
||||
- context_service_ : std::unique_ptr<IEnrichmentService>
|
||||
- generator_ : std::unique_ptr<DataGenerator>
|
||||
- exporter_ : std::unique_ptr<IExportService>
|
||||
- curated_data_service_ : std::unique_ptr<ICuratedDataService>
|
||||
- application_options_ : ApplicationOptions
|
||||
- generated_breweries_ : std::vector<BreweryRecord>
|
||||
- generated_users_ : std::vector<UserRecord>
|
||||
+ Run() : bool
|
||||
- QueryLocations() : std::vector<City>
|
||||
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
|
||||
- GenerateUsers(cities : std::span<const EnrichedCity>) : void
|
||||
- LogResults() const : void
|
||||
}
|
||||
|
||||
note right of BiergartenPipelineOrchestrator
|
||||
GenerateBreweries and GenerateUsers both call the
|
||||
free function GetRandomCoordsWithinRange()
|
||||
(biergarten_pipeline_orchestrator/coords.h) to jitter
|
||||
a coordinate pair uniformly within 5km of the city
|
||||
centre for each generated record's address.
|
||||
end note
|
||||
|
||||
class LogLevel <<enumeration>> {
|
||||
Debug
|
||||
Info
|
||||
Warn
|
||||
Error
|
||||
}
|
||||
|
||||
class PipelinePhase <<enumeration>> {
|
||||
Startup
|
||||
Enrichment
|
||||
UserGeneration
|
||||
BreweryAndBeerGeneration
|
||||
CheckinGeneration
|
||||
RatingGeneration
|
||||
FollowGeneration
|
||||
Teardown
|
||||
}
|
||||
|
||||
struct LogDTO {
|
||||
+ level : LogLevel
|
||||
+ phase : PipelinePhase
|
||||
+ message : std::string
|
||||
}
|
||||
|
||||
struct LogEntry {
|
||||
+ timestamp : std::chrono::system_clock::time_point
|
||||
+ origin : std::source_location
|
||||
+ thread_id : std::thread::id
|
||||
+ level : LogLevel
|
||||
+ phase : PipelinePhase
|
||||
+ message : std::string
|
||||
}
|
||||
|
||||
interface ILogger <<interface>> {
|
||||
+ Log(payload : LogDTO) : void
|
||||
- {abstract} DoLog(entry : LogEntry) : void
|
||||
}
|
||||
|
||||
class LogProducer {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
- DoLog(entry : LogEntry) : void
|
||||
}
|
||||
|
||||
class LogDispatcher {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
+ Run() : void
|
||||
- ToSpdlogLevel(level) : spdlog::level::level_enum
|
||||
}
|
||||
|
||||
interface IEnrichmentService <<interface>> {
|
||||
+ GetLocationContext(loc : const Location&) : std::string
|
||||
}
|
||||
|
||||
class MockEnrichmentService {
|
||||
+ GetLocationContext(loc : const Location&) : std::string
|
||||
}
|
||||
|
||||
class WikipediaEnrichmentService {
|
||||
- client_ : std::unique_ptr<WebClient>
|
||||
- extract_cache_ : std::unordered_map<std::string, std::string>
|
||||
+ GetLocationContext(loc : const Location&) : std::string
|
||||
- FetchExtract(query : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface WebClient <<interface>> {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
class HttpWebClient {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
interface DataGenerator <<interface>> {
|
||||
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
}
|
||||
|
||||
class MockGenerator {
|
||||
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
- DeterministicHash(location : const Location&) : size_t
|
||||
- DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t
|
||||
}
|
||||
|
||||
class LlamaGenerator {
|
||||
- model_ : ModelHandle
|
||||
- context_ : ContextHandle
|
||||
- prompt_formatter_ : std::unique_ptr<IPromptFormatter>
|
||||
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
|
||||
- rng_ : std::mt19937
|
||||
+ GenerateBrewery(...) : BreweryResult
|
||||
+ GenerateUser(...) : UserResult
|
||||
- Load(model_path : const std::string&) : void
|
||||
- Infer(...) : std::string
|
||||
- InferFormatted(...) : std::string
|
||||
}
|
||||
|
||||
class OpenAIGenerator {
|
||||
- api_key_ : std::string
|
||||
- model_ : std::string
|
||||
- logger_ : std::shared_ptr<ILogger>
|
||||
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
|
||||
- web_client_ : std::unique_ptr<WebClient>
|
||||
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
- CallChatCompletionsApi(...) : std::string
|
||||
}
|
||||
|
||||
note right of OpenAIGenerator
|
||||
Chat Completions with Structured Outputs
|
||||
(response_format: json_schema, strict: true)
|
||||
guarantees schema-valid JSON, replacing
|
||||
llama.cpp's GBNF grammar constraint -
|
||||
no IPromptFormatter needed.
|
||||
end note
|
||||
|
||||
interface IPromptFormatter <<interface>> {
|
||||
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
||||
}
|
||||
|
||||
class Gemma4JinjaPromptFormatter {
|
||||
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface IPromptDirectory <<interface>> {
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
class PromptDirectory {
|
||||
- prompt_dir_ : std::filesystem::path
|
||||
- cache_ : std::unordered_map<std::string, std::string>
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface ICuratedDataService <<interface>> {
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
class JsonLoader {
|
||||
- cache_ : cache
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
note right of JsonLoader
|
||||
Each Load* method memoizes its result in
|
||||
cache_ on first call; later calls on the
|
||||
same instance return the cached value
|
||||
without re-parsing.
|
||||
end note
|
||||
|
||||
class MockCuratedDataService {
|
||||
- locations_ : std::vector<Location>
|
||||
- personas_ : std::vector<UserPersona>
|
||||
- forenames_by_country_ : std::unordered_map<std::string, forename_list>
|
||||
- surnames_by_country_ : std::unordered_map<std::string, surname_list>
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
note right of MockCuratedDataService
|
||||
Fixed 4-location / 3-persona dataset
|
||||
(US, DE, FR, BE) used in --mocked runs;
|
||||
filepath arguments are ignored.
|
||||
end note
|
||||
|
||||
interface IExportService <<interface>> {
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
|
||||
+ ProcessRecord(user : const UserRecord&) : uint64_t
|
||||
+ Finalize() : void
|
||||
}
|
||||
|
||||
class SqliteExportService {
|
||||
- date_time_provider_ : std::unique_ptr<IDateTimeProvider>
|
||||
- run_timestamp_utc_ : std::string
|
||||
- database_path_ : std::filesystem::path
|
||||
- db_handle_ : SqliteDatabaseHandle
|
||||
- insert_location_stmt_ : SqliteStatementHandle
|
||||
- insert_brewery_stmt_ : SqliteStatementHandle
|
||||
- insert_user_stmt_ : SqliteStatementHandle
|
||||
- transaction_open_ : bool
|
||||
- location_cache_ : std::unordered_map<std::string, sqlite3_int64>
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
|
||||
+ ProcessRecord(user : const UserRecord&) : uint64_t
|
||||
+ Finalize() : void
|
||||
- InitializeSchema() : void
|
||||
- ResolveLocationId(location : const Location&) : sqlite3_int64
|
||||
}
|
||||
|
||||
interface IDateTimeProvider <<interface>> {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
class SystemDateTimeProvider {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
' Structural Relationships / Dependency Injection
|
||||
BiergartenPipelineOrchestrator *-- ILogger : owns
|
||||
BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
|
||||
BiergartenPipelineOrchestrator *-- DataGenerator : owns
|
||||
BiergartenPipelineOrchestrator *-- IExportService : owns
|
||||
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
|
||||
|
||||
LogEntry *-- LogLevel
|
||||
LogEntry *-- PipelinePhase
|
||||
LogDTO *-- LogLevel
|
||||
LogDTO *-- PipelinePhase
|
||||
ILogger <|.. LogProducer : implements
|
||||
LogProducer ..> LogEntry : emits
|
||||
LogDispatcher ..> LogEntry : consumes
|
||||
|
||||
IEnrichmentService <|.. WikipediaEnrichmentService : implements
|
||||
IEnrichmentService <|.. MockEnrichmentService : implements
|
||||
WikipediaEnrichmentService *-- WebClient : owns
|
||||
|
||||
WebClient <|.. HttpWebClient : implements
|
||||
|
||||
DataGenerator <|.. MockGenerator : implements
|
||||
DataGenerator <|.. LlamaGenerator : implements
|
||||
DataGenerator <|.. OpenAIGenerator : implements
|
||||
|
||||
LlamaGenerator *-- IPromptFormatter : uses
|
||||
LlamaGenerator *-- IPromptDirectory : uses
|
||||
|
||||
OpenAIGenerator *-- IPromptDirectory : uses
|
||||
OpenAIGenerator *-- WebClient : uses
|
||||
|
||||
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
||||
IPromptDirectory <|.. PromptDirectory : implements
|
||||
|
||||
ICuratedDataService <|.. JsonLoader : implements
|
||||
ICuratedDataService <|.. MockCuratedDataService : implements
|
||||
|
||||
IExportService <|.. SqliteExportService : implements
|
||||
SqliteExportService *-- IDateTimeProvider : owns
|
||||
IDateTimeProvider <|.. SystemDateTimeProvider : implements
|
||||
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
16
docs/pipeline/diagrams/planned/activity.md
Normal file
16
docs/pipeline/diagrams/planned/activity.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# The Biergarten Data Pipeline — Activity Diagram (Planned)
|
||||
|
||||
Each generation phase runs a producer/consumer chain 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 brewery phase inserts a fourth stage, a **Geocode Worker**, between
|
||||
the LLM Worker and the SQLite Worker: reverse-geocoding is network-bound and
|
||||
rate-limited independently of LLM inference (Nominatim's 1-request-per-second
|
||||
policy), so keeping it off the LLM Worker's thread means neither stage stalls
|
||||
waiting on the other's bottleneck. The Checkin and Follow phases run in parallel
|
||||
with each other once the user and brewery pools are populated.
|
||||
|
||||
```mermaid
|
||||
--8<-- "pipeline/diagrams/planned/activity.mmd"
|
||||
```
|
||||
208
docs/pipeline/diagrams/planned/activity.mmd
Normal file
208
docs/pipeline/diagrams/planned/activity.mmd
Normal file
@@ -0,0 +1,208 @@
|
||||
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, geo_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 → geo_ch"]
|
||||
BL6 --> BL1
|
||||
BL1 -- no --> BL7["Close geo_ch"]
|
||||
end
|
||||
subgraph B_GEO["Geocode Worker"]
|
||||
BG1{"geo_ch has items?"}
|
||||
BG1 -- yes --> BG2["Receive GeneratedBrewery"]
|
||||
BG2 --> BG3["AddressService.ReverseGeocode(brewery.longitude, brewery.latitude)<br/>rate-limited independently of LLM throughput — NominatimAddressService sleeps 1s per request; MockAddressService (--mocked) returns a fixed placeholder, no network call"]
|
||||
BG3 --> BG4{"Address resolved?"}
|
||||
BG4 -- yes --> BG5["Attach resolved AddressLookupResult to brewery"]
|
||||
BG4 -- no --> BG6["Log warning: could not reverse geocode; attach placeholder address_line1, empty postal_code — brewery is never dropped"]
|
||||
BG5 --> BG7["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, geocode)"]
|
||||
BG6 --> BG7
|
||||
BG7 --> BG8["Send GeneratedBrewery → exp_ch"]
|
||||
BG8 --> BG1
|
||||
BG1 -- no --> BG9["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 -.->|geo_ch| BG1
|
||||
BG9 -.->|exp_ch| BS2
|
||||
BL7 & BG9 & BS9 --> BrewJoin["Orchestrator: Join LLM Worker, Geocode 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])
|
||||
@@ -1,372 +0,0 @@
|
||||
@startuml biergarten_activity
|
||||
!include ../biergarten-weizen-theme.puml
|
||||
skinparam defaultFontSize 13
|
||||
skinparam titleFontSize 20
|
||||
|
||||
title The Biergarten Data Pipeline — Activity Diagram
|
||||
|
||||
|Main|
|
||||
start
|
||||
:ParseArguments(argc, argv);
|
||||
if (Invalid args?) then (yes)
|
||||
:spdlog::error;
|
||||
stop
|
||||
else (no)
|
||||
endif
|
||||
:Init OpenSSL global state & LlamaBackendState;
|
||||
:Build DI injector;
|
||||
|
||||
:Initialize SqliteExportService;
|
||||
note right
|
||||
Opens SQLite connection.
|
||||
(Transactions are now managed
|
||||
per-phase via batching).
|
||||
end note
|
||||
|
||||
:Create BoundedChannel<LogEntry> log_ch;
|
||||
:Spawn Log Worker thread;
|
||||
note right
|
||||
Log worker drains log_ch for the
|
||||
entire pipeline lifetime.
|
||||
All workers emit LogEntry structs
|
||||
via PipelineLogger -- never spdlog directly.
|
||||
end note
|
||||
|
||||
:BiergartenPipelineOrchestrator::Run();
|
||||
|BiergartenPipelineOrchestrator::Run()|
|
||||
|
||||
fork
|
||||
:JsonLoader::LoadBeerStyles("beer-styles.json");
|
||||
:EnrichmentService::PreWarmBeerStyleCache(beer_styles);
|
||||
fork again
|
||||
:JsonLoader::LoadLocations("locations.json");
|
||||
:EnrichmentService::PreWarmLocationCache(sampled_locations);
|
||||
end fork
|
||||
fork
|
||||
:JsonLoader::LoadForenamesByCountry(\n "forenames-by-country.json");
|
||||
fork again
|
||||
:JsonLoader::LoadSurnamesByCountry(\n "surnames-by-country.json");
|
||||
fork again
|
||||
:JsonLoader::LoadPersonas("personas.json");
|
||||
end fork
|
||||
note right
|
||||
Each call is memoized after its first parse
|
||||
(implemented today). MockCuratedDataService
|
||||
returns a fixed in-memory dataset instead
|
||||
under --mocked, skipping these JSON files.
|
||||
end note
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 0 — USER GENERATION
|
||||
' ═══════════════════════════════════════════
|
||||
|Orchestrator|
|
||||
:RunUserPhase(sampled_locations);
|
||||
:Create BoundedChannels\n(loc_ch, exp_ch);
|
||||
|
||||
fork
|
||||
|Orchestrator|
|
||||
:Loop: Send Locations -> loc_ch;
|
||||
:Close loc_ch;
|
||||
note right
|
||||
Producer closes loc_ch.
|
||||
LLM Worker while loop
|
||||
terminates on empty + closed.
|
||||
end note
|
||||
fork again
|
||||
|LLM Worker|
|
||||
while (loc_ch has items?) is (yes)
|
||||
:Receive Location;
|
||||
|
||||
:GetLocationContextFromCache(location);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
end note
|
||||
|
||||
:IPersonaSelectionStrategy::SelectPersona(\n personas_palette_);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
Returns a Persona struct carrying
|
||||
style_affinities, abv_range,
|
||||
ibu_preference, checkin_weight.
|
||||
end note
|
||||
|
||||
:SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1, rng);
|
||||
note right
|
||||
Deterministic lookup -- no LLM involved.
|
||||
Free function (implemented today in
|
||||
generate_users.cc), not a class method.
|
||||
Name selected from pre-keyed maps
|
||||
and passed into the generation prompt.
|
||||
Skips the city if either map has no
|
||||
entry for iso3166_1.
|
||||
end note
|
||||
|
||||
:GenerateUser(enriched_city, persona, sampled_name)\nvia DataGenerator;
|
||||
note right
|
||||
LLM receives: EnrichedCity context + persona
|
||||
description + sampled name. Generates
|
||||
bio and preference signals grounded
|
||||
in locale and persona.
|
||||
end note
|
||||
|
||||
:PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "llm");
|
||||
:Send GeneratedUser -> exp_ch;
|
||||
endwhile (no)
|
||||
:Close exp_ch;
|
||||
note right
|
||||
Producer closes exp_ch.
|
||||
SQLite Worker while loop
|
||||
terminates on empty + closed.
|
||||
end note
|
||||
fork again
|
||||
|SQLite Worker|
|
||||
:BEGIN TRANSACTION;
|
||||
while (exp_ch has items?) is (yes)
|
||||
:Receive GeneratedUser;
|
||||
:ProcessUser(user);
|
||||
:PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "sqlite");
|
||||
:Append -> user_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (no)
|
||||
:COMMIT (Final);
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join LLM Worker, SQLite Worker;
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 1a — BREWERY GENERATION
|
||||
' ═══════════════════════════════════════════
|
||||
:RunBreweryPhase(sampled_locations);
|
||||
:Create BoundedChannels\n(loc_ch, exp_ch);
|
||||
|
||||
fork
|
||||
|Orchestrator|
|
||||
:Loop: Sample User from user_pool_
|
||||
and pair with Location;
|
||||
:Send BreweryTask(Location, User) -> loc_ch;
|
||||
:Close loc_ch;
|
||||
fork again
|
||||
|LLM Worker|
|
||||
while (loc_ch has items?) is (yes)
|
||||
:Receive BreweryTask(Location, User);
|
||||
|
||||
:GetLocationContextFromCache(task.location);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
end note
|
||||
|
||||
:GenerateBrewery(enriched_city, context, task.user)\nvia DataGenerator;
|
||||
note right
|
||||
KV cache stays warm.
|
||||
Brewery is linked to the sampled owner_user_id.
|
||||
end note
|
||||
:PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "llm");
|
||||
:Send GeneratedBrewery -> exp_ch;
|
||||
endwhile (no)
|
||||
:Close exp_ch;
|
||||
fork again
|
||||
|SQLite Worker|
|
||||
:BEGIN TRANSACTION;
|
||||
while (exp_ch has items?) is (yes)
|
||||
:Receive GeneratedBrewery;
|
||||
:ProcessBrewery(brewery);
|
||||
:PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "sqlite");
|
||||
:Append -> brewery_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (no)
|
||||
:COMMIT (Final);
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join LLM Worker, SQLite Worker;
|
||||
note right
|
||||
brewery_pool_ is now fully populated.
|
||||
Phase 1b may begin.
|
||||
end note
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 1b — BEER GENERATION
|
||||
' ═══════════════════════════════════════════
|
||||
:RunBeerPhase();
|
||||
:Create BoundedChannels\n(brew_ch, exp_ch);
|
||||
|
||||
fork
|
||||
|Orchestrator|
|
||||
:Loop: Send Breweries -> brew_ch;
|
||||
:Close brew_ch;
|
||||
fork again
|
||||
|LLM Worker|
|
||||
while (brew_ch has items?) is (yes)
|
||||
:Receive GeneratedBrewery;
|
||||
:IBeerSelectionStrategy::SelectStyles(\n brewery, beer_style_palette_);
|
||||
|
||||
while (For each selected BeerStyle?) is (remaining)
|
||||
:GetStyleContextFromCache(style);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
KV cache stays warm across all
|
||||
beer generations -- system prompt
|
||||
does not change within this phase.
|
||||
end note
|
||||
:GenerateBeer(brewery, style_context)\nvia DataGenerator;
|
||||
:Attach GeneratedBeer to bundle;
|
||||
endwhile (done)
|
||||
|
||||
:PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "llm");
|
||||
:Send BeersBundle -> exp_ch;
|
||||
endwhile (no)
|
||||
:Close exp_ch;
|
||||
fork again
|
||||
|SQLite Worker|
|
||||
:BEGIN TRANSACTION;
|
||||
while (exp_ch has items?) is (yes)
|
||||
:Receive BeersBundle;
|
||||
while (For each beer in bundle?) is (remaining)
|
||||
:Set beer.brewery_id from bundle;
|
||||
:ProcessBeer(beer);
|
||||
:Append -> beer_pool_;
|
||||
endwhile (done)
|
||||
:PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "sqlite");
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (no)
|
||||
:COMMIT (Final);
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join LLM Worker, SQLite Worker;
|
||||
note right
|
||||
Both brewery_pool_ and beer_pool_
|
||||
are now completely populated.
|
||||
Checkin and Follow phases may
|
||||
now run in parallel.
|
||||
end note
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 2 — CHECKIN + FOLLOW GENERATION
|
||||
' (parallel — both depend only on user_pool_
|
||||
' and brewery_pool_ being fully populated)
|
||||
' ═══════════════════════════════════════════
|
||||
fork
|
||||
|Orchestrator|
|
||||
:RunCheckinPhase();
|
||||
:ICheckinDistributionStrategy::\nAssignActivityWeights(user_pool_);
|
||||
note right
|
||||
Weights seeded from each user's
|
||||
persona.checkin_weight. J-curve profile
|
||||
emerges from persona distribution.
|
||||
end note
|
||||
|
||||
:BEGIN TRANSACTION;
|
||||
while (For each GeneratedUser in user_pool_?) is (remaining)
|
||||
:CheckinsForUser(user, brewery_pool_.size());
|
||||
while (For each checkin index?) is (remaining)
|
||||
:TimestampFor(user, index);
|
||||
:Select brewery from brewery_pool_;
|
||||
:GenerateCheckin(user, brewery, timestamp)\nvia DataGenerator;
|
||||
:ProcessCheckin(checkin);
|
||||
:PipelineLogger::Log(Info, CheckinGeneration,\n nullopt, checkin_id, "sqlite");
|
||||
:Append -> checkin_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (done)
|
||||
endwhile (done)
|
||||
:COMMIT (Final);
|
||||
|
||||
fork again
|
||||
|Orchestrator|
|
||||
:RunFollowPhase();
|
||||
:IFollowGenerationStrategy::\nAssignFollowWeights(user_pool_);
|
||||
note right
|
||||
For RandomFollowStrategy, weights
|
||||
are uniform. For ActivityWeightedFollowStrategy,
|
||||
weights derived from user.activity_weight
|
||||
so high-activity users attract more followers.
|
||||
end note
|
||||
|
||||
:BEGIN TRANSACTION;
|
||||
:IFollowGenerationStrategy::\nGenerateFollows(user_pool_);
|
||||
note right
|
||||
Self-follow constraint (follower_id != followed_id)
|
||||
enforced here and at the DB schema level.
|
||||
end note
|
||||
while (For each GeneratedFollow?) is (remaining)
|
||||
:ProcessFollow(follow);
|
||||
:PipelineLogger::Log(Info, FollowGeneration,\n nullopt, follower_id, "sqlite");
|
||||
:Append -> follow_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (done)
|
||||
:COMMIT (Final);
|
||||
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join CheckinPhase, FollowPhase;
|
||||
note right
|
||||
checkin_pool_ and follow_pool_
|
||||
are now fully populated.
|
||||
Rating phase may begin.
|
||||
end note
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 3 — RATING GENERATION
|
||||
' ═══════════════════════════════════════════
|
||||
:RunRatingPhase();
|
||||
note right
|
||||
Beer selection biased by
|
||||
user.persona.style_affinities and abv_range.
|
||||
Rating skew modulated per persona.
|
||||
end note
|
||||
|
||||
:BEGIN TRANSACTION;
|
||||
while (For each GeneratedCheckin in checkin_pool_?) is (remaining)
|
||||
:Match brewery_id, select beer from beer_pool_\n(same brewery_id, biased by persona affinities);
|
||||
if (Beer exists for brewery?) then (yes)
|
||||
:GenerateRating(user, beer, checkin_id)\nvia DataGenerator;
|
||||
:ProcessRating(rating);
|
||||
:PipelineLogger::Log(Info, RatingGeneration,\n nullopt, rating_id, "sqlite");
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
else (no)
|
||||
:PipelineLogger::Log(Warn, RatingGeneration,\n nullopt, brewery_id, "sqlite");
|
||||
:Skip -- brewery has no beers;
|
||||
endif
|
||||
endwhile (done)
|
||||
:COMMIT (Final);
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' TEARDOWN
|
||||
' ═══════════════════════════════════════════
|
||||
|Orchestrator|
|
||||
:Finalize SqliteExportService;
|
||||
note right
|
||||
Safely closes the DB connection.
|
||||
end note
|
||||
:Close log_ch;
|
||||
|
||||
|Main|
|
||||
:spdlog::info "Pipeline complete in X ms";
|
||||
:Join Log Worker;
|
||||
note right
|
||||
Drain guarantees no LogEntry is
|
||||
dropped at shutdown.
|
||||
end note
|
||||
stop
|
||||
|
||||
@enduml
|
||||
17
docs/pipeline/diagrams/planned/class.md
Normal file
17
docs/pipeline/diagrams/planned/class.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Biergarten Data Pipeline — Class Diagram (Planned)
|
||||
|
||||
```mermaid
|
||||
--8<-- "pipeline/diagrams/planned/class.mmd"
|
||||
```
|
||||
|
||||
## 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.
|
||||
643
docs/pipeline/diagrams/planned/class.mmd
Normal file
643
docs/pipeline/diagrams/planned/class.mmd
Normal file
@@ -0,0 +1,643 @@
|
||||
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 GeneratorMode {
|
||||
<<enumeration>>
|
||||
kMock
|
||||
kLlama
|
||||
kOpenAI
|
||||
}
|
||||
|
||||
class GeneratorOptions {
|
||||
+path model_path
|
||||
+GeneratorMode mode
|
||||
+optional~SamplingOptions~ sampling
|
||||
+string openai_api_key
|
||||
+string openai_model
|
||||
}
|
||||
|
||||
class PipelineOptions {
|
||||
+path output_path
|
||||
+path log_path
|
||||
}
|
||||
|
||||
class ApplicationOptions {
|
||||
+GeneratorOptions generator
|
||||
+PipelineOptions pipeline
|
||||
}
|
||||
}
|
||||
note for GeneratorOptions "Already implemented (models.h), not planned: the three-way GeneratorMode split (kMock/kLlama/kOpenAI) and the openai_api_key/openai_model fields ship today, ahead of the rest of this diagram."
|
||||
ApplicationOptions *-- GeneratorOptions
|
||||
ApplicationOptions *-- PipelineOptions
|
||||
GeneratorOptions *-- GeneratorMode
|
||||
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 InfrastructureGeocoding {
|
||||
class AddressLookupResult {
|
||||
+string address_line1
|
||||
+string postal_code
|
||||
}
|
||||
|
||||
class AddressService {
|
||||
<<interface>>
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
|
||||
class NominatimAddressService {
|
||||
-unique_ptr~WebClient~ client_
|
||||
-Logger logger_
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
|
||||
class MockAddressService {
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
}
|
||||
note for NominatimAddressService "Already implemented (services/address/), not planned: queries the public Nominatim API, sleeping 1s per request per its usage policy; returns nullopt on failure or when no usable street address is found. Ships today, ahead of the rest of this diagram."
|
||||
note for MockAddressService "Already implemented, not planned: returns a fixed placeholder address ('123 Mock Street', '00000') for --mocked runs, no network call."
|
||||
AddressService <|.. NominatimAddressService
|
||||
AddressService <|.. MockAddressService
|
||||
AddressService ..> AddressLookupResult : returns
|
||||
NominatimAddressService *-- WebClient
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
class OpenAIGenerator {
|
||||
-string api_key_
|
||||
-string model_
|
||||
-Logger logger_
|
||||
-unique_ptr~IPromptDirectory~ prompt_directory_
|
||||
-unique_ptr~WebClient~ web_client_
|
||||
+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
|
||||
-CallChatCompletionsApi(...) string
|
||||
}
|
||||
}
|
||||
note for OpenAIGenerator "Already implemented (data_generation/openai/), not planned: calls the OpenAI Chat Completions API with Structured Outputs (json_schema, strict) to guarantee schema-valid JSON, needing no IPromptFormatter or GBNF grammar. Ships today for GenerateBrewery/GenerateUser only, ahead of the rest of this diagram — GenerateBeer/GenerateCheckin/GenerateRating are shown here for interface completeness but not yet implemented on any backend."
|
||||
|
||||
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~AddressService~ address_service_
|
||||
-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 *-- AddressService
|
||||
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
|
||||
DataGenerator <|.. OpenAIGenerator
|
||||
PromptFormatter <|.. Gemma4JinjaPromptFormatter
|
||||
ExportService <|.. SqliteExportService
|
||||
DateTimeProvider <|.. SystemDateTimeProvider
|
||||
|
||||
WikipediaService *-- WebClient
|
||||
WikipediaService ..> ContextStrategy
|
||||
LlamaGenerator *-- PromptFormatter
|
||||
LlamaGenerator *-- IPromptDirectory
|
||||
LlamaGenerator ..> GeneratorOptions
|
||||
OpenAIGenerator *-- IPromptDirectory
|
||||
OpenAIGenerator *-- WebClient
|
||||
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
|
||||
@@ -1,646 +0,0 @@
|
||||
@startuml class_diagram
|
||||
|
||||
' ==========================================
|
||||
' CONFIGURATION & STYLING
|
||||
' ==========================================
|
||||
!include ../biergarten-weizen-theme.puml
|
||||
skinparam classAttributeFontSize 9
|
||||
skinparam defaultFontSize 25
|
||||
skinparam titleFontSize 30
|
||||
|
||||
title Biergarten Data Pipeline — Class Diagram
|
||||
|
||||
package "Domain: Models" {
|
||||
|
||||
class Location {
|
||||
+ city : std::string
|
||||
+ state_province : std::string
|
||||
+ iso3166_2 : std::string
|
||||
+ country : std::string
|
||||
+ iso3166_1 : std::string
|
||||
+ local_languages : std::vector<std::string>
|
||||
+ latitude : double
|
||||
+ longitude : double
|
||||
}
|
||||
|
||||
class LocationContext {
|
||||
+ text : std::string
|
||||
+ completeness : Completeness
|
||||
+ char_count : size_t
|
||||
}
|
||||
|
||||
enum Completeness {
|
||||
Full
|
||||
Partial
|
||||
Absent
|
||||
}
|
||||
|
||||
class EnrichedCity {
|
||||
+ location : Location
|
||||
+ context : LocationContext
|
||||
}
|
||||
|
||||
class BeerStyle {
|
||||
+ name : std::string
|
||||
+ description : std::string
|
||||
+ min_abv : float
|
||||
+ max_abv : float
|
||||
+ min_ibu : int
|
||||
+ max_ibu : int
|
||||
}
|
||||
|
||||
class BreweryResult {
|
||||
+ name_en : std::string
|
||||
+ description_en : std::string
|
||||
+ name_local : std::string
|
||||
+ description_local : std::string
|
||||
}
|
||||
|
||||
class BeerResult {
|
||||
+ name_en : std::string
|
||||
+ description_en : std::string
|
||||
+ name_local : std::string
|
||||
+ description_local : std::string
|
||||
+ style : std::string
|
||||
+ abv : float
|
||||
+ ibu : int
|
||||
}
|
||||
|
||||
class UserResult {
|
||||
+ first_name : std::string
|
||||
+ last_name : std::string
|
||||
+ gender : std::string
|
||||
+ username : std::string
|
||||
+ bio : std::string
|
||||
+ activity_weight : float
|
||||
}
|
||||
|
||||
class Name {
|
||||
+ first_name : std::string
|
||||
+ last_name : std::string
|
||||
+ gender : std::string
|
||||
}
|
||||
|
||||
class ForenameEntry {
|
||||
+ name : std::string
|
||||
+ gender : std::string
|
||||
}
|
||||
|
||||
note right of ForenameEntry
|
||||
forename_list = std::unordered_set<ForenameEntry>
|
||||
surname_list = std::unordered_set<std::string>
|
||||
(aliases declared in curated_data_service.h).
|
||||
ICuratedDataService::LoadForenamesByCountry() /
|
||||
LoadSurnamesByCountry() each return a flat
|
||||
std::unordered_map<std::string, forename_list>
|
||||
or <std::string, surname_list> keyed by ISO
|
||||
3166-1 country code -- no NamesByCountry
|
||||
wrapper class. SampleName(forenames_by_country,
|
||||
surnames_by_country, iso3166_1, rng) is a free
|
||||
function, not a method, so it can be called by
|
||||
any per-city worker without owning the maps.
|
||||
Sourced from popular-names-by-country-dataset
|
||||
(CC0) -- see ETHICS-AND-KNOWN-ISSUES.md. Gender
|
||||
is preserved per forename so it can be threaded
|
||||
through to persona/bio generation later; surnames
|
||||
carry no gender in the source data. Pairing
|
||||
happens at sample time, not at fixture-authoring
|
||||
time, so no information from the source dataset
|
||||
is discarded up front.
|
||||
end note
|
||||
|
||||
ForenameEntry ..> Name : SampleName() produces
|
||||
|
||||
class CheckinResult {
|
||||
+ checked_in_at : std::string
|
||||
+ note : std::string
|
||||
}
|
||||
|
||||
class RatingResult {
|
||||
+ score : float
|
||||
+ note : std::string
|
||||
}
|
||||
|
||||
class GenerationMetadata {
|
||||
+ generation_id : uint64_t
|
||||
+ generated_time : std::string
|
||||
+ context_provided : bool
|
||||
+ generated_with : std::string
|
||||
}
|
||||
|
||||
class GeneratedBrewery {
|
||||
+ brewery_id : uint64_t
|
||||
+ location : Location
|
||||
+ brewery : BreweryResult
|
||||
+ context_completeness : LocationContext::Completeness
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedBeer {
|
||||
+ beer_id : uint64_t
|
||||
+ brewery_id : uint64_t
|
||||
+ location : Location
|
||||
+ style : BeerStyle
|
||||
+ beer : BeerResult
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedUser {
|
||||
+ user_id : uint64_t
|
||||
+ location : Location
|
||||
+ user : UserResult
|
||||
+ email : std::string
|
||||
+ date_of_birth : std::string
|
||||
+ password : std::string
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
note right of GeneratedUser
|
||||
email, date_of_birth, and password are
|
||||
programmatically generated by the orchestrator
|
||||
(not LLM-authored) so a downstream auth-account
|
||||
seeding consumer can register real accounts from
|
||||
this export. See ROADMAP.md §1.
|
||||
end note
|
||||
|
||||
class GeneratedCheckin {
|
||||
+ checkin_id : uint64_t
|
||||
+ user_id : uint64_t
|
||||
+ brewery_id : uint64_t
|
||||
+ checkin : CheckinResult
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedRating {
|
||||
+ user_id : uint64_t
|
||||
+ beer_id : uint64_t
|
||||
+ checkin_id : uint64_t
|
||||
+ rating : RatingResult
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedFollow {
|
||||
+ follower_id : uint64_t
|
||||
+ followed_id : uint64_t
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class UserPersona {
|
||||
+ name: std::string
|
||||
+ description: std::string
|
||||
+ style_affinities: std::vector<std::string>
|
||||
}
|
||||
|
||||
LocationContext *-- Completeness
|
||||
}
|
||||
|
||||
package "Domain: Application Configuration" {
|
||||
class SamplingOptions {
|
||||
+ temperature: float = 1.0F
|
||||
+ top_p: float = 0.95F
|
||||
+ top_k: uint32_t = 64
|
||||
+ n_ctx: uint32_t = 8192
|
||||
+ seed: int = -1
|
||||
}
|
||||
|
||||
class GeneratorOptions {
|
||||
+ model_path: std::filesystem::path
|
||||
+ use_mocked: bool = false
|
||||
+ sampling: std::optional<SamplingOptions>
|
||||
}
|
||||
|
||||
class PipelineOptions {
|
||||
+ output_path: std::filesystem::path
|
||||
+ log_path: std::filesystem::path
|
||||
}
|
||||
|
||||
class ApplicationOptions {
|
||||
+ generator: GeneratorOptions
|
||||
+ pipeline: PipelineOptions
|
||||
}
|
||||
|
||||
ApplicationOptions *-- GeneratorOptions
|
||||
ApplicationOptions *-- PipelineOptions
|
||||
GeneratorOptions o-- SamplingOptions
|
||||
}
|
||||
|
||||
package "Domain: Policy" {
|
||||
|
||||
interface ContextStrategy <<interface>> {
|
||||
+ QueriesFor(loc : const Location&) : std::vector<std::string>
|
||||
+ MaxContextChars() : size_t
|
||||
}
|
||||
|
||||
class BreweryContextStrategy {
|
||||
+ QueriesFor(loc : const Location&) : std::vector<std::string>
|
||||
+ MaxContextChars() : size_t
|
||||
}
|
||||
|
||||
class BeerContextStrategy {
|
||||
+ QueriesFor(loc : const Location&) : std::vector<std::string>
|
||||
+ MaxContextChars() : size_t
|
||||
}
|
||||
|
||||
interface SamplingStrategy <<interface>> {
|
||||
+ Sample(locations : const std::vector<Location>&) : std::vector<Location>
|
||||
}
|
||||
|
||||
class UniformSamplingStrategy {
|
||||
- sample_size_ : size_t
|
||||
+ Sample(locations : const std::vector<Location>&) : std::vector<Location>
|
||||
}
|
||||
|
||||
interface BeerSelectionStrategy <<interface>> {
|
||||
+ SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span<const BeerStyle>) : std::vector<BeerStyle>
|
||||
}
|
||||
|
||||
class RandomBeerSelectionStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_beers_ : size_t
|
||||
- max_beers_ : size_t
|
||||
+ SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span<const BeerStyle>) : std::vector<BeerStyle>
|
||||
}
|
||||
|
||||
interface CheckinDistributionStrategy <<interface>> {
|
||||
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
|
||||
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
|
||||
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
|
||||
}
|
||||
|
||||
class JCurveCheckinStrategy {
|
||||
- rng_ : std::mt19937
|
||||
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
|
||||
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
|
||||
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
|
||||
}
|
||||
|
||||
class RandomCheckinStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_checkins_ : size_t
|
||||
- max_checkins_ : size_t
|
||||
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
|
||||
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
|
||||
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
|
||||
}
|
||||
|
||||
interface FollowGenerationStrategy <<interface>> {
|
||||
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
|
||||
}
|
||||
|
||||
class RandomFollowStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_follows_ : size_t
|
||||
- max_follows_ : size_t
|
||||
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
|
||||
}
|
||||
|
||||
class ActivityWeightedFollowStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_follows_ : size_t
|
||||
- max_follows_ : size_t
|
||||
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
|
||||
}
|
||||
}
|
||||
|
||||
package "Infrastructure: Logging" {
|
||||
enum LogLevel {
|
||||
Debug
|
||||
Info
|
||||
Warn
|
||||
Error
|
||||
}
|
||||
|
||||
enum PipelinePhase {
|
||||
Startup
|
||||
UserGeneration
|
||||
BreweryAndBeerGeneration
|
||||
CheckinGeneration
|
||||
RatingGeneration
|
||||
FollowGeneration
|
||||
Teardown
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
+ timestamp : std::chrono::system_clock::time_point
|
||||
+ level : LogLevel
|
||||
+ phase : PipelinePhase
|
||||
+ message : std::string
|
||||
+ worker : std::optional<std::string>
|
||||
}
|
||||
|
||||
interface ILogger <<interface>> {
|
||||
+ Log(entry : const LogEntry&) : void
|
||||
}
|
||||
|
||||
class LogProducer {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
+ Log(entry : const LogEntry&) : void
|
||||
}
|
||||
|
||||
class LogDispatcher {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
+ Run() : void
|
||||
- ToSpdlogLevel(level) : spdlog::level::level_enum
|
||||
}
|
||||
|
||||
LogEntry *-- LogLevel
|
||||
LogEntry *-- PipelinePhase
|
||||
ILogger <|.. LogProducer
|
||||
LogProducer ..> LogEntry : emits
|
||||
LogDispatcher ..> LogEntry : consumes
|
||||
}
|
||||
|
||||
package "Infrastructure: Pipeline Channel" {
|
||||
|
||||
class "BoundedChannel<T>" as BoundedChannel {
|
||||
- queue_ : std::queue<T>
|
||||
- mutex_ : std::mutex
|
||||
- not_full_ : std::condition_variable
|
||||
- not_empty_ : std::condition_variable
|
||||
- capacity_ : size_t
|
||||
- closed_ : bool
|
||||
+ Send(item : T) : void
|
||||
+ Receive() : std::optional<T>
|
||||
+ Close() : void
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Infrastructure: Data Preloading" {
|
||||
|
||||
interface ICuratedDataService <<interface>> {
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
class JsonLoader {
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
note right of JsonLoader
|
||||
Each Load* call is memoized after its first
|
||||
parse -- implemented today, ahead of the rest
|
||||
of this package (LoadBeerStyles is still planned).
|
||||
end note
|
||||
|
||||
class MockCuratedDataService {
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
note right of MockCuratedDataService
|
||||
Fixed in-memory dataset used in --mocked mode,
|
||||
implemented today for Locations/Personas/
|
||||
Forenames/Surnames; would need a LoadBeerStyles
|
||||
fixture too once that method is implemented.
|
||||
end note
|
||||
|
||||
}
|
||||
|
||||
package "Infrastructure: Enrichment" {
|
||||
|
||||
interface EnrichmentService <<interface>> {
|
||||
+ GetLocationContext(loc : const Location&,\n strategy : const ContextStrategy&) : LocationContext
|
||||
}
|
||||
|
||||
class WikipediaService {
|
||||
- client_ : std::unique_ptr<WebClient>
|
||||
- extract_cache_ : std::unordered_map<std::string, std::string>
|
||||
+ GetLocationContext(loc : const Location&,\n strategy : const ContextStrategy&) : LocationContext
|
||||
- FetchExtract(query : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface WebClient <<interface>> {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
class HttpWebClient {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Infrastructure: Prompting" {
|
||||
|
||||
interface IPromptDirectory <<interface>> {
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
class PromptDirectory {
|
||||
- prompt_dir_ : std::filesystem::path
|
||||
- cache_ : std::unordered_map<std::string, std::string>
|
||||
+ PromptDirectory(prompt_dir : const std::filesystem::path&)
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
IPromptDirectory <|.. PromptDirectory
|
||||
}
|
||||
|
||||
package "Infrastructure: Data Generation" {
|
||||
|
||||
interface DataGenerator <<interface>> {
|
||||
+ GenerateBrewery(location : const Location&,\n context : const LocationContext&) : BreweryResult
|
||||
+ GenerateBeer(brewery_id : uint64_t,\n location : const Location&,\n context : const LocationContext&,\n style : const BeerStyle&) : BeerResult
|
||||
+ GenerateUser(city : const EnrichedCity&,\n persona : const UserPersona&,\n name : const Name&) : UserResult
|
||||
+ GenerateCheckin(user : const GeneratedUser&,\n brewery : const GeneratedBrewery&,\n timestamp : const std::string&) : CheckinResult
|
||||
+ GenerateRating(user : const GeneratedUser&,\n beer : const GeneratedBeer&,\n checkin_id : uint64_t) : RatingResult
|
||||
}
|
||||
|
||||
class MockGenerator {
|
||||
+ GenerateBrewery(...) : BreweryResult
|
||||
+ GenerateBeer(...) : BeerResult
|
||||
+ GenerateUser(...) : UserResult
|
||||
+ GenerateCheckin(...) : CheckinResult
|
||||
+ GenerateRating(...) : RatingResult
|
||||
- DeterministicHash(location : const Location&) : size_t
|
||||
}
|
||||
|
||||
class LlamaGenerator {
|
||||
- model_ : ModelHandle
|
||||
- context_ : ContextHandle
|
||||
- prompt_formatter_ : std::unique_ptr<PromptFormatter>
|
||||
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
|
||||
- rng_ : std::mt19937
|
||||
+ GenerateBrewery(...) : BreweryResult
|
||||
+ GenerateBeer(...) : BeerResult
|
||||
+ GenerateUser(...) : UserResult
|
||||
+ GenerateCheckin(...) : CheckinResult
|
||||
+ GenerateRating(...) : RatingResult
|
||||
- Load(opts : const GeneratorOptions&) : void
|
||||
- Infer(system_prompt, user_prompt,\n max_tokens, grammar) : std::string
|
||||
- ValidateModelArchitecture() : void
|
||||
}
|
||||
|
||||
interface PromptFormatter <<interface>> {
|
||||
+ Format(system_prompt : std::string_view,\n user_prompt : std::string_view) : std::string
|
||||
+ ExpectedArchitecture() : std::string_view
|
||||
}
|
||||
|
||||
class Gemma4JinjaPromptFormatter {
|
||||
+ Format(...) : std::string
|
||||
+ ExpectedArchitecture() : std::string_view
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Infrastructure: Data Export" {
|
||||
|
||||
interface ExportService <<interface>> {
|
||||
+ Initialize() : void
|
||||
+ ProcessBrewery(brewery : const GeneratedBrewery&) : uint64_t
|
||||
+ ProcessBeer(beer : const GeneratedBeer&) : uint64_t
|
||||
+ ProcessUser(user : const GeneratedUser&) : uint64_t
|
||||
+ ProcessCheckin(checkin : const GeneratedCheckin&) : uint64_t
|
||||
+ ProcessRating(rating : const GeneratedRating&) : void
|
||||
+ ProcessFollow(follow : const GeneratedFollow&) : void
|
||||
+ Finalize() : void
|
||||
}
|
||||
|
||||
class SqliteExportService {
|
||||
- date_time_provider_ : std::unique_ptr<DateTimeProvider>
|
||||
- db_handle_ : SqliteDatabaseHandle
|
||||
- insert_location_stmt_ : SqliteStatementHandle
|
||||
- insert_brewery_stmt_ : SqliteStatementHandle
|
||||
- insert_beer_stmt_ : SqliteStatementHandle
|
||||
- insert_user_stmt_ : SqliteStatementHandle
|
||||
- insert_checkin_stmt_ : SqliteStatementHandle
|
||||
- insert_rating_stmt_ : SqliteStatementHandle
|
||||
- insert_follow_stmt_ : SqliteStatementHandle
|
||||
- transaction_open_ : bool
|
||||
- location_cache_ : std::unordered_map<std::string, uint64_t>
|
||||
- brewery_cache_ : std::unordered_map<std::string, uint64_t>
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const GeneratedBrewery&) : uint64_t
|
||||
+ ProcessRecord(beer : const GeneratedBeer&) : uint64_t
|
||||
+ ProcessRecord(user : const GeneratedUser&) : uint64_t
|
||||
+ ProcessRecord(checkin : const GeneratedCheckin&) : uint64_t
|
||||
+ ProcessRecord(rating : const GeneratedRating&) : void
|
||||
+ ProcessRecord(follow : const GeneratedFollow&) : void
|
||||
+ Finalize() : void
|
||||
- InitializeSchema() : void
|
||||
- PrepareStatements() : void
|
||||
- RollbackAndCloseNoThrow() : void
|
||||
- FinalizeStatements() : void
|
||||
}
|
||||
|
||||
interface DateTimeProvider <<interface>> {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
class SystemDateTimeProvider {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BiergartenPipelineOrchestrator {
|
||||
- curated_data_service_ : std::unique_ptr<ICuratedDataService>
|
||||
- enrichment_service_ : std::unique_ptr<EnrichmentService>
|
||||
- generator_ : std::unique_ptr<DataGenerator>
|
||||
- logger_ : std::unique_ptr<Logger>
|
||||
- exporter_ : std::unique_ptr<ExportService>
|
||||
- brewery_context_strategy_ : std::unique_ptr<ContextStrategy>
|
||||
- sampling_strategy_ : std::unique_ptr<SamplingStrategy>
|
||||
- beer_selection_strategy_ : std::unique_ptr<BeerSelectionStrategy>
|
||||
- checkin_strategy_ : std::unique_ptr<CheckinDistributionStrategy>
|
||||
- follow_strategy_ : std::unique_ptr<FollowGenerationStrategy>
|
||||
- beer_style_palette_ : std::vector<BeerStyle>
|
||||
- options_ : ApplicationOptions
|
||||
--
|
||||
- user_pool_ : std::vector<GeneratedUser>
|
||||
- brewery_pool_ : std::vector<GeneratedBrewery>
|
||||
- beer_pool_ : std::vector<GeneratedBeer>
|
||||
- checkin_pool_ : std::vector<GeneratedCheckin>
|
||||
- follow_pool_ : std::vector<GeneratedFollow>
|
||||
--
|
||||
+ Run() : bool
|
||||
- RunUserPhase(locations : const std::vector<Location>&) : void
|
||||
- RunBreweryAndBeerPhase(locations : const std::vector<Location>&) : void
|
||||
- RunCheckinPhase() : void
|
||||
- RunRatingPhase() : void
|
||||
- RunFollowPhase() : void
|
||||
}
|
||||
|
||||
' --- Orchestration Aggregations (Services & Strategies) ---
|
||||
BiergartenPipelineOrchestrator *-- ICuratedDataService
|
||||
BiergartenPipelineOrchestrator *-- EnrichmentService
|
||||
BiergartenPipelineOrchestrator *-- DataGenerator
|
||||
BiergartenPipelineOrchestrator *-- ExportService
|
||||
BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy
|
||||
BiergartenPipelineOrchestrator *-- FollowGenerationStrategy
|
||||
BiergartenPipelineOrchestrator *-- SamplingStrategy
|
||||
BiergartenPipelineOrchestrator *-- BeerSelectionStrategy
|
||||
BiergartenPipelineOrchestrator *-- ApplicationOptions
|
||||
BiergartenPipelineOrchestrator *-- Logger
|
||||
|
||||
' --- Orchestration Aggregations (Data Pools) ---
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedUser : user_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedBrewery : brewery_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedBeer : beer_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedCheckin : checkin_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedFollow : follow_pool_
|
||||
|
||||
' --- Interfaces & Implementations ---
|
||||
ICuratedDataService <|.. JsonLoader
|
||||
ICuratedDataService <|.. MockCuratedDataService
|
||||
Logger <|.. PipelineLogger
|
||||
ContextStrategy <|.. BreweryContextStrategy
|
||||
ContextStrategy <|.. BeerContextStrategy
|
||||
SamplingStrategy <|.. UniformSamplingStrategy
|
||||
BeerSelectionStrategy <|.. RandomBeerSelectionStrategy
|
||||
CheckinDistributionStrategy <|.. JCurveCheckinStrategy
|
||||
CheckinDistributionStrategy <|.. RandomCheckinStrategy
|
||||
FollowGenerationStrategy <|.. RandomFollowStrategy
|
||||
FollowGenerationStrategy <|.. ActivityWeightedFollowStrategy
|
||||
EnrichmentService <|.. WikipediaService
|
||||
WebClient <|.. HttpWebClient
|
||||
DataGenerator <|.. MockGenerator
|
||||
DataGenerator <|.. LlamaGenerator
|
||||
PromptFormatter <|.. Gemma4JinjaPromptFormatter
|
||||
ExportService <|.. SqliteExportService
|
||||
DateTimeProvider <|.. SystemDateTimeProvider
|
||||
|
||||
' --- Service Compositions & Dependencies ---
|
||||
WikipediaService *-- WebClient
|
||||
WikipediaService ..> ContextStrategy
|
||||
LlamaGenerator *-- PromptFormatter
|
||||
LlamaGenerator *-- IPromptDirectory
|
||||
LlamaGenerator ..> GeneratorOptions
|
||||
SqliteExportService *-- DateTimeProvider
|
||||
|
||||
' --- Cross-Component Aggregations (Held References) ---
|
||||
PipelineLogger o-- BoundedChannel : logs to
|
||||
LogWorker o-- BoundedChannel : drains from
|
||||
|
||||
' --- Domain Containment ---
|
||||
EnrichedCity *-- Location
|
||||
EnrichedCity *-- LocationContext
|
||||
GeneratedBrewery *-- Location
|
||||
GeneratedBrewery *-- BreweryResult
|
||||
GeneratedBrewery *-- GenerationMetadata
|
||||
GeneratedBeer *-- Location
|
||||
GeneratedBeer *-- BeerStyle
|
||||
GeneratedBeer *-- BeerResult
|
||||
GeneratedBeer *-- GenerationMetadata
|
||||
GeneratedUser *-- Location
|
||||
GeneratedUser *-- UserResult
|
||||
GeneratedUser *-- GenerationMetadata
|
||||
GeneratedCheckin *-- CheckinResult
|
||||
GeneratedCheckin *-- GenerationMetadata
|
||||
GeneratedRating *-- RatingResult
|
||||
GeneratedRating *-- GenerationMetadata
|
||||
GeneratedFollow *-- GenerationMetadata
|
||||
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 142 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 240 KiB |
609
docs/web/architecture.md
Normal file
609
docs/web/architecture.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the architecture of The Biergarten App.
|
||||
|
||||
## High-level overview
|
||||
|
||||
The Biergarten App is a monorepo split between the backend and the website:
|
||||
|
||||
- **Backend**: .NET 10 Web API with SQL Server, organized as vertical feature
|
||||
slices with MediatR
|
||||
- **Frontend**: React 19 + React Router 8 website in `web/frontend`
|
||||
- **Architecture Style**: Vertical-slice backend plus server-rendered React
|
||||
frontend
|
||||
|
||||
`archive/next-js-web-app/` contains an archived Next.js frontend, kept for
|
||||
reference.
|
||||
|
||||
## Diagrams
|
||||
|
||||
For visual representations, see:
|
||||
|
||||
- [Vertical Slice](diagrams/vertical-slice.md) - host wiring and project
|
||||
reference diagrams
|
||||
- [Deployment](diagrams/deployment.md) - Docker deployment diagram
|
||||
- [Database Schema](diagrams/database-schema.md) - database relationships
|
||||
|
||||
Authentication workflow diagrams, one per flow, under
|
||||
[`diagrams/auth/`](diagrams/auth/):
|
||||
|
||||
- [Registration](diagrams/auth/registration.md)
|
||||
- [Login](diagrams/auth/login.md)
|
||||
- [Refresh Token](diagrams/auth/refresh-token.md)
|
||||
- [Confirm User](diagrams/auth/confirm-user.md)
|
||||
- [Resend Confirmation](diagrams/auth/resend-confirmation.md)
|
||||
- [Update Username](diagrams/auth/update-username.md)
|
||||
- [Update Email](diagrams/auth/update-email.md)
|
||||
- [Update Password](diagrams/auth/update-password.md)
|
||||
- [Update Profile](diagrams/auth/update-profile.md)
|
||||
- [Delete Account](diagrams/auth/delete-account.md)
|
||||
- [Error Handling](diagrams/auth/error-handling.md) - shared
|
||||
`GlobalExceptionFilter` mapping used by all flows above
|
||||
|
||||
## Backend architecture
|
||||
|
||||
### Vertical-slice architecture pattern
|
||||
|
||||
The backend organizes business capabilities as feature slices instead of
|
||||
technical layers.
|
||||
|
||||
Each feature (`Features.Users`, `Features.Breweries`, `Features.Emails`,
|
||||
`Features.Locations`, `Features.PhotoUpload`) is a single project that owns its
|
||||
own MediatR commands/queries/handlers, validators, and repository end to end.
|
||||
|
||||
`Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no
|
||||
`Controllers/` folder; all three are invoked internally by other slices via
|
||||
MediatR/DI, never over HTTP:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph HOST["API.Core (thin host)"]
|
||||
direction LR
|
||||
H1["Program.cs wiring:<br/>MediatR + AddApplicationPart per slice"]
|
||||
H2["Swagger / OpenAPI"]
|
||||
H3["JWT auth middleware"]
|
||||
H4["GlobalExceptionFilter"]
|
||||
end
|
||||
|
||||
subgraph SLICES["Feature Slices"]
|
||||
direction LR
|
||||
S1["Features.Users<br/>Controllers, Commands/Queries,<br/>Handlers, Repository"]
|
||||
S2["Features.Breweries<br/>Controller, Commands/Queries,<br/>Handlers, Repository"]
|
||||
S3["Features.Emails<br/>(internal only)<br/>Commands, Handlers,<br/>EmailDispatcher"]
|
||||
S4["Features.Locations<br/>(internal only)<br/>Repository"]
|
||||
S5["Features.PhotoUpload<br/>(internal only)<br/>Commands, Handlers,<br/>Repository"]
|
||||
end
|
||||
|
||||
subgraph SHARED["Shared Projects"]
|
||||
direction LR
|
||||
SH1["Shared.Contracts<br/>ResponseBody envelope"]
|
||||
SH2["Shared.Application<br/>ValidationBehavior,<br/>cross-slice email commands"]
|
||||
end
|
||||
|
||||
subgraph DOMAIN["Domain Layer"]
|
||||
direction LR
|
||||
D1["Domain.Entities"]
|
||||
D2["Domain.Exceptions"]
|
||||
end
|
||||
|
||||
subgraph INFRA["Infrastructure Layer"]
|
||||
direction LR
|
||||
I1["Database.Connection"]
|
||||
I2["Infrastructure.Jwt"]
|
||||
I3["Infrastructure.PasswordHashing"]
|
||||
I4["Infrastructure.Email /<br/>Email.Templates"]
|
||||
I5["Infrastructure.FileUpload"]
|
||||
end
|
||||
|
||||
DB[("Database (SQL Server)")]
|
||||
|
||||
HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
|
||||
S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3
|
||||
S1 -- "ProjectReference<br/>(avatar upload)" --> S5
|
||||
|
||||
SLICES --> SHARED
|
||||
SLICES --> DOMAIN
|
||||
SLICES --> INFRA
|
||||
|
||||
INFRA --> DB
|
||||
|
||||
style S3 stroke-dasharray: 4 3
|
||||
style S4 stroke-dasharray: 4 3
|
||||
style S5 stroke-dasharray: 4 3
|
||||
```
|
||||
|
||||
### Layer responsibilities
|
||||
|
||||
#### API layer (`API.Core`)
|
||||
|
||||
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
|
||||
own
|
||||
|
||||
**Components**:
|
||||
|
||||
| Component | Description |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
|
||||
| `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
|
||||
| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
|
||||
| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- Every `Features.*` project (for controller/MediatR discovery)
|
||||
- `Shared.Contracts`, `Shared.Application`
|
||||
- `Infrastructure.Jwt` (for the auth middleware)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- No controllers, no business logic, no feature-specific contracts
|
||||
- Exists purely to host and wire up the feature slices
|
||||
|
||||
#### Feature slices (`Features.Users`, `Features.Breweries`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`)
|
||||
|
||||
**Purpose**: Each slice is the complete vertical for one business capability
|
||||
|
||||
**Components** (per slice):
|
||||
|
||||
| Component | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Controllers/` | HTTP endpoints, binding directly to Command/Query types as the request contract |
|
||||
| `Commands/<Operation>/` and `Queries/<Operation>/` | One folder per operation, each containing the Command/Query record, its `IRequestHandler`, and (for commands) a FluentValidation validator |
|
||||
| `Repository/` | The slice's own Dapper repository implementation |
|
||||
| `Dtos/` | Response shapes returned by query handlers (never the raw domain entity) |
|
||||
| `DependencyInjection/` | An `AddFeaturesX()` extension method registering the slice's repository/services |
|
||||
|
||||
##### Note
|
||||
|
||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||
commands sent from other slices, never over HTTP.
|
||||
|
||||
`Features.PhotoUpload` also has no `Controllers/` folder; its
|
||||
`UploadPhotoCommand` is sent by other features' handlers (for example, a future
|
||||
brewery photo upload command) rather than bound to an HTTP route directly.
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- `Domain.Entities`, `Domain.Exceptions`
|
||||
- `Database.Connection` (generic ADO.NET connection/command plumbing) plus
|
||||
whichever infrastructure project the slice needs (`Infrastructure.Jwt`/
|
||||
`Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/
|
||||
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
|
||||
PhotoUpload)
|
||||
- `Shared.Contracts`, `Shared.Application`
|
||||
- No other `Features.*` project, with one exception: `Features.Users` references
|
||||
`Features.PhotoUpload` directly for avatar uploads
|
||||
|
||||
**Rules**:
|
||||
|
||||
- All business logic for that feature lives in its command/query handlers
|
||||
- No direct controller-to-repository calls; everything flows through MediatR
|
||||
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
||||
|
||||
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
|
||||
|
||||
**Purpose**: The minimum cross-slice surface area required because every slice
|
||||
needs it, or because duplicating it four times would be worse than sharing it
|
||||
|
||||
**Components**:
|
||||
|
||||
| Project | Purpose |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns |
|
||||
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) |
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Kept deliberately small: this is the exception to "no slice depends on another
|
||||
slice".
|
||||
|
||||
#### Infrastructure layer
|
||||
|
||||
**Purpose**: Technical capabilities and external integrations, shared by
|
||||
whichever slices need them
|
||||
|
||||
**Components**:
|
||||
|
||||
| Project | Description |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. |
|
||||
| `Infrastructure.Jwt` | JWT token generation and validation. |
|
||||
| `Infrastructure.PasswordHashing` | Argon2id password hashing. |
|
||||
| `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). |
|
||||
| `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
|
||||
| `Infrastructure.FileUpload` | `IFileStorageProvider` abstraction over S3-compatible object storage, implemented by `S3FileStorageProvider` (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's `AmazonS3Client` with `ForcePathStyle = true`. |
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- Domain entities
|
||||
- External libraries (ADO.NET, JWT, Argon2, MailKit, AWSSDK.S3, and so on)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Implements technical concerns only, no business logic
|
||||
- Reusable across slices
|
||||
|
||||
#### Domain layer (`Domain.Entities`)
|
||||
|
||||
**Purpose**: Core business entities and models
|
||||
|
||||
**Components**:
|
||||
|
||||
| Entity | Description |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `UserAccount` | User profile data. |
|
||||
| `UserCredential` | Authentication credentials. |
|
||||
| `UserVerification` | Account verification state. |
|
||||
| `BreweryPost` | A user-submitted brewery listing. |
|
||||
| `BreweryPostLocation` | A brewery's address and coordinates. |
|
||||
| `City` | A city referenced by a brewery location. |
|
||||
| `StateProvince` | A state/province referenced by a city. |
|
||||
| `Country` | A country referenced by a state/province. |
|
||||
| `BeerStyle` | A beer style/category (schema table exists; no repository yet). |
|
||||
| `BeerPost` | A beer listing tied to a brewery and style (schema table exists; no repository yet). |
|
||||
| `Photo` | A photo uploaded by a user account, written via `Features.PhotoUpload`'s `IPhotoUploadRepository`. |
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- None (pure domain)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Plain Old CLR Objects (POCOs)
|
||||
- No framework dependencies
|
||||
- No infrastructure references
|
||||
- Represents business concepts
|
||||
|
||||
### Design patterns
|
||||
|
||||
#### Vertical slice + MediatR
|
||||
|
||||
**Purpose**: Organize code by feature instead of by technical layer, so
|
||||
everything needed to understand or change one capability lives in one project
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
|
||||
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||
`Query` (for example, `GetBreweryByIdQuery`)
|
||||
- Controllers generally bind directly to the Command/Query as the request body;
|
||||
there is no separate request DTO + mapping step for writes
|
||||
- The exception to this is for authenticated routes, when the current user id
|
||||
needs to be extracted from the authentication token to be used in a command.
|
||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
||||
MediatR pipeline before any handler executes
|
||||
- Query handlers map to a dedicated response `Dto`, so domain entities never
|
||||
leak
|
||||
|
||||
**Example**:
|
||||
|
||||
```csharp
|
||||
public record CreateBreweryCommand(Guid PostedById, string BreweryName, string Description, ...)
|
||||
: IRequest<BreweryDto>;
|
||||
|
||||
public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||
{
|
||||
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Repository pattern
|
||||
|
||||
**Purpose**: Abstract database access behind interfaces
|
||||
|
||||
**Implementation**: each slice owns its own repository, scoped to that feature
|
||||
only:
|
||||
|
||||
- `Features.Users/Repository/IUserListRepository.cs`,
|
||||
`Features.Users/Repository/IUserProfileRepository.cs`
|
||||
- `Features.Breweries/Repository/IBreweryRepository.cs`
|
||||
- `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs`
|
||||
- `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection
|
||||
factory every slice's repository builds on
|
||||
- `Database.Connection/DapperRepository.cs`: shared abstract base class every
|
||||
repository extends, providing `CreateConnection()` and a
|
||||
`RollbackQuietlyAsync()` helper that rolls back a transaction while swallowing
|
||||
any exception the rollback itself raises, so the exception that triggered the
|
||||
rollback is what propagates
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Testable (easy to mock)
|
||||
- Each slice's data access logic is self-contained
|
||||
|
||||
**Example**:
|
||||
|
||||
```csharp
|
||||
public interface IUserListRepository
|
||||
{
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
}
|
||||
```
|
||||
|
||||
#### Dependency injection
|
||||
|
||||
**Purpose**: Loose coupling and testability
|
||||
|
||||
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
|
||||
`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
|
||||
method that registers its repository and slice-internal services
|
||||
|
||||
**Lifetimes**:
|
||||
|
||||
- Scoped: Repositories, slice-internal services (per request)
|
||||
- Singleton: `DefaultSqlConnectionFactory`
|
||||
- Transient: Utilities, helpers
|
||||
|
||||
#### Direct SQL via Dapper/ADO.NET
|
||||
|
||||
**Purpose**: Keep data access explicit and colocated with the slice that owns it
|
||||
|
||||
**Strategy**:
|
||||
|
||||
- Each repository issues inline SQL through Dapper, which handles parameter
|
||||
binding.
|
||||
- Proximity search (`GET /api/brewery/locations/nearby`) filters and orders rows
|
||||
server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding
|
||||
a client-side distance calculation over every row.
|
||||
- Referential checks (for example, "does this `CityId` exist?") are explicit
|
||||
`SELECT 1 ...` existence checks in the repository method, run inside the same
|
||||
transaction as the write
|
||||
- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
|
||||
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
|
||||
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
||||
`ConflictException`, ...) directly when a check fails; `API.Core`'s
|
||||
`GlobalExceptionFilter` maps those exception types to HTTP status codes
|
||||
- Application focuses on orchestration; the database enforces integrity via
|
||||
keys, `CHECK` constraints, and cascades
|
||||
|
||||
See [Database](database.md) for the schema and the app's SQL error-handling
|
||||
approach in more detail.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
### Website (`web/frontend`)
|
||||
|
||||
The website is a React Router 8 application with server-side rendering enabled.
|
||||
|
||||
```text
|
||||
web/frontend/
|
||||
├── app/
|
||||
│ ├── components/ Shared UI: Navbar, FormField, SubmitButton, ToastProvider,
|
||||
│ │ RouteErrorState, ClientOnly (defers client-only children,
|
||||
│ │ for example Leaflet maps, past hydration)
|
||||
│ ├── features/ One folder per feature slice, each owning its own
|
||||
│ │ routes/, components/, hooks/, schemas, and server-side
|
||||
│ │ loader helpers:
|
||||
│ │ - account profile/dashboard management
|
||||
│ │ - auth login, register, confirm, logout
|
||||
│ │ - breweries index/directory/detail routes, Leaflet
|
||||
│ │ map components, nearby-search resource
|
||||
│ │ route, distance/address utils
|
||||
│ │ - catalog beers, beer styles
|
||||
│ │ - home landing page
|
||||
│ │ - theme theme switcher/guide
|
||||
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
|
||||
│ ├── routes.ts Route table (React Router 8 config-based routing)
|
||||
│ ├── root.tsx App shell and global providers
|
||||
│ └── app.css Theme tokens and global styling
|
||||
├── .storybook/ Storybook config and preview setup
|
||||
├── stories/ Storybook stories for shared UI and themes
|
||||
├── tests/playwright/ Storybook Playwright coverage
|
||||
└── package.json Frontend scripts and dependencies
|
||||
```
|
||||
|
||||
### Frontend responsibilities
|
||||
|
||||
- Render brewery discovery routes (`/breweries` index, `/breweries/directory`,
|
||||
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
|
||||
search
|
||||
- Render the auth demo, account dashboard, and theme guide routes
|
||||
- Manage cookie-backed website session state
|
||||
- Call the .NET API for login, registration, token refresh, confirmation, and
|
||||
brewery/location data
|
||||
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||
- Supply Storybook documentation and browser-based component verification
|
||||
|
||||
### Theme system
|
||||
|
||||
The website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
|
||||
|
||||
- Biergarten Lager
|
||||
- Biergarten Stout
|
||||
- Biergarten Cassis
|
||||
- Biergarten Weizen
|
||||
|
||||
All component styling should prefer semantic tokens such as `primary`,
|
||||
`success`, `surface`, and `highlight` instead of hard-coded color values.
|
||||
|
||||
### Archived frontend
|
||||
|
||||
`archive/next-js-web-app/` contains an archived Next.js frontend, kept for
|
||||
reference. Product and engineering documentation points to `web/frontend`.
|
||||
|
||||
## Security architecture
|
||||
|
||||
### Authentication flow
|
||||
|
||||
| Flow | Details |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Registration** | User submits credentials; password is hashed with Argon2id; user account is created as unverified and a confirmation email is dispatched via `Features.Emails`. |
|
||||
| **Email confirmation** | User follows the confirmation link/token from the email; `Features.Users`' `ConfirmUser` command marks the account as verified. |
|
||||
| **Login** | User submits credentials; password is verified against the hash; access and refresh JWTs are issued and returned to the client; neither is stored server-side. |
|
||||
| **Token refresh** | Client exchanges a valid, unexpired refresh token for a new access/refresh pair via `Features.Users`' `RefreshToken` command; refresh tokens are stateless JWTs, not tracked server-side, and the previous refresh token remains usable until its own expiration. |
|
||||
| **API requests** | Client sends the access JWT in the `Authorization` header; `JwtAuthenticationHandler` validates the token; request proceeds if valid. |
|
||||
|
||||
### Password security
|
||||
|
||||
**Algorithm**: Argon2id
|
||||
|
||||
| Setting | Value |
|
||||
| ----------- | ---------------------------------------------- |
|
||||
| Memory | 64MB |
|
||||
| Iterations | 4 |
|
||||
| Parallelism | 2 (hardcoded, to avoid thread-pool exhaustion) |
|
||||
| Salt | 128-bit (16 bytes) |
|
||||
| Hash | 256-bit (32 bytes) |
|
||||
|
||||
### JWT tokens
|
||||
|
||||
**Algorithm**: HS256 (HMAC-SHA256)
|
||||
|
||||
**Claims**:
|
||||
|
||||
| Claim | Meaning |
|
||||
| ------------- | -------------------- |
|
||||
| `sub` | User ID |
|
||||
| `unique_name` | Username |
|
||||
| `jti` | Unique token ID |
|
||||
| `iat` | Issued at timestamp |
|
||||
| `exp` | Expiration timestamp |
|
||||
|
||||
**Configuration** (appsettings.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"Jwt": {
|
||||
"ExpirationMinutes": 60,
|
||||
"Issuer": "biergarten-api",
|
||||
"Audience": "biergarten-users"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This block exists in `appsettings.json`, but nothing in the code currently reads
|
||||
it: actual token lifetimes come from the hardcoded `TokenServiceExpirationHours`
|
||||
constants, and `JwtInfrastructure` hardcodes `ValidateIssuer`/`ValidateAudience`
|
||||
to `false`, so `Issuer`/`Audience` are not enforced.
|
||||
|
||||
## Database architecture
|
||||
|
||||
For the table-by-table schema and the custom SQL error code scheme, see
|
||||
[Database](database.md).
|
||||
|
||||
### SQL-first philosophy
|
||||
|
||||
**Principles**:
|
||||
|
||||
1. Database is source of truth
|
||||
2. Queries live in the owning repository, not scattered across the app
|
||||
3. Database handles referential integrity
|
||||
4. Application orchestrates, database executes
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Performance optimization via execution plans
|
||||
- Query logic scoped to the repository that owns it, not spread across layers
|
||||
- Version-controlled schema (migrations)
|
||||
- Easier query profiling and tuning
|
||||
|
||||
### Migration strategy
|
||||
|
||||
**Tool**: DbUp
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Write a SQL script under `Database.Migrations/scripts/`
|
||||
2. Embed it in the `Database.Migrations` project
|
||||
3. `Database.Migrations` runs each script against the target database on
|
||||
startup, tracked so a script never re-runs once applied
|
||||
|
||||
**Migration Files**:
|
||||
|
||||
```
|
||||
scripts/
|
||||
└── 01-schema/
|
||||
├── 01-UserAccount.sql
|
||||
├── 02-Photo.sql
|
||||
├── ...
|
||||
├── 16-UserProfile.sql
|
||||
└── 17-UserAvatar.sql # one script per CREATE TABLE
|
||||
```
|
||||
|
||||
Each table has its own script, numbered so a table's foreign keys always point
|
||||
at an earlier-numbered script. As the schema evolves, new numbered scripts get
|
||||
added rather than editing an existing one in place, so DbUp's already-applied
|
||||
tracking stays valid.
|
||||
|
||||
### Data seeding
|
||||
|
||||
**Purpose**: Populate development/test databases with realistic data
|
||||
|
||||
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||
pipeline under `tooling/pipeline/` (see
|
||||
[Pipeline README](../pipeline/README.md))
|
||||
|
||||
**Seed Data**:
|
||||
|
||||
- Countries, state/provinces, and cities (via `Features.Locations`'
|
||||
`ILocationRepository`, which creates `Country`/`StateProvince` rows on demand
|
||||
while resolving a city)
|
||||
- User accounts (via `Features.Users`' repository)
|
||||
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||
|
||||
Tables that exist in the schema but have no repository yet ( `UserFollow`,
|
||||
`BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, `BreweryPostPhoto`)
|
||||
are not seeded either.
|
||||
|
||||
## Deployment architecture
|
||||
|
||||
### Docker containerization
|
||||
|
||||
**Container Structure**:
|
||||
|
||||
| Container | Purpose |
|
||||
| --------------------- | ------------------------------------------------------------ |
|
||||
| `sqlserver` | SQL Server 2022 |
|
||||
| `database.migrations` | Schema migration runner |
|
||||
| `database.seed` | Data seeder |
|
||||
| `api.core` | ASP.NET Core Web API |
|
||||
| `mailpit` | Local dev/test SMTP server + web UI (not used in production) |
|
||||
| `seaweedfs` | S3-compatible object storage for uploaded photos (dev only) |
|
||||
|
||||
**Environments**:
|
||||
|
||||
| Environment | Compose file |
|
||||
| ----------- | -------------------------- |
|
||||
| Development | `docker-compose.dev.yaml` |
|
||||
| Testing | `docker-compose.test.yaml` |
|
||||
| Production | `docker-compose.prod.yaml` |
|
||||
|
||||
For details, see [Docker Guide](docker.md).
|
||||
|
||||
### Health checks
|
||||
|
||||
- **SQL Server**: Validates database connectivity
|
||||
- **API**: Checks service health and dependencies
|
||||
|
||||
**Configuration**:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "sqlcmd health check"]
|
||||
interval: 10s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
```
|
||||
|
||||
## Testing architecture
|
||||
|
||||
### Test pyramid
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
IT["Integration Tests<br/>API.Specs (Reqnroll)"]
|
||||
UT["Unit Tests (per slice, handler tests)<br/>Features.Users.Tests, Features.Breweries.Tests,<br/>Features.Emails.Tests, Features.PhotoUpload.Tests<br/>(commands/queries/handlers, with repository/service<br/>dependencies mocked via Moq)"]
|
||||
|
||||
IT --- UT
|
||||
```
|
||||
|
||||
**Strategy**:
|
||||
|
||||
- Many unit tests (fast, isolated)
|
||||
- Fewer integration tests (slower, e2e)
|
||||
- Mock external dependencies
|
||||
- Test database for integration tests
|
||||
|
||||
For details, see [Testing Guide](testing.md).
|
||||
125
docs/web/database.md
Normal file
125
docs/web/database.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: SQL Server database schema, migrations, and error handling
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- sql
|
||||
- repository
|
||||
- database
|
||||
- sql-server
|
||||
- schema
|
||||
---
|
||||
|
||||
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
|
||||
convention. For the vertical-slice code that talks to this schema, see
|
||||
[Architecture](architecture.md).
|
||||
|
||||
## Schema
|
||||
|
||||
The full schema lives under
|
||||
`web/backend/Database/Database.Migrations/scripts/01-schema/` with one numbered
|
||||
script per table.
|
||||
|
||||
```
|
||||
01-schema
|
||||
├── 01-UserAccount.sql
|
||||
├── 02-Photo.sql
|
||||
├── 03-UserVerification.sql
|
||||
├── 04-UserCredential.sql
|
||||
├── 05-UserFollow.sql
|
||||
├── 06-Country.sql
|
||||
├── 07-StateProvince.sql
|
||||
├── 08-City.sql
|
||||
├── 09-BreweryPost.sql
|
||||
├── 10-BreweryPostLocation.sql
|
||||
├── 11-BreweryPostPhoto.sql
|
||||
├── 12-BeerStyle.sql
|
||||
├── 13-BeerPost.sql
|
||||
├── 14-BeerPostPhoto.sql
|
||||
├── 15-BeerPostComment.sql
|
||||
├── 16-UserProfile.sql
|
||||
└── 17-UserAvatar.sql
|
||||
```
|
||||
|
||||
Each script is run in order by `Database.Migrations` (DbUp) on startup. Each
|
||||
table is queried directly by the owning feature slice's repository (see
|
||||
[Direct SQL via Dapper/ADO.NET](architecture.md#direct-sql-via-dapperadonet)).
|
||||
|
||||
A generated DBML copy of the schema (for import into a diagramming tool such as
|
||||
dbdiagram.io) lives at `web/backend/Database/Database.Migrations/schema.dbml`.
|
||||
|
||||
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
||||
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
|
||||
updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
|
||||
|
||||
### Tables backed by the API today
|
||||
|
||||
| Table | Backed by | Notes |
|
||||
| --------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
|
||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
|
||||
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
|
||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
|
||||
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
|
||||
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
|
||||
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
|
||||
|
||||
### Tables defined in the schema but not yet wired to a feature slice
|
||||
|
||||
These exist so the schema can support planned functionality, but have no
|
||||
repository or seed data yet:
|
||||
|
||||
| Table | Purpose (from schema) |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
||||
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
|
||||
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet |
|
||||
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
||||
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
||||
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
||||
|
||||
## Managing Migrations (DbUp)
|
||||
|
||||
Migrations are handled by the `Database.Migrations` container using DbUp.
|
||||
Scripts are placed in the `scripts/` directory, applied in order, and tracked so
|
||||
that each one runs only once.
|
||||
|
||||
**Note:** The schema is currently considered volatile. At this stage of
|
||||
development, fresh database instances are provisioned for both development and
|
||||
testing, so no production data exists yet.
|
||||
|
||||
### How to Make Schema Changes
|
||||
|
||||
- **Now (no production data):** Update the schema by editing the original DDL
|
||||
statements directly.
|
||||
- **Later (with production data):** Add a new, numbered script for any schema
|
||||
changes. Never edit an existing script in place — DbUp tracks completion by
|
||||
script name, so modifying an already-applied script means the changes will be
|
||||
ignored on existing databases.
|
||||
|
||||
## Data seeding
|
||||
|
||||
`Database.Seed` populates a target database from data produced by the C++
|
||||
pipeline (see [Pipeline README](../pipeline/README.md)): countries,
|
||||
state/provinces, and cities (created on demand while resolving each brewery's
|
||||
location), user accounts, and brewery posts with locations. It doesn't call
|
||||
every feature slice that has a write path: `Photo`, for example, has a
|
||||
repository (`Features.PhotoUpload`) but no seed data.
|
||||
|
||||
## Database error handling
|
||||
|
||||
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
||||
rows against the `RowVersion` column.
|
||||
- On failure, the repository throws a `Domain.Exceptions` type directly
|
||||
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
||||
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
||||
409, 400, ...); an unmapped `SqlException` (for example, a genuine
|
||||
connectivity or constraint-violation failure) maps to 503.
|
||||
|
||||
- One exception: `Features.Users`' `DapperUserStore` does catch `SqlException`
|
||||
directly, to detect a duplicate-key race (SQL Server error 2601 or 2627)
|
||||
when two requests try to verify the same account concurrently; see
|
||||
`DapperUserStore.IsDuplicateKeyViolation`.
|
||||
5
docs/web/diagrams/auth/confirm-user.md
Normal file
5
docs/web/diagrams/auth/confirm-user.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Confirm User
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/confirm-user.mmd"
|
||||
```
|
||||
79
docs/web/diagrams/auth/confirm-user.mmd
Normal file
79
docs/web/diagrams/auth/confirm-user.mmd
Normal file
@@ -0,0 +1,79 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant ConfirmHandler as ConfirmUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/delete-account.md
Normal file
5
docs/web/diagrams/auth/delete-account.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Delete Account
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/delete-account.mmd"
|
||||
```
|
||||
65
docs/web/diagrams/auth/delete-account.mmd
Normal file
65
docs/web/diagrams/auth/delete-account.mmd
Normal file
@@ -0,0 +1,65 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant DeleteAccountHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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, and UserProfile cascade on delete<br/>(UserAvatar cascades transitively through UserProfile).<br/>Posts, comments, photos, and follows use ON DELETE NO ACTION<br/>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<br>"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
|
||||
11
docs/web/diagrams/auth/error-handling.md
Normal file
11
docs/web/diagrams/auth/error-handling.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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
|
||||
--8<-- "web/diagrams/auth/error-handling.mmd"
|
||||
```
|
||||
12
docs/web/diagrams/auth/error-handling.mmd
Normal file
12
docs/web/diagrams/auth/error-handling.mmd
Normal file
@@ -0,0 +1,12 @@
|
||||
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
|
||||
5
docs/web/diagrams/auth/login.md
Normal file
5
docs/web/diagrams/auth/login.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Login
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/login.mmd"
|
||||
```
|
||||
93
docs/web/diagrams/auth/login.mmd
Normal file
93
docs/web/diagrams/auth/login.mmd
Normal file
@@ -0,0 +1,93 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant LoginHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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: Parse stored PHC string ($argon2id$v=19$m=...,t=...,p=...$salt$hash), rehash password with the embedded salt/parameters, 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
|
||||
5
docs/web/diagrams/auth/refresh-token.md
Normal file
5
docs/web/diagrams/auth/refresh-token.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Refresh Token
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/refresh-token.mmd"
|
||||
```
|
||||
77
docs/web/diagrams/auth/refresh-token.mmd
Normal file
77
docs/web/diagrams/auth/refresh-token.mmd
Normal file
@@ -0,0 +1,77 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant RefreshHandler as RefreshTokenHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/registration.md
Normal file
5
docs/web/diagrams/auth/registration.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Registration
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/registration.mmd"
|
||||
```
|
||||
165
docs/web/diagrams/auth/registration.mmd
Normal file
165
docs/web/diagrams/auth/registration.mmd
Normal file
@@ -0,0 +1,165 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant RegHandler as RegisterUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant ProfileHandler as CreateUserProfileHandler<br/>(Profile slice)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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 2 (fixed, to avoid thread-pool exhaustion), hash output 32 bytes
|
||||
Argon2-->>UserMgr: PHC string in Argon2id format (algorithm, version, memory/time/parallelism params, salt, 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
|
||||
|
||||
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->>Mediator: Send(CreateUserProfileCommand(user.Id, ""))
|
||||
activate Mediator
|
||||
Mediator->>ProfileHandler: Handle(command)
|
||||
activate ProfileHandler
|
||||
ProfileHandler->>DB: INSERT INTO UserProfile (UserAccountId, Biography)
|
||||
activate DB
|
||||
DB-->>ProfileHandler: UserProfileId
|
||||
deactivate DB
|
||||
ProfileHandler-->>Mediator: UserProfileId
|
||||
deactivate ProfileHandler
|
||||
Mediator-->>RegHandler: UserProfileId
|
||||
deactivate Mediator
|
||||
|
||||
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 second of two cross-slice dispatches in this flow (profile creation is the first)
|
||||
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
|
||||
5
docs/web/diagrams/auth/resend-confirmation.md
Normal file
5
docs/web/diagrams/auth/resend-confirmation.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Resend Confirmation Email
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/resend-confirmation.mmd"
|
||||
```
|
||||
71
docs/web/diagrams/auth/resend-confirmation.mmd
Normal file
71
docs/web/diagrams/auth/resend-confirmation.mmd
Normal file
@@ -0,0 +1,71 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant ResendHandler as ResendConfirmationEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-email.md
Normal file
5
docs/web/diagrams/auth/update-email.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Email
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-email.mmd"
|
||||
```
|
||||
64
docs/web/diagrams/auth/update-email.mmd
Normal file
64
docs/web/diagrams/auth/update-email.mmd
Normal file
@@ -0,0 +1,64 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdateEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-password.md
Normal file
5
docs/web/diagrams/auth/update-password.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Password
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-password.mmd"
|
||||
```
|
||||
84
docs/web/diagrams/auth/update-password.mmd
Normal file
84
docs/web/diagrams/auth/update-password.mmd
Normal file
@@ -0,0 +1,84 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdatePasswordHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-profile.md
Normal file
5
docs/web/diagrams/auth/update-profile.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Profile
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-profile.mmd"
|
||||
```
|
||||
63
docs/web/diagrams/auth/update-profile.mmd
Normal file
63
docs/web/diagrams/auth/update-profile.mmd
Normal file
@@ -0,0 +1,63 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdateProfileHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-username.md
Normal file
5
docs/web/diagrams/auth/update-username.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Username
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-username.mmd"
|
||||
```
|
||||
68
docs/web/diagrams/auth/update-username.mmd
Normal file
68
docs/web/diagrams/auth/update-username.mmd
Normal file
@@ -0,0 +1,68 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdateUsernameHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
40
docs/web/diagrams/database-schema.md
Normal file
40
docs/web/diagrams/database-schema.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Database Schema
|
||||
|
||||
Five diagrams, one per schema category — mirrors the `identity` / `media` /
|
||||
`location` / `brewery` / `beer` schema split. Every table also has a
|
||||
`RowVersion ROWVERSION` column (optimistic concurrency), omitted below for
|
||||
brevity. Tables marked `(*)` have no repository/feature slice yet.
|
||||
|
||||
Boxes with no attribute list (e.g. `Photo` in the identity diagram) are owned by
|
||||
a different schema and shown only to keep the relationship visible; see the
|
||||
diagram named after that schema for its full definition.
|
||||
|
||||
## Identity (`identity`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/identity.mmd"
|
||||
```
|
||||
|
||||
## Media (`media`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/media.mmd"
|
||||
```
|
||||
|
||||
## Location (`location`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/location.mmd"
|
||||
```
|
||||
|
||||
## Brewery (`brewery`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/brewery.mmd"
|
||||
```
|
||||
|
||||
## Beer (`beer`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/beer.mmd"
|
||||
```
|
||||
38
docs/web/diagrams/database-schema/beer.mmd
Normal file
38
docs/web/diagrams/database-schema/beer.mmd
Normal file
@@ -0,0 +1,38 @@
|
||||
erDiagram
|
||||
BeerStyle ||--o{ Beer : "categorizes"
|
||||
Beer ||--o{ BeerPhoto : "has photos"
|
||||
Beer ||--o{ BeerComment : "has comments"
|
||||
|
||||
%% Cross-schema: defined in full in identity.mmd / brewery.mmd / media.mmd
|
||||
User ||--o{ Beer : "posts"
|
||||
Brewery ||--o{ Beer : "brews"
|
||||
User ||--o{ BeerComment : "comments"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
BeerComment {
|
||||
uniqueidentifier BeerPostCommentID PK
|
||||
nvarchar Comment
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier CommentedByID FK
|
||||
int Rating
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
16
docs/web/diagrams/database-schema/brewery.mmd
Normal file
16
docs/web/diagrams/database-schema/brewery.mmd
Normal file
@@ -0,0 +1,16 @@
|
||||
erDiagram
|
||||
Brewery ||--o| BreweryLoc : "has location"
|
||||
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||
Brewery ||--o{ Beer : "brews"
|
||||
|
||||
%% Cross-schema: defined in full in identity.mmd / location.mmd / media.mmd / beer.mmd
|
||||
User ||--o{ Brewery : "posts"
|
||||
|
||||
Brewery {
|
||||
uniqueidentifier BreweryPostID PK
|
||||
nvarchar BreweryName
|
||||
uniqueidentifier PostedByID FK
|
||||
nvarchar Description
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
53
docs/web/diagrams/database-schema/identity.mmd
Normal file
53
docs/web/diagrams/database-schema/identity.mmd
Normal file
@@ -0,0 +1,53 @@
|
||||
erDiagram
|
||||
User ||--o{ Cred : "has credentials"
|
||||
User ||--o| Verify : "has verification"
|
||||
User ||--o| Profile : "has profile"
|
||||
User ||--o{ Follow : "follows"
|
||||
User ||--o{ Follow : "followed by"
|
||||
|
||||
%% Cross-schema: defined in full in media.mmd / brewery.mmd / beer.mmd
|
||||
Profile ||--o{ Avatar : "has avatar history"
|
||||
User ||--o{ Photo : "uploads"
|
||||
User ||--o{ Brewery : "posts"
|
||||
User ||--o{ Beer : "posts"
|
||||
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
|
||||
}
|
||||
|
||||
Follow {
|
||||
uniqueidentifier UserFollowID PK
|
||||
uniqueidentifier UserAccountID FK
|
||||
uniqueidentifier FollowingID FK
|
||||
datetime CreatedAt
|
||||
}
|
||||
36
docs/web/diagrams/database-schema/location.mmd
Normal file
36
docs/web/diagrams/database-schema/location.mmd
Normal file
@@ -0,0 +1,36 @@
|
||||
erDiagram
|
||||
Country ||--o{ StateProvince : "has"
|
||||
StateProvince ||--o{ City : "has"
|
||||
City ||--o{ BreweryLoc : "located in"
|
||||
|
||||
%% Cross-schema: defined in full in brewery.mmd
|
||||
Brewery ||--o| BreweryLoc : "has location"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
BreweryLoc {
|
||||
uniqueidentifier BreweryPostLocationID PK
|
||||
uniqueidentifier BreweryPostID "FK, UK"
|
||||
nvarchar AddressLine1
|
||||
nvarchar AddressLine2 "nullable"
|
||||
nvarchar PostalCode
|
||||
uniqueidentifier CityID FK
|
||||
geography Coordinates "nullable"
|
||||
}
|
||||
39
docs/web/diagrams/database-schema/media.mmd
Normal file
39
docs/web/diagrams/database-schema/media.mmd
Normal file
@@ -0,0 +1,39 @@
|
||||
erDiagram
|
||||
Avatar }o--|| Photo : "uses photo"
|
||||
Photo ||--o{ BreweryPhoto : "used in"
|
||||
Photo ||--o{ BeerPhoto : "used in"
|
||||
|
||||
%% Cross-schema: defined in full in identity.mmd / brewery.mmd / beer.mmd
|
||||
Profile ||--o{ Avatar : "has avatar history"
|
||||
User ||--o{ Photo : "uploads"
|
||||
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||
Beer ||--o{ BeerPhoto : "has photos"
|
||||
|
||||
Photo {
|
||||
uniqueidentifier PhotoID PK
|
||||
nvarchar Hyperlink "nullable"
|
||||
uniqueidentifier UploadedByID FK
|
||||
datetime UploadedAt
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
|
||||
BreweryPhoto {
|
||||
uniqueidentifier BreweryPostPhotoID PK
|
||||
uniqueidentifier BreweryPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
|
||||
BeerPhoto {
|
||||
uniqueidentifier BeerPostPhotoID PK
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
48
docs/web/diagrams/deployment.md
Normal file
48
docs/web/diagrams/deployment.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Docker Deployment Architecture
|
||||
|
||||
Five diagrams, one per Compose file — mixing all five networks in one graph made
|
||||
it impossible to tell which containers actually talk to each other. Solid arrows
|
||||
are real container traffic; dashed arrows are `depends_on` startup gating with
|
||||
no data flowing over them.
|
||||
|
||||
## Development (`docker-compose.dev.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/dev.mmd"
|
||||
```
|
||||
|
||||
## Test (`docker-compose.test.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/test.mmd"
|
||||
```
|
||||
|
||||
## Production (`docker-compose.prod.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/prod.mmd"
|
||||
```
|
||||
|
||||
## Database-only (`docker-compose.db.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/db-only.mmd"
|
||||
```
|
||||
|
||||
## Minimal (`docker-compose.min.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/minimal.mmd"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
| Environment | Details |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
|
||||
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are 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`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
|
||||
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
|
||||
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
|
||||
| `.trx` results | Readable by Visual Studio, Azure DevOps, and GitHub Actions. |
|
||||
13
docs/web/diagrams/deployment/db-only.mmd
Normal file
13
docs/web/diagrams/deployment/db-only.mmd
Normal file
@@ -0,0 +1,13 @@
|
||||
sequenceDiagram
|
||||
box Database-only Environment (docker-compose.db.yaml)
|
||||
participant DbOnlyDB as SQL Server<br/>:1434
|
||||
participant DbOnlySeaweedFS as seaweedfs<br/>S3-compatible object storage
|
||||
participant DbOnlyMig as Migrations (run-once)
|
||||
participant DbOnlySeed as Seed (run-once)
|
||||
end
|
||||
|
||||
DbOnlyMig-->>DbOnlyDB: depends_on
|
||||
DbOnlyMig->>DbOnlyDB: Run migrations
|
||||
DbOnlySeed-->>DbOnlyMig: depends_on
|
||||
DbOnlySeed-->>DbOnlySeaweedFS: depends_on
|
||||
DbOnlySeed->>DbOnlyDB: Seed data
|
||||
20
docs/web/diagrams/deployment/dev.mmd
Normal file
20
docs/web/diagrams/deployment/dev.mmd
Normal file
@@ -0,0 +1,20 @@
|
||||
sequenceDiagram
|
||||
actor Developer
|
||||
participant Host as Docker Host
|
||||
box Development Environment (docker-compose.dev.yaml)
|
||||
participant DevDB as SQL Server 2022<br/>Biergarten Database<br/>:1434
|
||||
participant DevMig as Migrations (run-once)<br/>Database.Migrations
|
||||
participant DevSeed as Seed (run-once)<br/>Database.Seed
|
||||
participant DevAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||
participant DevMailpit as Mailpit<br/>:8025 Web UI / :1025 SMTP
|
||||
end
|
||||
|
||||
Developer->>Host: docker compose up
|
||||
DevMig-->>DevDB: depends_on (healthy)
|
||||
DevMig->>DevDB: Run migrations
|
||||
DevSeed-->>DevMig: depends_on
|
||||
DevSeed->>DevDB: Seed data
|
||||
DevAPI-->>DevSeed: depends_on
|
||||
DevAPI->>DevDB: Connect and serve
|
||||
DevAPI-->>DevMailpit: SMTP_HOST/PORT
|
||||
Host->>DevAPI: http://localhost:8080
|
||||
15
docs/web/diagrams/deployment/minimal.mmd
Normal file
15
docs/web/diagrams/deployment/minimal.mmd
Normal file
@@ -0,0 +1,15 @@
|
||||
sequenceDiagram
|
||||
box Minimal Environment (docker-compose.min.yaml)
|
||||
participant MinDB as SQL Server<br/>:1433
|
||||
participant MinSeaweedFS as seaweedfs<br/>S3-compatible object storage
|
||||
participant MinMig as Migrations (run-once)
|
||||
participant MinSeed as Seed (run-once)
|
||||
participant MinMailpit as Mailpit<br/>:8025 / :1025
|
||||
end
|
||||
|
||||
MinMig-->>MinDB: depends_on
|
||||
MinMig->>MinDB: Run migrations
|
||||
MinSeed-->>MinMig: depends_on
|
||||
MinSeed-->>MinSeaweedFS: depends_on
|
||||
MinSeed->>MinDB: Seed data
|
||||
Note over MinDB,MinMailpit: consumed by an externally run API.Core process (not shown)
|
||||
11
docs/web/diagrams/deployment/prod.mmd
Normal file
11
docs/web/diagrams/deployment/prod.mmd
Normal file
@@ -0,0 +1,11 @@
|
||||
sequenceDiagram
|
||||
box Production Environment (docker-compose.prod.yaml)
|
||||
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
|
||||
participant ProdMig as Migrations (run-once)
|
||||
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||
end
|
||||
|
||||
ProdMig-->>ProdDB: depends_on
|
||||
ProdMig->>ProdDB: Run migrations
|
||||
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
|
||||
ProdAPI->>ProdDB: Connect and serve
|
||||
29
docs/web/diagrams/deployment/test.mmd
Normal file
29
docs/web/diagrams/deployment/test.mmd
Normal file
@@ -0,0 +1,29 @@
|
||||
sequenceDiagram
|
||||
box Test Environment (docker-compose.test.yaml)
|
||||
participant TestDB as SQL Server, isolated instance<br/>Biergarten Test Database<br/>no host port published
|
||||
participant TestSeaweedFS as seaweedfs<br/>S3-compatible object storage
|
||||
participant TestMig as Migrations (test)
|
||||
participant TestSeed as Seed (test)<br/>minimal: test.user only
|
||||
participant Specs as API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)
|
||||
participant UnitTests as unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container
|
||||
participant FrontendTests as frontend.tests<br/>Playwright/Vitest
|
||||
end
|
||||
participant Results as test-results/ (mounted volume)
|
||||
|
||||
TestMig-->>TestDB: depends_on
|
||||
TestMig->>TestDB: Migrate
|
||||
TestSeed-->>TestMig: depends_on
|
||||
TestSeed-->>TestSeaweedFS: depends_on
|
||||
TestSeed->>TestDB: Seed
|
||||
|
||||
par Specs
|
||||
Specs-->>TestSeed: depends_on
|
||||
Specs->>TestDB: Integration test
|
||||
Specs->>Results: Export TRX
|
||||
and unit.tests
|
||||
UnitTests-->>TestSeed: depends_on
|
||||
UnitTests-->>TestDB: depends_on (startup ordering only, no real connection)
|
||||
UnitTests->>Results: Export TRX, one per slice
|
||||
and frontend.tests
|
||||
FrontendTests->>Results: Export results
|
||||
end
|
||||
103
docs/web/diagrams/vertical-slice.md
Normal file
103
docs/web/diagrams/vertical-slice.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# The Biergarten App — Vertical Slice Architecture
|
||||
|
||||
Two diagrams, split by concern: which host wires up which slice at startup, and
|
||||
which projects each slice actually compiles against. Mixing DI wiring and
|
||||
compile-time references in one graph is what made the old diagram unreadable.
|
||||
|
||||
## Host wiring
|
||||
|
||||
Each host (`API.Core`, `Database.Seed`) decides for itself which `Features.*`
|
||||
assemblies to register — there's no shared bootstrap. `Database.Migrations`
|
||||
doesn't wire up any slice; it applies schema directly.
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/vertical-slice/host-wiring.mmd"
|
||||
```
|
||||
|
||||
## Project reference dependencies
|
||||
|
||||
Solid arrows are compile-time project references. Dashed arrows are the two
|
||||
places slices talk to each other without a direct reference: the email MediatR
|
||||
contract routed through `Shared.Application`, and the one place that pattern is
|
||||
broken outright. Every DB-backed slice depends on the same three foundation
|
||||
projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
|
||||
as one box instead of fifteen separate arrows; see the notes for what else each
|
||||
of those foundation projects pulls in.
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/vertical-slice/project-references.mmd"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **API.Core**: Program.cs wiring only
|
||||
- MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly
|
||||
- Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter
|
||||
- Only `Features.Users` and `Features.Breweries` get `AddApplicationPart`
|
||||
(controllers)
|
||||
- `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers
|
||||
only
|
||||
- `Features.Locations` and `Features.Beers` not referenced by `API.Core` at
|
||||
all
|
||||
|
||||
- **Feature slices**: each owns its own controller (where present),
|
||||
commands/queries + handlers, validators, and Dapper repository
|
||||
- `Features.Users` covers auth (`Commands/Authentication`), account management
|
||||
(`Commands/Account`), and profile/avatar (`Commands/Profile`)
|
||||
- internally namespaced `Features.Auth`, despite project name
|
||||
`Features.Users`
|
||||
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked
|
||||
only via MediatR commands sent from other slices
|
||||
- `Features.Locations` has no controller; one MediatR handler
|
||||
(`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core`
|
||||
never scans it
|
||||
- `Features.Beers` is scaffolded (project + references only, no source files
|
||||
yet)
|
||||
|
||||
- **The one exception to slice isolation**: `Features.Users` takes a direct
|
||||
project reference to `Features.PhotoUpload`
|
||||
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
|
||||
- unlike the email flow, the command type lives in the target slice itself,
|
||||
not in `Shared.Application`
|
||||
|
||||
- **Shared.Application**:
|
||||
- `ValidationBehavior` (MediatR pipeline)
|
||||
- Cross-slice email commands (`SendRegistrationEmailCommand`,
|
||||
`SendResendConfirmationEmailCommand`)
|
||||
- MediatR command contract (not project reference)
|
||||
- Every slice (including `Features.Emails`) references it for the pipeline
|
||||
behavior alone
|
||||
|
||||
- **Domain.Entities**:
|
||||
- `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`,
|
||||
`UserAvatar`
|
||||
- `BreweryPost`, `BreweryPostLocation`
|
||||
- `City`, `StateProvince`, `Country`
|
||||
- `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for
|
||||
`BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no
|
||||
code)
|
||||
|
||||
- **Database.Connection**: the shared connection-factory project
|
||||
- each slice's own repository issues inline SQL via Dapper against it
|
||||
- no ORM, no stored procedures
|
||||
- Replaces the old `Infrastructure.Sql` project
|
||||
|
||||
- **Database.Migrations**: separate console app
|
||||
- `schema.dbml` + SQL scripts
|
||||
- applies DDL directly
|
||||
- distinct from `Database.Seed`, which loads data through the feature slices'
|
||||
own DI extensions
|
||||
|
||||
- **Database.Seed**:
|
||||
- calls each slice's `AddFeatures*()` DI extension directly
|
||||
- builds its own MediatR registration
|
||||
- no API host, no shared pipeline with `API.Core`
|
||||
|
||||
- **Omitted from the diagram**:
|
||||
- `Domain.Exceptions` (referenced by nearly every project directly, including
|
||||
all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
|
||||
- `Infrastructure.Configuration` (the shared options-binding project
|
||||
referenced by `Database.Connection`, `Infrastructure.Email`,
|
||||
`Infrastructure.FileUpload`, and `Features.Emails` directly)
|
||||
- both are plumbing-level dependencies of nearly everything above, and drawing
|
||||
them would just add a second copy of the same fan-in
|
||||
31
docs/web/diagrams/vertical-slice/host-wiring.mmd
Normal file
31
docs/web/diagrams/vertical-slice/host-wiring.mmd
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
config:
|
||||
flowchart:
|
||||
curve: stepAfter
|
||||
layout: elk
|
||||
---
|
||||
|
||||
flowchart TB
|
||||
API["API.Core<br/>(ASP.NET Core Web API)"]
|
||||
Seed["Database.Seed<br/>(console app)"]
|
||||
Migrations["Database.Migrations<br/>(console app)"]
|
||||
|
||||
subgraph SLICES["Feature Slices"]
|
||||
direction LR
|
||||
UsersSlice["Features.Users<br/>(controller)"]
|
||||
BrewerySlice["Features.Breweries<br/>(controller)"]
|
||||
PhotoSlice["Features.PhotoUpload<br/>(MediatR only)"]
|
||||
EmailsSlice["Features.Emails<br/>(MediatR only)"]
|
||||
LocationsSlice["Features.Locations<br/>(plain DI service)"]
|
||||
BeersSlice["Features.Beers<br/>(scaffolded, no code yet)"]
|
||||
end
|
||||
|
||||
API -->|AddApplicationPart + MediatR scan| UsersSlice
|
||||
API -->|AddApplicationPart + MediatR scan| BrewerySlice
|
||||
API -->|MediatR scan only| PhotoSlice
|
||||
API -->|MediatR scan only| EmailsSlice
|
||||
|
||||
Seed -->|AddFeaturesUsers| UsersSlice
|
||||
Seed -->|AddFeaturesBreweries| BrewerySlice
|
||||
Seed -->|AddFeaturesPhotoUpload| PhotoSlice
|
||||
Seed -->|AddFeaturesLocations| LocationsSlice
|
||||
43
docs/web/diagrams/vertical-slice/project-references.mmd
Normal file
43
docs/web/diagrams/vertical-slice/project-references.mmd
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
config:
|
||||
flowchart: {}
|
||||
layout: fixed
|
||||
---
|
||||
|
||||
flowchart TB
|
||||
subgraph SLICES["Feature Slices"]
|
||||
direction LR
|
||||
BrewerySlice["Features.Breweries"]
|
||||
LocationsSlice["Features.Locations"]
|
||||
BeersSlice["Features.Beers<br>(scaffolded)"]
|
||||
PhotoSlice["Features.PhotoUpload"]
|
||||
UsersSlice["Features.Users"]
|
||||
EmailsSlice["Features.Emails"]
|
||||
end
|
||||
|
||||
subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"]
|
||||
direction LR
|
||||
SharedContracts["Shared.Contracts"]
|
||||
Domain["Domain.Entities"]
|
||||
DBConn["Database.Connection"]
|
||||
end
|
||||
|
||||
subgraph INFRA["Slice-specific Infrastructure"]
|
||||
direction LR
|
||||
JWT["Infrastructure.Jwt"]
|
||||
PwdHash["Infrastructure.PasswordHashing"]
|
||||
FileUpload["Infrastructure.FileUpload"]
|
||||
Email["Infrastructure.Email"]
|
||||
EmailTemplates["Infrastructure.Email.Templates"]
|
||||
end
|
||||
|
||||
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
|
||||
UsersSlice -. sends email commands .-> SharedApp["Shared.Application<br>(email command contract)"]
|
||||
EmailsSlice -. handles email commands .-> SharedApp
|
||||
BrewerySlice --> FOUNDATION
|
||||
LocationsSlice --> FOUNDATION
|
||||
BeersSlice --> FOUNDATION
|
||||
PhotoSlice --> FOUNDATION & FileUpload
|
||||
UsersSlice --> FOUNDATION & JWT & PwdHash
|
||||
EmailsSlice --> Email & EmailTemplates
|
||||
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]
|
||||
@@ -1,4 +1,11 @@
|
||||
# Docker guide
|
||||
---
|
||||
title: Docker deployment, configuration, and troubleshooting
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- docker
|
||||
- docker-compose
|
||||
- deployment
|
||||
---
|
||||
|
||||
This document covers Docker deployment, configuration, and troubleshooting for
|
||||
The Biergarten App.
|
||||
@@ -14,8 +21,7 @@ The project uses Docker Compose to orchestrate multiple services:
|
||||
- React Router website (SSR frontend)
|
||||
- Test runners
|
||||
|
||||
See the [deployment diagram](diagrams-out/deployment.svg) for visual
|
||||
representation.
|
||||
See the [deployment diagram](diagrams/deployment.md) for visual representation.
|
||||
|
||||
## Docker Compose environments
|
||||
|
||||
@@ -100,7 +106,8 @@ frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests
|
||||
|
||||
# View results
|
||||
ls -la test-results/
|
||||
@@ -111,6 +118,13 @@ cat test-results/Features.Users.Tests.trx
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
`wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
|
||||
stopped, regardless of which one finishes first; `up --abort-on-container-exit`
|
||||
isn't used here because the one-shot `database.migrations`/`database.seed` jobs
|
||||
and `frontend.tests` (no database dependency) can exit before the other test
|
||||
containers, which would end the run prematurely. See
|
||||
[Testing](testing.md#running-tests-with-docker-recommended) for details.
|
||||
|
||||
### 3. Production (`docker-compose.prod.yaml`)
|
||||
|
||||
**Purpose**: Production-ready deployment
|
||||
@@ -123,8 +137,8 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
- Health checks enabled
|
||||
- Restart policies (unless-stopped)
|
||||
- Security hardening
|
||||
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core`
|
||||
are reachable only from other containers on `prodnet`
|
||||
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
|
||||
reachable only from other containers on `prodnet`
|
||||
|
||||
**Services**:
|
||||
|
||||
@@ -198,8 +212,8 @@ graph TD
|
||||
E[seaweedfs: container started] --> D
|
||||
```
|
||||
|
||||
In development, `api.core` also depends on `seaweedfs` (`condition:
|
||||
service_started`, not a health check) alongside `database.seed`.
|
||||
In development, `api.core` also depends on `seaweedfs`
|
||||
(`condition: service_started`, not a health check) alongside `database.seed`.
|
||||
|
||||
**Health Check Example** (SQL Server):
|
||||
|
||||
@@ -233,8 +247,7 @@ api.core:
|
||||
|
||||
- `sqlserverdata-dev` - Database files persist between restarts
|
||||
- `nuget-cache-dev` - NuGet package cache (speeds up builds)
|
||||
- `seaweedfsdata-dev` - SeaweedFS object storage data persists between
|
||||
restarts
|
||||
- `seaweedfsdata-dev` - SeaweedFS object storage data persists between restarts
|
||||
|
||||
**Testing**:
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# Environment variables
|
||||
---
|
||||
title: Environment variables for the backend, frontend, and Docker
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- environment-variables
|
||||
- configuration
|
||||
- secrets
|
||||
- docker
|
||||
---
|
||||
|
||||
This document covers the active environment variables used by the current
|
||||
Biergarten stack.
|
||||
This document covers the environment variables used by the Biergarten stack.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -19,10 +26,10 @@ Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
||||
|
||||
### Frontend (`web/frontend`)
|
||||
|
||||
The active website reads runtime values from the server environment for its auth
|
||||
and API integration. In Docker, the `frontend` service is wired into the same
|
||||
The website reads runtime values from the server environment for its auth and
|
||||
API integration. In Docker, the `frontend` service is wired into the same
|
||||
`web/.env.dev` / `.env.test` / `.env.prod` files as the backend; running it
|
||||
locally with `npm run dev` still reads from the shell environment.
|
||||
locally with `npm run dev` reads from the shell environment.
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -38,9 +45,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
||||
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
|
||||
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`,
|
||||
`SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`,
|
||||
`SEAWEEDFS_SECRET_ACCESS_KEY`)
|
||||
with freshly randomized ones via `openssl`. Non-secret values are left
|
||||
untouched. The output file is written with `600` permissions.
|
||||
`SEAWEEDFS_SECRET_ACCESS_KEY`) with freshly randomized ones via `openssl`.
|
||||
Non-secret values are left untouched. The output file is written with `600`
|
||||
permissions.
|
||||
|
||||
```bash
|
||||
cd web
|
||||
@@ -104,7 +111,8 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||
**Security Requirements**:
|
||||
|
||||
- Use at least 32 characters per secret
|
||||
- Use 127 or more characters in production
|
||||
- Use secrets generated from 127 or more random bytes in production (base64
|
||||
encoding turns 127 bytes into a longer string, well over 127 characters)
|
||||
- Generate secrets with a cryptographically secure random function
|
||||
- Never reuse secrets across token types or environments
|
||||
- Rotate secrets periodically in production
|
||||
@@ -170,10 +178,17 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||
```
|
||||
|
||||
`ASPNETCORE_URLS` is hardcoded directly in `docker-compose.dev.yaml` and
|
||||
`docker-compose.prod.yaml` (not sourced from the `.env` file), so it's required
|
||||
only for Docker. Outside Docker, `dotnet run` falls back to the `http` profile
|
||||
in `API.Core/Properties/launchSettings.json` (`http://localhost:5069`), so
|
||||
manual backend setup (see
|
||||
[Getting Started](getting-started.md#manual-backend-setup)) doesn't need to set
|
||||
it.
|
||||
|
||||
## Frontend variables (`web/frontend`)
|
||||
|
||||
The active website does not use the old Next.js/Prisma environment model. Its
|
||||
core runtime variables are:
|
||||
The website's core runtime variables are:
|
||||
|
||||
```bash
|
||||
API_BASE_URL=http://api.core:8080 # Base URL for the .NET API (container DNS name in Docker)
|
||||
@@ -184,8 +199,8 @@ PORT=3000 # Port the built server listens on
|
||||
|
||||
The `frontend` Docker service hardcodes `API_BASE_URL` to `http://api.core:8080`
|
||||
(the backend's in-network service name) and `PORT` to `3000`; only
|
||||
`SESSION_SECRET` is sourced from the env file, and `NODE_ENV` is set per
|
||||
compose file (`development` in dev, `production` in prod).
|
||||
`SESSION_SECRET` is sourced from the env file, and `NODE_ENV` is set per compose
|
||||
file (`development` in dev, `production` in prod).
|
||||
|
||||
### Frontend variable details
|
||||
|
||||
@@ -287,15 +302,15 @@ web/.env.prod # Production config (gitignored)
|
||||
**Setup**:
|
||||
|
||||
```bash
|
||||
cp web/.env.example web/.env.dev
|
||||
# Edit web/.env.dev with your values
|
||||
cd web
|
||||
./generate-env.sh .env.dev
|
||||
```
|
||||
|
||||
## Legacy frontend variables
|
||||
|
||||
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
|
||||
been removed from this active reference, since that app is retained for
|
||||
reference only and is not run as part of the active stack.
|
||||
See [Generating an env file](#generating-an-env-file) above — `generate-env.sh`
|
||||
randomizes the secret-bearing values, so it's the preferred way to create any
|
||||
`.env.dev`/`.env.test`/`.env.prod` file. A plain
|
||||
`cp web/.env.example web/.env.dev` works too, but leaves the placeholder secrets
|
||||
from the template in place; edit them manually if you go that route.
|
||||
|
||||
**Docker Compose Mapping**:
|
||||
|
||||
@@ -334,7 +349,7 @@ reference only and is not run as part of the active stack.
|
||||
| `PORT` | | ✓ | ✓ | No | Frontend server port |
|
||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes\*\* | API binding address |
|
||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||
@@ -343,6 +358,10 @@ reference only and is not run as part of the active stack.
|
||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
|
||||
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
|
||||
|
||||
\*\* Required for Docker, where it's hardcoded in the compose files. Outside
|
||||
Docker, `dotnet run` falls back to the `applicationUrl` in
|
||||
`launchSettings.json`.
|
||||
|
||||
## Validation
|
||||
|
||||
### Backend validation
|
||||
@@ -352,15 +371,15 @@ Variables are validated at startup:
|
||||
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
|
||||
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`,
|
||||
`SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`,
|
||||
`SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values)
|
||||
cause the application to fail with an `InvalidOperationException`
|
||||
`SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values) cause
|
||||
the application to fail with an `InvalidOperationException`
|
||||
- No minimum length is enforced on the token secrets in code; the "minimum 32
|
||||
characters" guidance above is a recommendation, not an enforced check
|
||||
|
||||
### Frontend validation
|
||||
|
||||
The active website relies on runtime defaults for local development and the
|
||||
surrounding server environment in deployed environments.
|
||||
The website relies on runtime defaults for local development and on the server
|
||||
environment when deployed.
|
||||
|
||||
- `API_BASE_URL` defaults to `http://localhost:8080`
|
||||
- `SESSION_SECRET` falls back to a development-only local secret
|
||||
@@ -1,12 +1,19 @@
|
||||
# Getting started
|
||||
---
|
||||
title: Getting started — local setup for backend and frontend
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- getting-started
|
||||
- setup
|
||||
- onboarding
|
||||
---
|
||||
|
||||
This guide covers local setup for the current Biergarten stack: the .NET backend
|
||||
in `web/backend` and the active React Router frontend in `web/frontend`.
|
||||
This guide covers local setup for the Biergarten stack: the .NET backend in
|
||||
`web/backend` and the React Router frontend in `web/frontend`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **.NET SDK 10+**
|
||||
- **Node.js 18+**
|
||||
- **Node.js 22+**
|
||||
- **Docker Desktop** or equivalent Docker Engine setup
|
||||
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||
|
||||
@@ -58,18 +65,19 @@ section for details on `generate-env.sh`.
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
||||
```
|
||||
|
||||
This starts SQL Server, migrations, seeding, the API, Mailpit (local dev
|
||||
SMTP), and SeaweedFS (local dev S3-compatible object storage for photo
|
||||
uploads).
|
||||
This starts SQL Server, migrations, seeding, the API, Mailpit (local dev SMTP),
|
||||
and SeaweedFS (local dev S3-compatible object storage for photo uploads).
|
||||
|
||||
Available endpoints:
|
||||
|
||||
- API Swagger: http://localhost:8080/swagger
|
||||
- Health Check: http://localhost:8080/health
|
||||
- Mailpit UI: http://localhost:8025
|
||||
- SeaweedFS S3 API: http://localhost:8333
|
||||
| Service | URL |
|
||||
| ---------------- | ----------------------------- |
|
||||
| API Swagger | http://localhost:8080/swagger |
|
||||
| Health Check | http://localhost:8080/health |
|
||||
| Mailpit UI | http://localhost:8025 |
|
||||
| SeaweedFS S3 API | http://localhost:8333 |
|
||||
|
||||
### 4. Start the active frontend
|
||||
### 4. Start the frontend
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
@@ -146,13 +154,13 @@ dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
||||
dotnet run --project API/API.Core/API.Core.csproj
|
||||
```
|
||||
|
||||
## Legacy frontend note
|
||||
## Archived frontend
|
||||
|
||||
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
|
||||
only and is not the active website.
|
||||
`archive/next-js-web-app/` contains an archived Next.js frontend, kept for
|
||||
reference; it is not part of the running stack.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Review [Architecture](../architecture.md)
|
||||
- Review [Architecture](architecture.md)
|
||||
- Run backend and frontend checks from [Testing](testing.md)
|
||||
- Use [Docker Guide](docker.md) for container troubleshooting
|
||||
@@ -1,4 +1,13 @@
|
||||
# Testing
|
||||
---
|
||||
title: Testing strategy — unit, integration, and Storybook suites
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- testing
|
||||
- ci
|
||||
- xunit
|
||||
- reqnroll
|
||||
- storybook
|
||||
---
|
||||
|
||||
This document describes the testing strategy and how to run tests for The
|
||||
Biergarten App.
|
||||
@@ -7,22 +16,17 @@ Biergarten App.
|
||||
|
||||
The project uses a multi-layered testing approach across backend and frontend:
|
||||
|
||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
|
||||
live, seeded database
|
||||
- **Features.\*.Tests** - One unit test project per backend feature slice
|
||||
(`Features.Users.Tests`, `Features.Breweries.Tests`,
|
||||
`Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that
|
||||
slice's command/query handlers, with dependencies mocked via Moq (no real
|
||||
database required)
|
||||
- **Storybook Vitest project** - Browser-based interaction tests for shared
|
||||
website stories
|
||||
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
|
||||
components
|
||||
| Suite | Purpose |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **API.Specs** | BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database |
|
||||
| **Features.\*.Tests** | One unit test project per backend feature slice (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that slice's command/query handlers, with dependencies mocked via Moq (no real database required) |
|
||||
| **Storybook Vitest project** | Browser-based interaction tests for shared website stories |
|
||||
| **Storybook Playwright suite** | Browser checks against Storybook-rendered components |
|
||||
|
||||
## Running tests with Docker (recommended)
|
||||
|
||||
The easiest way to run all tests is using Docker Compose, which sets up an
|
||||
isolated test environment:
|
||||
To run all tests, use Docker Compose, which sets up an isolated test
|
||||
environment:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
|
||||
@@ -42,8 +46,8 @@ This command:
|
||||
`wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
|
||||
stopped, regardless of which one finishes first. Long-running services
|
||||
(`sqlserver`, `seaweedfs`) and the one-shot `database.migrations` /
|
||||
`database.seed` jobs keep running in the background alongside them and are
|
||||
torn down separately (see below) — `wait` doesn't touch them.
|
||||
`database.seed` jobs keep running in the background alongside them and are torn
|
||||
down separately (see below); `wait` doesn't touch them.
|
||||
|
||||
### View test results
|
||||
|
||||
@@ -72,19 +76,6 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
|
||||
You can run individual test projects locally without Docker:
|
||||
|
||||
### Integration tests (API.Specs)
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
dotnet test API/API.Specs/API.Specs.csproj
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- SQL Server instance running
|
||||
- Database migrated and seeded
|
||||
- Environment variables set (DB connection, JWT secret)
|
||||
|
||||
### Feature slice unit tests
|
||||
|
||||
Each feature slice has its own test project, covering its command/query
|
||||
@@ -114,6 +105,19 @@ npm install
|
||||
npm run test:storybook
|
||||
```
|
||||
|
||||
### Integration tests (API.Specs)
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
dotnet test API/API.Specs/API.Specs.csproj
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- SQL Server instance running
|
||||
- Database migrated and seeded
|
||||
- Environment variables set (DB connection, JWT secret)
|
||||
|
||||
**Purpose**:
|
||||
|
||||
- Verifies shared stories such as form fields, submit buttons, navbar states,
|
||||
@@ -175,8 +179,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
||||
**Features.Breweries.Tests**:
|
||||
|
||||
- Brewery create/update/delete commands and get-by-id/get-all queries
|
||||
- Brewery location queries: all locations, and locations within a given range
|
||||
of a coordinate
|
||||
- Brewery location queries: all locations, and locations within a given range of
|
||||
a coordinate
|
||||
|
||||
**Features.Emails.Tests**:
|
||||
|
||||
@@ -186,9 +190,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
||||
|
||||
- Upload command handler: storage key construction, content type passthrough,
|
||||
and photo persistence via the mocked storage provider and repository
|
||||
- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature
|
||||
checks (including rejection of files with a mismatched or missing
|
||||
signature)
|
||||
- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature checks
|
||||
(including rejection of files with a mismatched or missing signature)
|
||||
|
||||
**Frontend UI Coverage**:
|
||||
|
||||
@@ -295,8 +298,8 @@ Features.Users.Tests/
|
||||
|
||||
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
|
||||
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
|
||||
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds
|
||||
a validator test file alongside its handler test).
|
||||
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a
|
||||
validator test file alongside its handler test).
|
||||
|
||||
## Writing tests
|
||||
|
||||
@@ -337,10 +340,10 @@ Scenario: User login with valid credentials
|
||||
|
||||
## Continuous integration
|
||||
|
||||
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push
|
||||
and pull request to `main`. It runs the same test Docker Compose
|
||||
configuration used locally, so backend and frontend tests execute in the same
|
||||
containerized environment in CI as they do on a developer machine:
|
||||
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push and
|
||||
pull request to `main`. It runs the same test Docker Compose configuration used
|
||||
locally, so backend and frontend tests execute in the same containerized
|
||||
environment in CI as they do on a developer machine:
|
||||
|
||||
```bash
|
||||
# CI/CD command
|
||||
@@ -351,19 +354,15 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
The workflow inspects the exit code of each test container
|
||||
(`test-env-api-specs`, `test-env-unit-tests`, `test-env-frontend-tests`)
|
||||
after `wait` returns and fails the job if any of them is non-zero.
|
||||
`./test-results/` is uploaded as a build artifact regardless of outcome.
|
||||
(`test-env-api-specs`, `test-env-unit-tests`, `test-env-frontend-tests`) after
|
||||
`wait` returns and fails the job if any of them is non-zero. `./test-results/`
|
||||
is uploaded as a build artifact regardless of outcome.
|
||||
|
||||
Earlier versions of this workflow used
|
||||
`up --abort-on-container-exit`, which stops the entire stack the instant
|
||||
*any* watched container exits. That's wrong here: the one-shot
|
||||
`database.migrations`/`database.seed` jobs are expected to exit 0 partway
|
||||
through, and even scoping the flag to just the three test services doesn't
|
||||
help, since `frontend.tests` (no database dependency) reliably finishes
|
||||
before `api.specs`/`unit.tests` even start, aborting them prematurely.
|
||||
`up -d` + `wait` avoids both races by not tying container teardown to any
|
||||
single container's exit.
|
||||
`up -d` + `wait` keeps container teardown independent of any single container's
|
||||
exit. The one-shot `database.migrations`/`database.seed` jobs exit 0 partway
|
||||
through the run, and `frontend.tests` (which has no database dependency)
|
||||
reliably finishes before `api.specs`/`unit.tests` start; because `wait` blocks
|
||||
on all three test containers, neither early exit ends the run prematurely.
|
||||
|
||||
Exit codes:
|
||||
|
||||
@@ -371,9 +370,9 @@ Exit codes:
|
||||
- Non-zero - Test failures occurred
|
||||
|
||||
The frontend's Storybook Vitest and Playwright suites run inside the
|
||||
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that
|
||||
same compose run, so no separate frontend CI step is needed. To run them
|
||||
locally without Docker:
|
||||
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that same
|
||||
compose run, so no separate frontend CI step is needed. To run them locally
|
||||
without Docker:
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
@@ -384,9 +383,9 @@ npm run test:storybook:playwright
|
||||
|
||||
### Running the workflow locally with `act`
|
||||
|
||||
[`act`](https://github.com/nektos/act) replays `.github/workflows/*.yml` on
|
||||
your own machine using Docker, so you can reproduce a CI run (or a fix for
|
||||
one) without pushing a branch.
|
||||
[`act`](https://github.com/nektos/act) replays `.github/workflows/*.yml` on your
|
||||
own machine using Docker, so you can reproduce a CI run (or a fix for one)
|
||||
without pushing a branch.
|
||||
|
||||
**Install** (macOS):
|
||||
|
||||
@@ -404,34 +403,31 @@ act push \
|
||||
--container-architecture linux/amd64
|
||||
```
|
||||
|
||||
- `-j containerized-tests` runs only that job (there's currently just the
|
||||
one).
|
||||
- `-P ubuntu-latest=catthehacker/ubuntu:act-latest` pins the runner image
|
||||
act uses to impersonate `ubuntu-latest`. Without it, act's first run
|
||||
prompts interactively to choose a default image size, which hangs/fails
|
||||
under a non-interactive shell.
|
||||
- `-j containerized-tests` runs only that job (there's currently just the one).
|
||||
- `-P ubuntu-latest=catthehacker/ubuntu:act-latest` pins the runner image act
|
||||
uses to impersonate `ubuntu-latest`. Without it, act's first run prompts
|
||||
interactively to choose a default image size, which hangs/fails under a
|
||||
non-interactive shell.
|
||||
- `--container-architecture linux/amd64` is required on Apple Silicon: the
|
||||
`sqlserver` service's image is amd64-only, and act needs to know to
|
||||
emulate that platform for the whole job container, not just that one
|
||||
service.
|
||||
- No secrets or `.actrc` are needed — `generate-env.sh` creates its own
|
||||
`sqlserver` service's image is amd64-only, and act needs to know to emulate
|
||||
that platform for the whole job container, not just that one service.
|
||||
- No secrets or `.actrc` are needed: `generate-env.sh` creates its own
|
||||
`.env.test` with freshly randomized values, same as in real CI.
|
||||
|
||||
**Known act-only limitation**: the "Upload test results" step
|
||||
(`actions/upload-artifact@v4`) fails locally with `Unable to get the
|
||||
ACTIONS_RUNTIME_TOKEN env variable`. act doesn't provide a real Actions
|
||||
artifact backend, so this step — and only this step — is expected to fail
|
||||
under act even when everything else passes. It works normally on
|
||||
(`actions/upload-artifact@v4`) fails locally with
|
||||
`Unable to get the ACTIONS_RUNTIME_TOKEN env variable`. act doesn't provide a
|
||||
real Actions artifact backend, so this step, and only this step, is expected to
|
||||
fail under act even when everything else passes. It works normally on
|
||||
GitHub-hosted runners.
|
||||
|
||||
**Stale local state**: `docker-compose.test.yaml` uses fixed container
|
||||
names and named volumes (`sqlserverdata-test`, `seaweedfsdata-test`) scoped
|
||||
to the `web` compose project. If a previous local run (via `act` or a
|
||||
manual `docker compose` invocation) didn't get torn down, its SQL Server
|
||||
volume can persist with an old `SA_PASSWORD` baked in, while a fresh
|
||||
`.env.test` generates a new one each run — causing `Login failed for user
|
||||
'sa'` errors that look like a test bug but are really a leftover volume.
|
||||
Clear it before re-running:
|
||||
**Stale local state**: `docker-compose.test.yaml` uses fixed container names and
|
||||
named volumes (`sqlserverdata-test`, `seaweedfsdata-test`) scoped to the `web`
|
||||
compose project. If a previous local run (via `act` or a manual `docker compose`
|
||||
invocation) didn't get torn down, its SQL Server volume can persist with an old
|
||||
`SA_PASSWORD` baked in, while a fresh `.env.test` generates a new one each run,
|
||||
causing `Login failed for user 'sa'` errors that look like a test bug but are
|
||||
really a leftover volume. Clear it before re-running:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
@@ -1,14 +1,22 @@
|
||||
# Token validation architecture
|
||||
---
|
||||
title: JWT token validation architecture
|
||||
last-updated: 2026-08-31
|
||||
tags:
|
||||
- jwt
|
||||
- authentication
|
||||
- security
|
||||
- tokens
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Core project validates JSON Web Tokens (JWTs) across three token types:
|
||||
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
|
||||
|
||||
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
||||
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
|
||||
tokens
|
||||
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email
|
||||
confirmation
|
||||
| Token Type | Lifetime | Purpose |
|
||||
| ----------------------- | ---------- | --------------------------- |
|
||||
| **Access Tokens** | 1 hour | API authentication |
|
||||
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
|
||||
| **Confirmation Tokens** | 30 minutes | Email confirmation |
|
||||
|
||||
## Components
|
||||
|
||||
@@ -34,22 +42,27 @@ Low-level JWT operations.
|
||||
|
||||
#### [ITokenService](../../web/backend/Features/Features.Users/Services/ITokenService.cs)
|
||||
|
||||
Both token generation and validation live on the same slice-internal service
|
||||
(there is no separate validation service/class).
|
||||
Token generation and validation live on the same slice-internal service.
|
||||
|
||||
**Generation methods:**
|
||||
|
||||
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
|
||||
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
|
||||
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation
|
||||
- `GenerateAccessToken(Guid userId, string username)` - Creates 1-hour access
|
||||
token
|
||||
- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day refresh
|
||||
token
|
||||
- `GenerateConfirmationToken(Guid userId, string username)` - Creates 30-minute
|
||||
confirmation token
|
||||
|
||||
**Validation methods:**
|
||||
|
||||
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
|
||||
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
|
||||
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
|
||||
|
||||
There is no `ValidateAccessTokenAsync` on `ITokenService`. Access-token
|
||||
validation doesn't go through `ITokenService` at all: `JwtAuthenticationHandler`
|
||||
(the host-level auth middleware) calls `ITokenInfrastructure.ValidateJwtAsync()`
|
||||
directly, bypassing the `Features.Users` slice entirely.
|
||||
|
||||
**Returns (validation):** `ValidatedToken` record containing:
|
||||
|
||||
- `UserId` (Guid)
|
||||
@@ -65,10 +78,9 @@ Both token generation and validation live on the same slice-internal service
|
||||
|
||||
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()`
|
||||
extension method, except for the lower-level `ITokenInfrastructure` (JWT
|
||||
signing/verification) it
|
||||
depends on, which is registered by the host (`API.Core/Program.cs`) since
|
||||
`JwtAuthenticationHandler` (host-level auth middleware) also depends on it
|
||||
directly.
|
||||
signing/verification) it depends on, which is registered by the host
|
||||
(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
|
||||
middleware) also depends on it directly.
|
||||
|
||||
### Integration points
|
||||
|
||||
@@ -78,10 +90,20 @@ directly.
|
||||
|
||||
1. Receives confirmation token from user via `ConfirmUserCommand`
|
||||
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
|
||||
3. Extracts user ID from validated token and looks it up via `UserManager<ApplicationUser>`
|
||||
4. Marks the account confirmed via `IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`
|
||||
3. Extracts user ID from validated token and looks it up via
|
||||
`UserManager<ApplicationUser>`
|
||||
4. Marks the account confirmed via
|
||||
`IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`, then persists it
|
||||
via `UserManager<ApplicationUser>.UpdateAsync()`
|
||||
5. Returns confirmation result
|
||||
|
||||
`UserManager<ApplicationUser>` and `IUserEmailStore<ApplicationUser>` are
|
||||
ASP.NET Core Identity's abstractions, not EF Core's default Identity store: the
|
||||
app backs them with its own `DapperUserStore` (see
|
||||
[Database](database.md#database-error-handling)), which persists against the
|
||||
existing `UserAccount`/`UserCredential`/`UserVerification` tables via Dapper,
|
||||
consistent with the rest of the app's repository pattern.
|
||||
|
||||
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
|
||||
|
||||
**Flow:**
|
||||
@@ -156,40 +178,45 @@ Each token is validated for:
|
||||
|
||||
### Error handling
|
||||
|
||||
Validation failures return HTTP 401 Unauthorized:
|
||||
|
||||
- Invalid signature → "Invalid token"
|
||||
- Expired token → "Invalid token" (message doesn't reveal reason for security)
|
||||
- Missing claims → "Invalid token"
|
||||
- Malformed claims → "Invalid token"
|
||||
Validation failures return HTTP 401 Unauthorized. Regardless of the specific
|
||||
cause (invalid signature, expired token, missing header, malformed claims),
|
||||
`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same generic
|
||||
message: `"Unauthorized: Invalid or missing authentication token"` — it doesn't
|
||||
distinguish failure reasons in the response.
|
||||
|
||||
## Token lifecycle
|
||||
|
||||
### Access token lifecycle
|
||||
|
||||
1. **Generation**: During login (1-hour validity)
|
||||
2. **Usage**: Included in Authorization header on API requests
|
||||
3. **Validation**: Validated on protected endpoints
|
||||
4. **Expiration**: Token becomes invalid after 1 hour
|
||||
5. **Refresh**: Use refresh token to obtain new access token
|
||||
| Step | Description |
|
||||
| ---------- | ------------------------------------------------ |
|
||||
| Generation | During login (1-hour validity) |
|
||||
| Usage | Included in Authorization header on API requests |
|
||||
| Validation | Validated on protected endpoints |
|
||||
| Expiration | Token becomes invalid after 1 hour |
|
||||
| Refresh | Use refresh token to obtain new access token |
|
||||
|
||||
### Refresh token lifecycle
|
||||
|
||||
1. **Generation**: During login (21-day validity)
|
||||
2. **Storage**: Client-side (secure storage)
|
||||
3. **Usage**: Posted to `/api/auth/refresh` endpoint
|
||||
4. **Validation**: Validated by `ITokenService.RefreshTokenAsync()`
|
||||
5. **Rotation**: New refresh token issued on successful refresh
|
||||
6. **Expiration**: Token becomes invalid after 21 days
|
||||
| Step | Description |
|
||||
| ---------- | ------------------------------------------------ |
|
||||
| Generation | During login (21-day validity) |
|
||||
| Storage | Client-side (secure storage) |
|
||||
| Usage | Posted to `/api/auth/refresh` endpoint |
|
||||
| Validation | Validated by `ITokenService.RefreshTokenAsync()` |
|
||||
| Rotation | New refresh token issued on successful refresh |
|
||||
| Expiration | Token becomes invalid after 21 days |
|
||||
|
||||
### Confirmation token lifecycle
|
||||
|
||||
1. **Generation**: During user registration (30-minute validity)
|
||||
2. **Delivery**: Emailed to user in confirmation link
|
||||
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
|
||||
4. **Validation**: Validated by `ConfirmUserHandler`
|
||||
5. **Completion**: User account marked as confirmed
|
||||
6. **Expiration**: Token becomes invalid after 30 minutes
|
||||
| Step | Description |
|
||||
| ---------- | ----------------------------------------------------- |
|
||||
| Generation | During user registration (30-minute validity) |
|
||||
| Delivery | Emailed to user in confirmation link |
|
||||
| Usage | User clicks link, token posted to `/api/auth/confirm` |
|
||||
| Validation | Validated by `ConfirmUserHandler` |
|
||||
| Completion | User account marked as confirmed |
|
||||
| Expiration | Token becomes invalid after 30 minutes |
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -249,22 +276,18 @@ All of the following live in `Features.Users.Tests`.
|
||||
|
||||
### Stretch goals
|
||||
|
||||
1. **Middleware for Access Token Validation**
|
||||
- Automatically validate access tokens on protected routes
|
||||
- Populate HttpContext.User from token claims
|
||||
- Return 401 for invalid/missing tokens
|
||||
1. **Middleware for Access Token Validation** - Automatically validate access
|
||||
tokens on protected routes - Populate HttpContext.User from token claims -
|
||||
Return 401 for invalid/missing tokens
|
||||
|
||||
2. **Token Denylisting**
|
||||
- Implement token revocation (for example, on logout)
|
||||
- Store denylisted tokens in cache/database
|
||||
- Check the denylist during validation
|
||||
2. **Token Denylisting** - Implement token revocation (for example, on logout) -
|
||||
Store denylisted tokens in cache/database - Check the denylist during
|
||||
validation
|
||||
|
||||
3. **Refresh Token Rotation Strategy**
|
||||
- Detect token reuse (replay attacks)
|
||||
- Automatically invalidate entire token chain on reuse
|
||||
- Log suspicious activity
|
||||
3. **Refresh Token Rotation Strategy** - Detect token reuse (replay attacks) -
|
||||
Automatically invalidate entire token chain on reuse - Log suspicious
|
||||
activity
|
||||
|
||||
4. **Structured Logging**
|
||||
- Log token validation attempts
|
||||
- Track failed validation reasons
|
||||
- Alert on repeated validation failures (brute force detection)
|
||||
4. **Structured Logging** - Log token validation attempts - Track failed
|
||||
validation reasons - Alert on repeated validation failures (brute force
|
||||
detection)
|
||||
@@ -1,87 +0,0 @@
|
||||
# Database
|
||||
|
||||
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
|
||||
convention. For the vertical-slice code that talks to this schema, see
|
||||
[Architecture](../architecture.md).
|
||||
|
||||
## Schema
|
||||
|
||||
The full schema lives under
|
||||
`web/backend/Database/Database.Migrations/scripts/01-schema/`, one numbered
|
||||
script per table (`01-UserAccount.sql`, `02-Photo.sql`, ...), run in order by
|
||||
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
|
||||
owning feature slice's repository (see
|
||||
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
|
||||
|
||||
A generated DBML copy of the schema (for import into a diagramming tool such
|
||||
as dbdiagram.io) lives at
|
||||
`web/backend/Database/Database.Migrations/schema.dbml`.
|
||||
|
||||
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
||||
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
|
||||
updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
|
||||
|
||||
### Tables backed by the API today
|
||||
|
||||
| Table | Backed by | Notes |
|
||||
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
|
||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
|
||||
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
|
||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
|
||||
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
|
||||
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
|
||||
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
|
||||
|
||||
### Tables defined in the schema but not yet wired to a feature slice
|
||||
|
||||
These exist so the schema can support planned functionality, but have no
|
||||
repository or seed data yet:
|
||||
|
||||
| Table | Purpose (from schema) |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
||||
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
|
||||
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet |
|
||||
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
||||
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
||||
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
||||
|
||||
## Migrations
|
||||
|
||||
**Tool**: DbUp, run by the `Database.Migrations` project/container.
|
||||
|
||||
Scripts live under `scripts/`, applied in order and tracked so a script never
|
||||
re-runs once it has succeeded. Today that's `01-schema/`, with one script per
|
||||
table, numbered so a table's foreign keys always point at an earlier-numbered
|
||||
script. When the schema needs to change, add a new numbered script rather than
|
||||
editing an existing one in place — DbUp's tracking is per-script, so editing
|
||||
an already-applied script means it won't re-run against existing databases.
|
||||
|
||||
## Data seeding
|
||||
|
||||
`Database.Seed` populates a target database from data produced by the C++
|
||||
pipeline (see [Pipeline README](../pipeline/README.md)): countries,
|
||||
state/provinces, and cities (created on demand while resolving each brewery's
|
||||
location), user accounts, and brewery posts with locations. It doesn't call
|
||||
every feature slice that has a write path — `Photo`, for example, has a
|
||||
repository (`Features.PhotoUpload`) but no seed data.
|
||||
|
||||
## Database error handling
|
||||
|
||||
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
||||
rows against the `RowVersion` column.
|
||||
- On failure, the repository throws a `Domain.Exceptions` type directly
|
||||
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
||||
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
||||
409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
|
||||
constraint-violation failure) maps to 503.
|
||||
|
||||
- One exception: `Features.Users`' `DapperUserStore` does catch
|
||||
`SqlException` directly, to detect a duplicate-key race (SQL Server error
|
||||
2601 or 2627) when two requests try to verify the same account
|
||||
concurrently — see `DapperUserStore.IsDuplicateKeyViolation`.
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 50 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 76 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 108 KiB |
@@ -1,146 +0,0 @@
|
||||
@startuml architecture
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title The Biergarten App - Vertical Slice Architecture
|
||||
|
||||
package "API.Core (thin host)" #E3F2FD {
|
||||
[API.Core\nASP.NET Core Web API host] as API
|
||||
note right of API
|
||||
- Program.cs wiring only
|
||||
- MediatR + AddApplicationPart
|
||||
per Features.* assembly
|
||||
- Swagger/OpenAPI, health checks
|
||||
- JWT auth middleware
|
||||
- Global exception filter
|
||||
end note
|
||||
}
|
||||
|
||||
package "Feature Slices" #F3E5F5 {
|
||||
[Features.Users] as UsersSlice
|
||||
[Features.Breweries] as BrewerySlice
|
||||
[Features.Emails] as EmailsSlice
|
||||
[Features.Locations] as LocationsSlice
|
||||
note right of UsersSlice
|
||||
Each slice owns its own:
|
||||
- Controller (HTTP endpoints)
|
||||
- Commands/Queries + Handlers
|
||||
- Validators
|
||||
- Repository (Dapper)
|
||||
|
||||
Features.Users covers auth
|
||||
(Commands/Authentication),
|
||||
account management
|
||||
(Commands/Account), and
|
||||
profile/avatar (Commands/Profile)
|
||||
end note
|
||||
note right of EmailsSlice
|
||||
No controller - invoked only
|
||||
via MediatR commands sent
|
||||
from other slices
|
||||
end note
|
||||
note right of LocationsSlice
|
||||
No controller and no MediatR
|
||||
handlers - exposes ILocationRepository
|
||||
directly as a plain DI service.
|
||||
Not registered by API.Core at all;
|
||||
only Database.Seed depends on it today
|
||||
end note
|
||||
}
|
||||
|
||||
package "Shared" #FCE4EC {
|
||||
[Shared.Contracts] as SharedContracts
|
||||
[Shared.Application] as SharedApp
|
||||
note right of SharedApp
|
||||
- ValidationBehavior
|
||||
(MediatR pipeline)
|
||||
- Cross-slice email commands
|
||||
(SendRegistrationEmailCommand,
|
||||
SendResendConfirmationEmailCommand)
|
||||
end note
|
||||
}
|
||||
|
||||
package "Infrastructure Layer" #FFF3E0 {
|
||||
[Infrastructure.Sql] as Sql
|
||||
[Infrastructure.Jwt] as JWT
|
||||
[Infrastructure.PasswordHashing] as PwdHash
|
||||
[Infrastructure.Email] as Email
|
||||
[Infrastructure.Email.Templates] as EmailTemplates
|
||||
}
|
||||
|
||||
package "Domain Layer" #E8F5E9 {
|
||||
[Domain.Entities] as Domain
|
||||
[Domain.Exceptions] as DomainExceptions
|
||||
note right of Domain
|
||||
- UserAccount, UserCredential, UserVerification
|
||||
- UserProfile, UserAvatar
|
||||
- BreweryPost, BreweryPostLocation
|
||||
- City, StateProvince, Country
|
||||
- BeerPost, BeerStyle
|
||||
(schema tables exist; no repository yet)
|
||||
end note
|
||||
}
|
||||
|
||||
database "SQL Server" {
|
||||
[Tables] as Tables
|
||||
}
|
||||
|
||||
node "Database.Seed\n(separate console app)" as Seed
|
||||
|
||||
' Relationships
|
||||
API ..> UsersSlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
||||
|
||||
Seed ..> UsersSlice : AddFeaturesUsers()\n(DI only, no API/MediatR)
|
||||
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
||||
Seed ..> LocationsSlice : AddFeaturesLocations()
|
||||
|
||||
UsersSlice --> Sql
|
||||
UsersSlice --> JWT
|
||||
UsersSlice --> PwdHash
|
||||
UsersSlice --> SharedContracts
|
||||
UsersSlice --> SharedApp
|
||||
|
||||
BrewerySlice --> Sql
|
||||
BrewerySlice --> SharedContracts
|
||||
BrewerySlice --> SharedApp
|
||||
|
||||
LocationsSlice --> Sql
|
||||
|
||||
EmailsSlice --> Email
|
||||
EmailsSlice --> EmailTemplates
|
||||
EmailsSlice --> SharedApp
|
||||
|
||||
' The one cross-slice interaction: a MediatR command contract, not a project reference
|
||||
UsersSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
|
||||
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
||||
|
||||
Sql --> Domain
|
||||
|
||||
UsersSlice --> Tables : Dapper SQL
|
||||
BrewerySlice --> Tables : Dapper SQL
|
||||
LocationsSlice --> Tables : Dapper SQL
|
||||
|
||||
UsersSlice --> Domain
|
||||
BrewerySlice --> Domain
|
||||
LocationsSlice --> Domain
|
||||
UsersSlice --> DomainExceptions
|
||||
|
||||
' Notes
|
||||
note left of Sql
|
||||
SQL-first approach
|
||||
Each slice's own repository issues
|
||||
inline SQL via Dapper - no ORM,
|
||||
no stored procedures
|
||||
end note
|
||||
|
||||
note bottom of UsersSlice
|
||||
No Features.* project
|
||||
ever references another
|
||||
Features.* project directly
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -1,927 +0,0 @@
|
||||
@startuml authentication-flow
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Expanded
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #LightBlue
|
||||
participant "RegisterUserHandler" as RegHandler
|
||||
participant "LoginHandler" as LoginHandler
|
||||
participant "RefreshTokenHandler" as RefreshHandler
|
||||
participant "ConfirmUserHandler" as ConfirmHandler
|
||||
participant "ResendConfirmationEmailHandler" as ResendHandler
|
||||
participant "UpdateUsernameHandler" as UpdateUsernameHandler
|
||||
participant "UpdateEmailHandler" as UpdateEmailHandler
|
||||
participant "UpdatePasswordHandler" as UpdatePasswordHandler
|
||||
participant "UpdateProfileHandler" as UpdateProfileHandler
|
||||
participant "DeleteAccountHandler" as DeleteAccountHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #LightGreen
|
||||
participant "Argon2PasswordHasher" as Argon2
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
participant "Email\nProvider" as SMTP
|
||||
participant "Template\nProvider" as Template
|
||||
end box
|
||||
box "Repository / Cross-slice" #LightYellow
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
== Registration Flow ==
|
||||
|
||||
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(RegisterUserCommand)
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs RegisterUserValidator
|
||||
(FluentValidation) before the handler:
|
||||
- Username: 3-64 chars, alphanumeric + [._-]
|
||||
- Email: valid format, max 128 chars
|
||||
- Password: min 8 chars, uppercase,\n lowercase, number, special char
|
||||
- DateOfBirth: must be 19+ years old
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
Mediator -> RegHandler: Handle(command)
|
||||
activate RegHandler
|
||||
|
||||
RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
|
||||
|
||||
RegHandler -> UserMgr: CreateAsync(user, password)
|
||||
activate UserMgr
|
||||
|
||||
UserMgr -> Argon2: HashPassword(user, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Hash:
|
||||
Argon2id parameters:
|
||||
- Salt: 16 bytes (128-bit)
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Parallelism: CPU count
|
||||
- Hash output: 32 bytes
|
||||
end note
|
||||
Argon2 --> UserMgr: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
|
||||
note right of UserStore
|
||||
user.Id is still empty at this point -
|
||||
only sets the in-memory hash. CreateAsync
|
||||
writes it to dbo.UserCredential itself.
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr -> UserStore: FindByEmailAsync(email)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
|
||||
activate DB
|
||||
DB --> UserStore: null (email doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
note right of UserMgr
|
||||
Default UserManager validators check
|
||||
uniqueness before CreateAsync is called;
|
||||
DapperUserStore.CreateAsync also guards
|
||||
against a genuine race via a caught
|
||||
duplicate-key SqlException (2601/2627).
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: CreateAsync(user)
|
||||
activate UserStore
|
||||
|
||||
UserStore -> DB: BEGIN TRANSACTION
|
||||
activate DB
|
||||
UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
|
||||
DB --> UserStore: UserAccountId (GUID)
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
UserStore -> DB: COMMIT TRANSACTION
|
||||
deactivate DB
|
||||
note right of UserStore
|
||||
Both statements run via Dapper's
|
||||
ExecuteScalarAsync/ExecuteAsync against
|
||||
a single DbTransaction, not a stored
|
||||
procedure - no manual parameter binding
|
||||
end note
|
||||
|
||||
UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr --> RegHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict\n"Username or email already exists"
|
||||
else result.Succeeded
|
||||
|
||||
RegHandler -> TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
JWT Configuration:
|
||||
- Algorithm: HS256
|
||||
- Expires: 1 hour
|
||||
- Claims:
|
||||
* sub: userId
|
||||
* unique_name: username
|
||||
* iat / jti
|
||||
end note
|
||||
JWT -> JWT: Create JWT with claims
|
||||
JWT -> JWT: Sign with secret key
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Refresh Token:
|
||||
- Expires: 21 days (504 hours)
|
||||
- Same structure as access token,\n signed with a separate secret
|
||||
end note
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler -> TokenSvc: GenerateConfirmationToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Confirmation Token:
|
||||
- Expires: 30 minutes (0.5 hours)
|
||||
- Signed with a third, separate secret
|
||||
end note
|
||||
JWT --> TokenSvc: Confirmation Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegHandler: Confirmation Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
|
||||
activate Mediator
|
||||
Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
|
||||
note right of Mediator
|
||||
Routed to Features.Emails'
|
||||
SendRegistrationEmailHandler,
|
||||
the only cross-slice call in
|
||||
the backend
|
||||
end note
|
||||
activate EmailDispatcher
|
||||
|
||||
EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
|
||||
activate Template
|
||||
note right of Template
|
||||
Razor Component:
|
||||
- Header with branding
|
||||
- Welcome message
|
||||
- Confirmation button
|
||||
- Footer
|
||||
end note
|
||||
Template -> Template: Render Razor component\nto HTML
|
||||
Template --> EmailDispatcher: HTML email content
|
||||
deactivate Template
|
||||
|
||||
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
|
||||
activate SMTP
|
||||
note right of SMTP
|
||||
SMTP Configuration (MailKit):
|
||||
- Host/port: from env (SMTP_HOST/SMTP_PORT)
|
||||
- TLS: StartTLS when SMTP_USE_SSL=true
|
||||
- Auth: username/password, if configured
|
||||
end note
|
||||
SMTP -> SMTP: Build MIME message
|
||||
SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
|
||||
SMTP --> EmailDispatcher: Success / throws
|
||||
deactivate SMTP
|
||||
|
||||
deactivate EmailDispatcher
|
||||
deactivate Mediator
|
||||
|
||||
alt Email sent successfully
|
||||
RegHandler -> RegHandler: emailSent = true
|
||||
else mediator.Send throws
|
||||
RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
|
||||
end
|
||||
|
||||
RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
|
||||
deactivate RegHandler
|
||||
Mediator --> API: RegistrationPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Login Flow ==
|
||||
|
||||
User -> API: POST /api/auth/login\n{username, password}
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(LoginCommand)
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs LoginValidator:
|
||||
username and password must both be non-empty
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> LoginHandler: Handle(command)
|
||||
activate LoginHandler
|
||||
|
||||
LoginHandler -> UserMgr: FindByNameAsync(username)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: UserAccount row or null
|
||||
deactivate DB
|
||||
UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> LoginHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
|
||||
LoginHandler -> UserMgr: CheckPasswordAsync(user, password)
|
||||
activate UserMgr
|
||||
note right of UserMgr
|
||||
HasPasswordAsync short-circuits to false
|
||||
(no active UserCredential was hydrated)
|
||||
without calling the hasher; otherwise
|
||||
delegates to Argon2PasswordHasher.
|
||||
end note
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Verify:
|
||||
1. Split stored hash: "salt:hash"
|
||||
2. Hash provided password with the same salt
|
||||
3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
||||
end note
|
||||
Argon2 --> UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
UserMgr --> LoginHandler: true/false
|
||||
deactivate UserMgr
|
||||
|
||||
alt Password invalid
|
||||
LoginHandler -> Mediator: throw UnauthorizedException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Password valid
|
||||
|
||||
LoginHandler -> TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
|
||||
deactivate LoginHandler
|
||||
Mediator --> API: LoginPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Refresh Token Flow ==
|
||||
|
||||
User -> API: POST /api/auth/refresh\nHeader: X-Refresh-Token: <token>
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(RefreshTokenCommand(refreshToken))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs RefreshTokenValidator:
|
||||
refreshToken must be non-empty. Anonymous
|
||||
endpoint - no Bearer token required.
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> RefreshHandler: Handle(command)
|
||||
activate RefreshHandler
|
||||
|
||||
RefreshHandler -> TokenSvc: RefreshTokenAsync(refreshToken)
|
||||
activate TokenSvc
|
||||
|
||||
TokenSvc -> JWT: ValidateRefreshTokenAsync(refreshToken)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Validates signature/expiry against the
|
||||
refresh token secret, extracts userId/
|
||||
username claims.
|
||||
end note
|
||||
JWT --> TokenSvc: ValidatedToken or throws
|
||||
deactivate JWT
|
||||
|
||||
alt Token invalid or expired
|
||||
TokenSvc -> RefreshHandler: throw UnauthorizedException
|
||||
RefreshHandler -> Mediator: propagate
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Token valid
|
||||
|
||||
TokenSvc -> UserMgr: FindByIdAsync(validated.UserId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> TokenSvc: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
TokenSvc -> RefreshHandler: throw UnauthorizedException\n"User account not found"
|
||||
RefreshHandler -> Mediator: propagate
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
|
||||
JWT --> TokenSvc: new access + refresh tokens
|
||||
TokenSvc --> RefreshHandler: RefreshTokenResult
|
||||
deactivate TokenSvc
|
||||
|
||||
RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
|
||||
deactivate RefreshHandler
|
||||
Mediator --> API: LoginPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Confirm User Flow ==
|
||||
|
||||
User -> API: POST /api/auth/confirm?token=<token>\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(ConfirmUserCommand(token))
|
||||
activate Mediator
|
||||
|
||||
Mediator -> ConfirmHandler: Handle(command)
|
||||
activate ConfirmHandler
|
||||
|
||||
ConfirmHandler -> TokenSvc: ValidateConfirmationTokenAsync(token)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: Validate against confirmation\ntoken secret, extract userId
|
||||
activate JWT
|
||||
JWT --> TokenSvc: ValidatedToken or throws
|
||||
deactivate JWT
|
||||
TokenSvc --> ConfirmHandler: ValidatedToken
|
||||
deactivate TokenSvc
|
||||
|
||||
alt Token invalid or expired
|
||||
ConfirmHandler -> Mediator: throw UnauthorizedException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Token valid
|
||||
|
||||
ConfirmHandler -> UserMgr: FindByIdAsync(validatedToken.UserId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> ConfirmHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
ConfirmHandler -> Mediator: throw UnauthorizedException\n"User account not found"
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
|
||||
ConfirmHandler -> UserStore: SetEmailConfirmedAsync(user, true)
|
||||
note right of UserStore
|
||||
IUserEmailStore<ApplicationUser> is injected
|
||||
directly into the handler (not via UserManager),
|
||||
setting the confirmed flag in-memory here.
|
||||
end note
|
||||
|
||||
ConfirmHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
activate UserStore
|
||||
UserStore -> DB: UPDATE dbo.UserAccount SET ...
|
||||
UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
|
||||
note right of UserStore
|
||||
Idempotent: skipped (duplicate-key
|
||||
exception swallowed) if a verification
|
||||
row already exists.
|
||||
end note
|
||||
UserStore --> UserMgr: IdentityResult.Success
|
||||
deactivate UserStore
|
||||
UserMgr --> ConfirmHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
|
||||
deactivate ConfirmHandler
|
||||
Mediator --> API: ConfirmationPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Resend Confirmation Email Flow ==
|
||||
|
||||
User -> API: POST /api/auth/confirm/resend?userId=<id>\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(ResendConfirmationEmailCommand(userId))
|
||||
activate Mediator
|
||||
|
||||
Mediator -> ResendHandler: Handle(command)
|
||||
activate ResendHandler
|
||||
|
||||
ResendHandler -> UserMgr: FindByIdAsync(userId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> ResendHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
ResendHandler -> ResendHandler: return silently\n(prevents user enumeration)
|
||||
else User found
|
||||
|
||||
ResendHandler -> UserMgr: IsEmailConfirmedAsync(user)
|
||||
UserMgr --> ResendHandler: bool
|
||||
|
||||
alt Already confirmed
|
||||
ResendHandler -> ResendHandler: return silently (no-op)
|
||||
else Not yet confirmed
|
||||
|
||||
ResendHandler -> TokenSvc: GenerateConfirmationToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc --> ResendHandler: confirmation token
|
||||
deactivate TokenSvc
|
||||
|
||||
ResendHandler -> Mediator: Send(SendResendConfirmationEmailCommand)
|
||||
activate Mediator
|
||||
Mediator -> EmailDispatcher: SendResendConfirmationEmailAsync(\n firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
EmailDispatcher -> Template: Render email\n(same Razor pipeline as registration)
|
||||
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
|
||||
deactivate EmailDispatcher
|
||||
deactivate Mediator
|
||||
end
|
||||
end
|
||||
|
||||
ResendHandler --> Mediator: (void)
|
||||
deactivate ResendHandler
|
||||
Mediator --> API: (void)
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "confirmation email has been resent"}
|
||||
note right of API
|
||||
Always 200 OK regardless of outcome above -
|
||||
same user-enumeration precaution as a not-found
|
||||
user or an already-confirmed account.
|
||||
end note
|
||||
deactivate API
|
||||
|
||||
== Update Username Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateUsernameValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdateUsernameHandler: Handle(command)
|
||||
activate UpdateUsernameHandler
|
||||
|
||||
UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> UpdateUsernameHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateUsernameHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByNameAsync(newUsername)
|
||||
note right of UserMgr
|
||||
Uniqueness check before the UPDATE.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateUsernameHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
|
||||
deactivate UpdateUsernameHandler
|
||||
Mediator --> API: UpdateUsernamePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Update Email Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateEmailValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdateEmailHandler: Handle(command)
|
||||
activate UpdateEmailHandler
|
||||
|
||||
UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore -> DB: SELECT ...
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> UpdateEmailHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateEmailHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByEmailAsync(newEmail)
|
||||
note right of UserMgr
|
||||
Uniqueness check. Also resets
|
||||
user.EmailConfirmed = false -
|
||||
the caller must re-confirm via
|
||||
POST /api/auth/confirm/resend.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
|
||||
UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateEmailHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
|
||||
deactivate UpdateEmailHandler
|
||||
Mediator --> API: UpdateEmailPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Update Password Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdatePasswordValidator
|
||||
(same password strength rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdatePasswordHandler: Handle(command)
|
||||
activate UpdatePasswordHandler
|
||||
|
||||
UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> UpdatePasswordHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdatePasswordHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword)
|
||||
activate UserMgr
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
|
||||
activate Argon2
|
||||
Argon2 --> UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
|
||||
alt Current password mismatch
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch)
|
||||
else Current password matches
|
||||
UserMgr -> Argon2: HashPassword(user, newPassword)
|
||||
activate Argon2
|
||||
Argon2 --> UserMgr: new hash
|
||||
deactivate Argon2
|
||||
UserMgr -> UserStore: SetPasswordHashAsync(user, newHash)
|
||||
activate UserStore
|
||||
UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
note right of UserStore
|
||||
Revoke-then-insert runs inside one
|
||||
DbTransaction (credential rotation).
|
||||
end note
|
||||
UserStore --> UserMgr: (void)
|
||||
deactivate UserStore
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Success
|
||||
end
|
||||
deactivate UserMgr
|
||||
|
||||
alt Current password is incorrect
|
||||
UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect"
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else !result.Succeeded (other failure)
|
||||
UpdatePasswordHandler -> Mediator: throw ConflictException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow)
|
||||
deactivate UpdatePasswordHandler
|
||||
Mediator --> API: UpdatePasswordPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Update Profile Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateProfileValidator
|
||||
(same name/DateOfBirth rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdateProfileHandler: Handle(command)
|
||||
activate UpdateProfileHandler
|
||||
|
||||
UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> UpdateProfileHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateProfileHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ...
|
||||
note right of UpdateProfileHandler
|
||||
First/last name and date of birth aren't
|
||||
tracked by any Identity store interface -
|
||||
mutated directly on the ApplicationUser,
|
||||
then saved via UserManager.UpdateAsync.
|
||||
end note
|
||||
|
||||
UpdateProfileHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ...
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateProfileHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateProfileHandler -> Mediator: throw ConflictException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth)
|
||||
deactivate UpdateProfileHandler
|
||||
Mediator --> API: UpdateProfilePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Delete Account Flow ==
|
||||
|
||||
User -> API: DELETE /api/auth/account\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId()))
|
||||
activate Mediator
|
||||
|
||||
Mediator -> DeleteAccountHandler: Handle(command)
|
||||
activate DeleteAccountHandler
|
||||
|
||||
DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> DeleteAccountHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
DeleteAccountHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
DeleteAccountHandler -> UserMgr: DeleteAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: DeleteAsync(user)
|
||||
activate UserStore
|
||||
UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
note right of UserStore
|
||||
UserCredential/UserVerification/UserAvatar
|
||||
cascade on delete. Posts, comments, photos,
|
||||
and follows use ON DELETE NO ACTION - if any
|
||||
reference this account, the DELETE throws a
|
||||
foreign key violation (SQL error 547).
|
||||
end note
|
||||
|
||||
alt Foreign key violation (dependent records exist)
|
||||
UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords)
|
||||
else Delete succeeds
|
||||
UserStore --> UserMgr: IdentityResult.Success
|
||||
end
|
||||
deactivate UserStore
|
||||
UserMgr --> DeleteAccountHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account has dependent records
|
||||
DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows."
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else !result.Succeeded (other failure)
|
||||
DeleteAccountHandler -> Mediator: throw ConflictException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
DeleteAccountHandler --> Mediator: (void)
|
||||
deactivate DeleteAccountHandler
|
||||
Mediator --> API: (void)
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Account deleted successfully."}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Error Handling (Global Exception Filter) ==
|
||||
|
||||
note over API
|
||||
API.Core's GlobalExceptionFilter catches:
|
||||
- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
|
||||
- ConflictException -> 409 Conflict
|
||||
- NotFoundException -> 404 Not Found
|
||||
- UnauthorizedException -> 401 Unauthorized
|
||||
- ForbiddenException -> 403 Forbidden
|
||||
- Domain.Exceptions.ValidationException -> 400 Bad Request
|
||||
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
|
||||
- All others -> 500 Internal Server Error
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -1,221 +0,0 @@
|
||||
@startuml database-schema
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam linetype ortho
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title Database Schema
|
||||
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
|
||||
|
||||
package "User & Auth" {
|
||||
entity "UserAccount" as User {
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Username: VARCHAR(64) <<UNIQUE>>
|
||||
* FirstName: NVARCHAR(128)
|
||||
* LastName: NVARCHAR(128)
|
||||
* Email: VARCHAR(128) <<UNIQUE>>
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
* DateOfBirth: DATE
|
||||
}
|
||||
|
||||
entity "UserCredential" as Cred {
|
||||
* UserCredentialID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
* Expiry: DATETIME
|
||||
* Hash: NVARCHAR(256)
|
||||
* IsRevoked: BIT
|
||||
RevokedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "UserVerification" as Verify {
|
||||
* UserVerificationID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* VerificationDateTime: DATETIME
|
||||
}
|
||||
|
||||
entity "UserProfile" as Profile {
|
||||
* UserProfileID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* Biography: NVARCHAR(MAX)
|
||||
}
|
||||
|
||||
entity "UserAvatar" as Avatar {
|
||||
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserProfileID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* ValidFrom: DATETIME2(3)
|
||||
ValidTo: DATETIME2(3)
|
||||
..
|
||||
At most one row per UserProfileID
|
||||
has ValidTo IS NULL (active avatar)
|
||||
}
|
||||
|
||||
entity "UserFollow (*)" as Follow {
|
||||
* UserFollowID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||
* FollowingID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "Photo (*)" as Photo {
|
||||
* PhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
Hyperlink: NVARCHAR(256)
|
||||
* UploadedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* UploadedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
package "Location" {
|
||||
entity "Country" as Country {
|
||||
* CountryID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* CountryName: NVARCHAR(100)
|
||||
* ISO3166_1: CHAR(2) <<UNIQUE>>
|
||||
}
|
||||
|
||||
entity "StateProvince" as StateProvince {
|
||||
* StateProvinceID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* StateProvinceName: NVARCHAR(100)
|
||||
* ISO3166_2: CHAR(6) <<UNIQUE>>
|
||||
* CountryID: UNIQUEIDENTIFIER <<FK>>
|
||||
}
|
||||
|
||||
entity "City" as City {
|
||||
* CityID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* CityName: NVARCHAR(100)
|
||||
* StateProvinceID: UNIQUEIDENTIFIER <<FK>>
|
||||
}
|
||||
}
|
||||
|
||||
package "Brewery" {
|
||||
entity "BreweryPost" as Brewery {
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryName: NVARCHAR(256)
|
||||
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* Description: NVARCHAR(512)
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BreweryPostLocation" as BreweryLoc {
|
||||
* BreweryPostLocationID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* AddressLine1: NVARCHAR(256)
|
||||
AddressLine2: NVARCHAR(256)
|
||||
* PostalCode: NVARCHAR(20)
|
||||
* CityID: UNIQUEIDENTIFIER <<FK>>
|
||||
Coordinates: GEOGRAPHY
|
||||
}
|
||||
|
||||
entity "BreweryPostPhoto (*)" as BreweryPhoto {
|
||||
* BreweryPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* LinkedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
package "Beer (*)" {
|
||||
entity "BeerStyle (*)" as BeerStyle {
|
||||
* BeerStyleID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* StyleName: NVARCHAR(100) <<UNIQUE>>
|
||||
Description: NVARCHAR(MAX)
|
||||
}
|
||||
|
||||
entity "BeerPost (*)" as Beer {
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Name: NVARCHAR(100)
|
||||
* Description: NVARCHAR(MAX)
|
||||
* ABV: DECIMAL(4,2)
|
||||
* IBU: INT
|
||||
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* BeerStyleID: UNIQUEIDENTIFIER <<FK>>
|
||||
* BrewedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BeerPostPhoto (*)" as BeerPhoto {
|
||||
* BeerPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* LinkedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BeerPostComment (*)" as BeerComment {
|
||||
* BeerPostCommentID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Comment: NVARCHAR(250)
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CommentedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* Rating: INT
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
' Relationships: User & Auth
|
||||
User ||--o{ Cred : "has"
|
||||
User ||--o| Verify : "has"
|
||||
User ||--o| Profile : "has"
|
||||
Profile ||--o{ Avatar : "has (history)"
|
||||
Avatar }o--|| Photo : "refers to"
|
||||
User ||--o{ Follow : "follows (UserAccountID)"
|
||||
User ||--o{ Follow : "followed by (FollowingID)"
|
||||
User ||--o{ Photo : "uploaded"
|
||||
|
||||
' Relationships: Location
|
||||
Country ||--o{ StateProvince : "has"
|
||||
StateProvince ||--o{ City : "has"
|
||||
|
||||
' Relationships: Brewery
|
||||
User ||--o{ Brewery : "posted"
|
||||
Brewery ||--o| BreweryLoc : "has"
|
||||
City ||--o{ BreweryLoc : "located in"
|
||||
Brewery ||--o{ BreweryPhoto : "has"
|
||||
Photo ||--o{ BreweryPhoto : "linked to"
|
||||
|
||||
' Relationships: Beer
|
||||
User ||--o{ Beer : "posted"
|
||||
BeerStyle ||--o{ Beer : "categorizes"
|
||||
Brewery ||--o{ Beer : "brewed"
|
||||
Beer ||--o{ BeerPhoto : "has"
|
||||
Photo ||--o{ BeerPhoto : "linked to"
|
||||
Beer ||--o{ BeerComment : "has"
|
||||
User ||--o{ BeerComment : "commented"
|
||||
|
||||
note bottom of User
|
||||
Auth reads/writes (registration, login, credential
|
||||
rotation, verification) go through ASP.NET Core
|
||||
Identity's UserManager<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
|
||||
@@ -1,332 +0,0 @@
|
||||
@startuml deployment
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam linetype ortho
|
||||
|
||||
title Docker Deployment Architecture
|
||||
|
||||
' External systems
|
||||
actor Developer
|
||||
cloud "Docker Host" as Host
|
||||
|
||||
package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
|
||||
|
||||
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
|
||||
database "Biergarten\nDatabase" as DevDBInner {
|
||||
portin "1433"
|
||||
}
|
||||
note right
|
||||
Environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=***
|
||||
- MSSQL_PID=Developer
|
||||
|
||||
Volumes:
|
||||
- biergarten-dev-data
|
||||
end note
|
||||
}
|
||||
|
||||
node "API Container\n(API.Core)" as DevAPI {
|
||||
component "ASP.NET Core 10" as API1
|
||||
portin "8080:8080 (HTTP)" as DevPort1
|
||||
portin "8081:8081 (HTTPS)" as DevPort2
|
||||
|
||||
note right
|
||||
Environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- DB_SERVER=sql-server
|
||||
- DB_NAME=Biergarten
|
||||
- DB_USER/PASSWORD
|
||||
- JWT_SECRET
|
||||
- SMTP_* (10+ variables)
|
||||
|
||||
Health Check:
|
||||
/health endpoint
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as DevMig {
|
||||
component "Database.Migrations" as Mig1
|
||||
note bottom
|
||||
Runs: DbUp migrations
|
||||
Environment:
|
||||
- CLEAR_DATABASE=false
|
||||
Depends on: sql-server
|
||||
end note
|
||||
}
|
||||
|
||||
node "Seed\n(run-once)" as DevSeed {
|
||||
component "Database.Seed" as Seed1
|
||||
note bottom
|
||||
Creates:
|
||||
- 100 test users
|
||||
- Location data (US/CA/MX)
|
||||
- test.user account
|
||||
Depends on: migrations
|
||||
end note
|
||||
}
|
||||
|
||||
node "Mailpit\n(axllent/mailpit)" as DevMailpit {
|
||||
portin "8025 (Web UI)" as DevMailpitWeb
|
||||
portin "1025 (SMTP)" as DevMailpitSmtp
|
||||
note right
|
||||
Local SMTP catcher for dev email testing.
|
||||
Environment:
|
||||
- MP_SMTP_AUTH_ACCEPT_ANY=1
|
||||
- MP_SMTP_AUTH_ALLOW_INSECURE=1
|
||||
end note
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||
|
||||
node "SQL Server\n(isolated instance)" as TestDB {
|
||||
database "Biergarten\nTest Database" as TestDBInner {
|
||||
portin "1434"
|
||||
}
|
||||
note right
|
||||
Fresh instance each run
|
||||
CLEAR_DATABASE=true
|
||||
|
||||
Volumes:
|
||||
- biergarten-test-data
|
||||
(ephemeral)
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(test)" as TestMig {
|
||||
component "Database.Migrations"
|
||||
}
|
||||
|
||||
node "Seed\n(test)" as TestSeed {
|
||||
component "Database.Seed"
|
||||
note bottom
|
||||
Minimal seed:
|
||||
- test.user only
|
||||
- Essential data
|
||||
end note
|
||||
}
|
||||
|
||||
node "API.Specs\n(Integration Tests)" as Specs {
|
||||
component "Reqnroll + xUnit" as SpecsComp
|
||||
note right
|
||||
Tests:
|
||||
- Registration flow
|
||||
- Login flow
|
||||
- Validation rules
|
||||
- 404 handling
|
||||
|
||||
Uses: TestApiFactory
|
||||
Mocks: Email services
|
||||
end note
|
||||
}
|
||||
|
||||
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
|
||||
component "xUnit + Moq" as UnitComp
|
||||
note right
|
||||
Builds Dockerfile.tests, which
|
||||
restores/builds against Core.slnx
|
||||
then runs `dotnet test` for every
|
||||
Features/*.Tests project in turn:
|
||||
- Features.Users.Tests
|
||||
- Features.Breweries.Tests
|
||||
- Features.Emails.Tests
|
||||
|
||||
Uses: Moq to mock each handler's
|
||||
repository/service dependencies
|
||||
No real database needed
|
||||
end note
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC {
|
||||
|
||||
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as ProdDB {
|
||||
database "Biergarten\nDatabase" as ProdDBInner
|
||||
note right
|
||||
Environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=***
|
||||
- MSSQL_PID=Express
|
||||
|
||||
Volumes:
|
||||
- sqlserverdata-prod
|
||||
No host port published.
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as ProdMig {
|
||||
component "Database.Migrations" as ProdMig1
|
||||
note bottom
|
||||
Depends on: sql-server (healthy)
|
||||
No seed step in production.
|
||||
end note
|
||||
}
|
||||
|
||||
node "API Container\n(API.Core)" as ProdAPI {
|
||||
component "ASP.NET Core 10" as ProdAPI1
|
||||
portin "8080:8080 (HTTP)" as ProdPort1
|
||||
portin "8081:8081 (HTTPS)" as ProdPort2
|
||||
note right
|
||||
Environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- DB_SERVER/NAME/USER/PASSWORD
|
||||
- ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET
|
||||
- WEBSITE_BASE_URL
|
||||
|
||||
Depends on: sql-server (healthy) -
|
||||
does not wait on migrations/seed.
|
||||
end note
|
||||
}
|
||||
|
||||
ProdMig --> ProdDB : 1. Run migrations
|
||||
ProdAPI --> ProdDB : 2. Connect & serve
|
||||
ProdMig .up.> ProdDB : depends_on
|
||||
ProdAPI .up.> ProdDB : depends_on
|
||||
|
||||
note bottom of ProdDB
|
||||
<b>Prod Network (bridge: prodnet)</b>
|
||||
Isolated from dev/test networks.
|
||||
Volumes: sqlserverdata-prod,
|
||||
nuget-cache-prod
|
||||
end note
|
||||
}
|
||||
|
||||
package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 {
|
||||
|
||||
node "SQL Server" as DbOnlyDB {
|
||||
database "Biergarten\nDatabase" as DbOnlyDBInner {
|
||||
portin "1434"
|
||||
}
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as DbOnlyMig {
|
||||
component "Database.Migrations"
|
||||
}
|
||||
|
||||
node "Seed\n(run-once)" as DbOnlySeed {
|
||||
component "Database.Seed"
|
||||
}
|
||||
|
||||
note bottom of DbOnlyDB
|
||||
Same devnet volumes/images as
|
||||
docker-compose.dev.yaml, minus
|
||||
api.core and mailpit - for working
|
||||
against the database without
|
||||
running the API.
|
||||
end note
|
||||
|
||||
DbOnlyMig --> DbOnlyDB : 1. Run migrations
|
||||
DbOnlySeed --> DbOnlyDB : 2. Seed data
|
||||
DbOnlyMig .up.> DbOnlyDB : depends_on
|
||||
DbOnlySeed .up.> DbOnlyMig : depends_on
|
||||
}
|
||||
|
||||
package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 {
|
||||
|
||||
node "SQL Server" as MinDB {
|
||||
database "Biergarten\nDatabase" as MinDBInner {
|
||||
portin "1433"
|
||||
}
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as MinMig {
|
||||
component "Database.Migrations"
|
||||
}
|
||||
|
||||
node "Mailpit" as MinMailpit {
|
||||
portin "8025 / 1025"
|
||||
}
|
||||
|
||||
node "Seed\n(run-once)" as MinSeed {
|
||||
component "Database.Seed"
|
||||
}
|
||||
|
||||
note bottom of MinDB
|
||||
Database + mailpit only, no API
|
||||
container - for frontend development
|
||||
against an API.Core process run
|
||||
outside Docker.
|
||||
end note
|
||||
|
||||
MinMig --> MinDB : 1. Run migrations
|
||||
MinSeed --> MinDB : 2. Seed data
|
||||
MinMig .up.> MinDB : depends_on
|
||||
MinSeed .up.> MinMig : depends_on
|
||||
}
|
||||
|
||||
folder "test-results/\n(mounted volume)" as Results {
|
||||
file "api-specs/\n results.trx" as Result1
|
||||
file "Features.Users.Tests.trx" as Result2
|
||||
file "Features.Breweries.Tests.trx" as Result3
|
||||
file "Features.Emails.Tests.trx" as Result5
|
||||
|
||||
note bottom
|
||||
TRX format
|
||||
Readable by:
|
||||
- Visual Studio
|
||||
- Azure DevOps
|
||||
- GitHub Actions
|
||||
end note
|
||||
}
|
||||
|
||||
' External access
|
||||
Developer --> Host : docker compose up
|
||||
Host --> DevAPI : http://localhost:8080
|
||||
|
||||
' Development dependencies
|
||||
DevMig --> DevDB : 1. Run migrations
|
||||
DevSeed --> DevDB : 2. Seed data
|
||||
DevAPI --> DevDB : 3. Connect & serve
|
||||
DevAPI ..> DevMailpit : 4. SMTP_HOST/PORT
|
||||
DevMig .up.> DevDB : depends_on
|
||||
DevSeed .up.> DevMig : depends_on
|
||||
DevAPI .up.> DevSeed : depends_on
|
||||
|
||||
' Test dependencies
|
||||
TestMig --> TestDB : 1. Migrate
|
||||
TestSeed --> TestDB : 2. Seed
|
||||
Specs --> TestDB : 3. Integration test
|
||||
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq mocks,\nno real connection)
|
||||
|
||||
TestMig .up.> TestDB : depends_on
|
||||
TestSeed .up.> TestMig : depends_on
|
||||
Specs .up.> TestSeed : depends_on
|
||||
UnitTests .up.> TestSeed : depends_on
|
||||
|
||||
' Test results export
|
||||
Specs --> Results : Export TRX
|
||||
UnitTests --> Results : Export TRX (one .trx per slice)
|
||||
|
||||
' Network notes
|
||||
note bottom of DevDB
|
||||
<b>Dev Network (bridge: biergarten-dev)</b>
|
||||
Internal DNS:
|
||||
- sql-server (resolves to SQL container)
|
||||
- api (resolves to API container)
|
||||
end note
|
||||
|
||||
note bottom of TestDB
|
||||
<b>Test Network (bridge: biergarten-test)</b>
|
||||
All test components isolated
|
||||
end note
|
||||
|
||||
' Startup sequence notes
|
||||
note top of DevMig
|
||||
Startup Order:
|
||||
1. SQL Server (health check)
|
||||
2. Migrations (run-once)
|
||||
3. Seed (run-once)
|
||||
4. API (long-running)
|
||||
end note
|
||||
|
||||
note top of Specs
|
||||
Test Execution:
|
||||
All tests run in parallel
|
||||
Results aggregated
|
||||
end note
|
||||
|
||||
@enduml
|
||||
67
mkdocs.yml
Normal file
67
mkdocs.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
- Web:
|
||||
- Architecture: web/architecture.md
|
||||
- Getting Started: web/getting-started.md
|
||||
- Database: web/database.md
|
||||
- Docker: web/docker.md
|
||||
- Environment Variables: web/environment-variables.md
|
||||
- Testing: web/testing.md
|
||||
- Token Validation: web/token-validation.md
|
||||
- Diagrams:
|
||||
- Vertical Slice: web/diagrams/vertical-slice.md
|
||||
- Database Schema: web/diagrams/database-schema.md
|
||||
- Deployment: web/diagrams/deployment.md
|
||||
- Auth Flows:
|
||||
- Registration: web/diagrams/auth/registration.md
|
||||
- Login: web/diagrams/auth/login.md
|
||||
- Refresh Token: web/diagrams/auth/refresh-token.md
|
||||
- Confirm User: web/diagrams/auth/confirm-user.md
|
||||
- Resend Confirmation: web/diagrams/auth/resend-confirmation.md
|
||||
- Update Username: web/diagrams/auth/update-username.md
|
||||
- Update Email: web/diagrams/auth/update-email.md
|
||||
- Update Password: web/diagrams/auth/update-password.md
|
||||
- Update Profile: web/diagrams/auth/update-profile.md
|
||||
- Delete Account: web/diagrams/auth/delete-account.md
|
||||
- Error Handling: web/diagrams/auth/error-handling.md
|
||||
- Pipeline:
|
||||
- Overview: pipeline/README.md
|
||||
- Roadmap: pipeline/ROADMAP.md
|
||||
- Ethics & Known Issues: pipeline/ETHICS-AND-KNOWN-ISSUES.md
|
||||
- Diagrams:
|
||||
- Current:
|
||||
- Activity: pipeline/diagrams/current/activity.md
|
||||
- Class: pipeline/diagrams/current/class.md
|
||||
- Planned:
|
||||
- Activity: pipeline/diagrams/planned/activity.md
|
||||
- Class: pipeline/diagrams/planned/class.md
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- glightbox
|
||||
- panzoom:
|
||||
mermaid: true
|
||||
- minify:
|
||||
minify_html: true
|
||||
minify_js: true
|
||||
minify_css: true
|
||||
htmlmin_opts:
|
||||
remove_comments: true
|
||||
cache_safe: true
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.snippets:
|
||||
base_path: [docs]
|
||||
check_paths: true
|
||||
Reference in New Issue
Block a user