mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Compare commits
48 Commits
a580314583
...
feat/auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5028a43f4b | ||
|
|
fffbe68dc2 | ||
|
|
e6240bc889 | ||
|
|
4a5abd4639 | ||
|
|
d02f31b55c | ||
|
|
e961ce4042 | ||
|
|
dfb67bcdfa | ||
|
|
0ecc9a830e | ||
|
|
5ac55b8be0 | ||
|
|
19791df6a5 | ||
|
|
0780349a79 | ||
|
|
c0cb013f54 | ||
|
|
dfe248ea5f | ||
|
|
ff775e3b8a | ||
|
|
0e51630439 | ||
|
|
bd0e9e4a20 | ||
|
|
c5972b9f04 | ||
|
|
e1b6cba1d4 | ||
|
|
0ff42c3c70 | ||
|
|
a6a9046457 | ||
|
|
40ae75808c | ||
|
|
fbede956e9 | ||
|
|
3793fca65e | ||
|
|
78d0eaa739 | ||
|
|
180ed41e68 | ||
|
|
ce2c90070e | ||
|
|
de63e9f216 | ||
|
|
7187631ebc | ||
|
|
5fa78fbdf6 | ||
|
|
f85ff79a2c | ||
|
|
a3ec7d63f2 | ||
|
|
9d5a463d93 | ||
|
|
d958933250 | ||
|
|
4aa7ba5d9b | ||
|
|
272a25fb05 | ||
|
|
d9411eb00e | ||
|
|
78ad32e93a | ||
|
|
d6e176bcd9 | ||
|
|
8d59f09cb2 | ||
|
|
30317326ce | ||
|
|
1fe35a2ea6 | ||
|
|
cd0f33f603 | ||
|
|
8b429be857 | ||
|
|
49d7f860de | ||
|
|
39c5a89ca7 | ||
|
|
ade2128a73 | ||
|
|
e5b354e322 | ||
|
|
be672b6ca9 |
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`.
|
||||||
101
.github/pull_request_template.md
vendored
Normal file
101
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!--
|
||||||
|
State WHAT is changing and WHY, in two to five sentences.
|
||||||
|
A reader should understand the change without opening the diff.
|
||||||
|
Describe the problem first, then the outcome.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
<!-- Tick the one option that best describes this PR. -->
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Refactor (no change in behaviour)
|
||||||
|
- [ ] Performance improvement
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Tests only
|
||||||
|
- [ ] Build, CI or dependency update
|
||||||
|
- [ ] Other (please specify):
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
|
||||||
|
|
||||||
|
Fixes #123
|
||||||
|
Fixes #124, fixes #125
|
||||||
|
Fixes owner/other-repo#456
|
||||||
|
|
||||||
|
Closing keywords only take effect when the PR targets the repository's default branch.
|
||||||
|
Use "Related to #123" if the PR should not close the issue.
|
||||||
|
If there is no issue, write "N/A" and explain the motivation in the Summary.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Approach and context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Explain how the change works and why this approach was chosen.
|
||||||
|
Include, where relevant:
|
||||||
|
- The decisions you made that are not obvious from the code.
|
||||||
|
- Alternatives you considered and why you rejected them.
|
||||||
|
- Known limitations or shortcomings, and any planned follow-up work.
|
||||||
|
- Background such as benchmark results or design documents.
|
||||||
|
|
||||||
|
Summarise the key points of any external link. Links may be inaccessible to future readers.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## How this was tested
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe what you ran and what you observed. Be specific, and state only what you actually did.
|
||||||
|
For example: the automated tests added or changed, manual steps and their outcome,
|
||||||
|
environments or platforms covered, and anything you were unable to test.
|
||||||
|
For a bug fix, note the test that fails without the fix.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Compatibility and risk
|
||||||
|
|
||||||
|
- [ ] This change is backwards compatible.
|
||||||
|
- [ ] This change is **breaking** (complete the details below).
|
||||||
|
- [ ] This change deprecates existing behaviour (complete the details below).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
If breaking or deprecating, describe: what changes, who is affected,
|
||||||
|
and the migration path or recommended alternative.
|
||||||
|
Also note any deployment, configuration, data migration or rollback considerations.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## User-facing change
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Does this PR change behaviour that users, operators or API consumers will notice?
|
||||||
|
If not, write "None".
|
||||||
|
If so, write one or two sentences suitable for release notes, in plain language and from the
|
||||||
|
user's point of view. If users must take action when upgrading, state clearly that action is required.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```release-note
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes for reviewers
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Help reviewers focus. For example:
|
||||||
|
- Where to start, or a suggested order in which to read the files.
|
||||||
|
- Areas where you would particularly value scrutiny, or where you are uncertain.
|
||||||
|
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
|
||||||
|
or a code generator), so that reviewers can reproduce the steps.
|
||||||
|
- Screenshots or recordings for visual changes (include before and after where useful).
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
|
||||||
|
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
|
||||||
|
- [ ] I have added or updated tests that cover this change.
|
||||||
|
- [ ] The build succeeds and the tests pass locally.
|
||||||
|
- [ ] The code follows the existing patterns and style of this repository.
|
||||||
|
- [ ] I have updated documentation, changelogs or API references where required.
|
||||||
|
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.
|
||||||
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
|
||||||
61
.github/workflows/tests.yml
vendored
Normal file
61
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
containerized-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Generate test environment file
|
||||||
|
run: ./generate-env.sh .env.test
|
||||||
|
|
||||||
|
- name: Build test images
|
||||||
|
run:
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml build
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml up -d
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml wait api.specs unit.tests frontend.tests || true
|
||||||
|
|
||||||
|
- name: Check test container exit codes
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
status=0
|
||||||
|
for name in test-env-database-migrations test-env-database-seed test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
|
||||||
|
code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)"
|
||||||
|
echo "$name exited with $code"
|
||||||
|
if [ "$code" != "0" ]; then
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $status
|
||||||
|
|
||||||
|
- name: Show service logs on failure
|
||||||
|
if: failure()
|
||||||
|
run:
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml logs
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: web/test-results
|
||||||
|
|
||||||
|
- name: Tear down
|
||||||
|
if: always()
|
||||||
|
run:
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yaml down
|
||||||
|
-v
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,6 +22,7 @@ scratchpad.txt
|
|||||||
/web/frontend/playwright-report/
|
/web/frontend/playwright-report/
|
||||||
/web/frontend/test-results/
|
/web/frontend/test-results/
|
||||||
/test-results/
|
/test-results/
|
||||||
|
/docs/rendered-diagrams/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -501,3 +502,8 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
web/test-results/frontend/vitest-storybook.xml
|
||||||
|
web/test-results/frontend/playwright-storybook.xml
|
||||||
|
|
||||||
|
# mkdocs build output
|
||||||
|
/site
|
||||||
|
|||||||
66
README.md
66
README.md
@@ -22,34 +22,28 @@ pipeline notes) lives under `docs/`.
|
|||||||
|
|
||||||
Website + backend (active stack):
|
Website + backend (active stack):
|
||||||
|
|
||||||
- [Getting Started](docs/website/getting-started.md)
|
- [Getting Started](docs/web/getting-started.md)
|
||||||
- [Architecture](docs/architecture.md)
|
- [Architecture](docs/web/architecture.md)
|
||||||
- [Database](docs/website/database.md)
|
- [Database](docs/web/database.md)
|
||||||
- [Docker Guide](docs/website/docker.md)
|
- [Docker Guide](docs/web/docker.md)
|
||||||
- [Testing](docs/website/testing.md)
|
- [Testing](docs/web/testing.md)
|
||||||
- [Environment Variables](docs/website/environment-variables.md)
|
- [Environment Variables](docs/web/environment-variables.md)
|
||||||
- [Token Validation](docs/website/token-validation.md)
|
- [Token Validation](docs/web/token-validation.md)
|
||||||
|
|
||||||
Data generation pipeline (C++):
|
Data generation pipeline (C++):
|
||||||
|
|
||||||
- [Pipeline README](docs/pipeline/README.md)
|
- [Pipeline README](docs/pipeline/README.md)
|
||||||
- [Ethics & Known Issues](docs/pipeline/ETHICS-AND-KNOWN-ISSUES.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
|
## Current status
|
||||||
|
|
||||||
Active areas in the repository:
|
Active areas in the repository:
|
||||||
|
|
||||||
- .NET 10 backend (vertical-slice architecture with MediatR) + SQL Server
|
- .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
|
- Shared Biergarten theme system + Storybook coverage
|
||||||
- Auth flows and account/email integration (local Mailpit in dev compose)
|
- Auth flows and account/email integration (local Mailpit in dev compose)
|
||||||
|
- Photo upload to S3-compatible object storage (local SeaweedFS in dev compose)
|
||||||
- Data generation pipeline with C++ and Llama.cpp
|
- Data generation pipeline with C++ and Llama.cpp
|
||||||
|
|
||||||
Archived/reference areas:
|
Archived/reference areas:
|
||||||
@@ -60,7 +54,7 @@ Archived/reference areas:
|
|||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
- **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)
|
Leaflet/React Leaflet (brewery maps)
|
||||||
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||||
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||||
@@ -72,34 +66,41 @@ Archived/reference areas:
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- .NET SDK 10 or later
|
- .NET SDK 10 or later
|
||||||
- Node.js 18 or later
|
- Node.js 22 or later
|
||||||
- Docker Desktop, or an equivalent Docker Engine setup
|
- 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.
|
prerequisite list, including the pipeline's C++ toolchain.
|
||||||
|
|
||||||
## Quick start
|
## 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.
|
This section is the shortest path to a working dev environment.
|
||||||
|
|
||||||
### Backend (Docker)
|
### Full stack (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||||
cd 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
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend access:
|
`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
|
||||||
- API Swagger: http://localhost:8080/swagger
|
- API Swagger: http://localhost:8080/swagger
|
||||||
- Health Check: http://localhost:8080/health
|
- Health Check: http://localhost:8080/health
|
||||||
- Mailpit UI (dev SMTP): http://localhost:8025
|
- Mailpit UI (dev SMTP): http://localhost:8025
|
||||||
|
- SeaweedFS S3 API (dev object storage): http://localhost:8333
|
||||||
|
|
||||||
### Frontend (Node)
|
### Frontend (Node, outside Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
@@ -128,9 +129,8 @@ tooling/
|
|||||||
pipeline/ C++20 seed-data generation CLI (CMake)
|
pipeline/ C++20 seed-data generation CLI (CMake)
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
architecture.md High-level architecture overview
|
web/ Architecture, setup, docker, testing, diagrams
|
||||||
website/ Backend/frontend setup, docker, testing, diagrams
|
pipeline/ Pipeline docs, ethics notes, diagrams
|
||||||
pipeline/ Pipeline docs, ethics notes, PlantUML diagrams
|
|
||||||
|
|
||||||
archive/
|
archive/
|
||||||
next-js-web-app/ Older Next.js frontend (reference only)
|
next-js-web-app/ Older Next.js frontend (reference only)
|
||||||
@@ -138,14 +138,18 @@ archive/
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Create a test environment file, then run the backend test stack with Docker:
|
Create a test environment file, then run the full test stack (backend and
|
||||||
|
frontend) with Docker:
|
||||||
|
|
||||||
```bash
|
```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
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Testing](docs/website/testing.md) for the full command list.
|
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/web/testing.md) for the
|
||||||
|
full command list.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -156,14 +160,14 @@ Common active variables:
|
|||||||
`WEBSITE_BASE_URL`
|
`WEBSITE_BASE_URL`
|
||||||
- Frontend runtime: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
|
- 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
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
3. Commit your changes (`git commit -m 'Add 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`)
|
`npm run lint`, `npm run typecheck`)
|
||||||
5. Push to the branch (`git push origin feature/amazing-feature`)
|
5. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
6. Open a Pull Request
|
6. Open a Pull Request
|
||||||
|
|||||||
@@ -1,589 +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.Auth`, `Features.Breweries`,
|
|
||||||
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
|
|
||||||
project that owns its own MediatR commands/queries/handlers, validators, and
|
|
||||||
repository end to end. `Features.Emails` and `Features.Locations` have no
|
|
||||||
`Controllers/` folder — both 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.Auth │Features. │ Features. │ Features.Emails │ Features. │
|
|
||||||
│ │Breweries │ UserManagement │ (no controller, │ Locations │
|
|
||||||
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │
|
|
||||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
|
|
||||||
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
|
|
||||||
│ Handlers │ Handlers │ Handlers │ │ │
|
|
||||||
│ Repository │ Repository │ Repository │ EmailDispatcher │ │
|
|
||||||
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
|
|
||||||
↓ 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.Auth` 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.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**Dependencies**:
|
|
||||||
|
|
||||||
- `Domain.Entities`, `Domain.Exceptions`
|
|
||||||
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
|
|
||||||
project the slice needs (`Infrastructure.Jwt`/
|
|
||||||
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
|
|
||||||
`Infrastructure.Email.Templates` for Emails)
|
|
||||||
- `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)
|
|
||||||
|
|
||||||
**Dependencies**:
|
|
||||||
|
|
||||||
- Domain entities
|
|
||||||
- External libraries (ADO.NET, JWT, Argon2, MailKit, 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)
|
|
||||||
|
|
||||||
**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.Auth/Repository/IAuthRepository.cs`
|
|
||||||
- `Features.Breweries/Repository/IBreweryRepository.cs`
|
|
||||||
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
|
|
||||||
- `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection
|
|
||||||
factory every slice's repository builds on
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
|
|
||||||
- Testable (easy to mock)
|
|
||||||
- Each slice's data access logic is self-contained
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public interface IAuthRepository
|
|
||||||
{
|
|
||||||
Task<UserCredential> GetUserCredentialAsync(string username);
|
|
||||||
Task<int> CreateUserAccountAsync(UserAccount user, UserCredential credential);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 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.Auth`'s `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.Auth`'s `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-BeerPostComment.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.Auth`'s repository)
|
|
||||||
- Brewery posts with locations (via `Features.Breweries`' repository)
|
|
||||||
|
|
||||||
Tables that exist in the schema but have no repository yet (`Photo`,
|
|
||||||
`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`,
|
|
||||||
`BeerPostComment`, `BreweryPostPhoto`) are not seeded.
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
**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.Auth.Tests, Features.Breweries.Tests,
|
|
||||||
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.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,
|
This document covers the ethical context of the Biergarten Pipeline's output,
|
||||||
the model's biases, and known issues including hallucinated brewing science and
|
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
|
**Natural language processing is a powerful tool for data analysis and
|
||||||
generation, but its output should be treated with scrutiny. Human language is
|
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.**
|
that artificial intelligence cannot capture.**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -102,7 +111,7 @@ vendored verbatim, unmodified, from
|
|||||||
(the `common-forenames-by-country.json` / `common-surnames-by-country.json`
|
(the `common-forenames-by-country.json` / `common-surnames-by-country.json`
|
||||||
release assets), released under **CC0** (public domain). That dataset's own
|
release assets), released under **CC0** (public domain). That dataset's own
|
||||||
forename/surname lists are pulled from Wikipedia's "Lists of most common
|
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
|
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
|
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
|
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
|
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
|
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`,
|
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.
|
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
|
uniformly at random within a 5 km disc centred on the city's curated
|
||||||
`latitude`/`longitude` from `locations.json`.
|
`latitude`/`longitude` from `locations.json`.
|
||||||
|
|
||||||
The resulting point is **plausible by construction** — it always falls within
|
The coordinate pair itself is **plausible by construction**, always falling
|
||||||
the city's vicinity — but it is not looked up from any real address registry.
|
within the city's vicinity, but does not correspond to a real business or
|
||||||
It does not correspond to a real address, business, or resident, and should
|
resident, and should not be treated as accurate geocoding data. This is
|
||||||
not be treated as accurate geocoding data. This is consistent with the rest of
|
consistent with the rest of the dataset: fixture data for a proof-of-concept,
|
||||||
the dataset: fixture data for a proof-of-concept, not a source of truth.
|
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,
|
A C++20 command-line pipeline that samples city records from local JSON,
|
||||||
enriches each with Wikipedia context, and generates bilingual brewery names and
|
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
|
> **This pipeline produces AI-generated data.** It is not a source of truth for
|
||||||
> brewing techniques, cultural representation, or local-language accuracy. See
|
> 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.
|
> 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)
|
- [How it fits the main app](#how-it-fits-the-main-app)
|
||||||
- [Quick start](#quick-start)
|
- [Quick start](#quick-start)
|
||||||
@@ -101,25 +110,27 @@ Each run writes a fresh dated SQLite file such as
|
|||||||
|
|
||||||
#### CLI flags
|
#### CLI flags
|
||||||
|
|
||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
| `--mocked` | Deterministic mock generator, no model required. |
|
| `--mocked` | Deterministic mock generator, no model required. |
|
||||||
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
| `--model, -m` | Path to a GGUF file. Selects the local Llama.cpp generator. |
|
||||||
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
| `--openai` | Use the OpenAI API (Chat Completions) for brewery/user data. Requires the `OPENAI_API_KEY` environment variable. |
|
||||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
| `--openai-model` | OpenAI model ID used when `--openai` is set. Default: `gpt-4o-mini`. |
|
||||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
||||||
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||||
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||||
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
||||||
| `--top-k` | Top-k sampling. Default: `64`. |
|
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||||
| `--n-ctx` | Context window size. Default: `8192`. |
|
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||||
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||||
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
|
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||||
| `--help, -h` | Print usage and exit. |
|
| `--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
|
`--mocked`, `--model`, and `--openai` are mutually exclusive. Omitting all three
|
||||||
error before the pipeline starts. Sampling flags are ignored when `--mocked` is
|
exits with an error before the pipeline starts. Sampling flags are ignored
|
||||||
set.
|
unless `--model` is set (Llama).
|
||||||
|
|
||||||
The post-build step copies `prompts/` into `build/prompts/`. Rebuild after
|
The post-build step copies `prompts/` into `build/prompts/`. Rebuild after
|
||||||
editing any prompt file.
|
editing any prompt file.
|
||||||
@@ -176,7 +187,7 @@ output to confirm the fast path was taken.
|
|||||||
### Run the container
|
### Run the container
|
||||||
|
|
||||||
The container always runs the model-backed path; there is no `--mocked`
|
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
|
The entrypoint, `runpod/start.sh`, downloads the GGUF model automatically if it
|
||||||
is not already present at the configured path.
|
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
|
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
|
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
|
#### Environment variables
|
||||||
|
|
||||||
@@ -211,8 +222,9 @@ configurable via environment variable.
|
|||||||
|
|
||||||
Use a GPU pod template. Mount persistent storage for `/workspace/models`,
|
Use a GPU pod template. Mount persistent storage for `/workspace/models`,
|
||||||
`/workspace/output`, and `/workspace/logs`. See
|
`/workspace/output`, and `/workspace/logs`. See
|
||||||
`tooling/pipeline/runpod/pod-template.yaml` for a starter template — set the
|
`tooling/pipeline/runpod/pod-template.yaml` for a starter template; set the
|
||||||
environment variables listed above to match your run.
|
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`). |
|
| 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. |
|
| 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 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. |
|
| 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 `locations`, `users`, and `breweries` tables. |
|
| 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. |
|
| Log | `spdlog` writes results and warnings to the console. |
|
||||||
|
|
||||||
If name sampling, enrichment, or generation fails for a city, that city is
|
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
|
### Key components
|
||||||
|
|
||||||
- `src/main.cc` — argument parsing and Boost.DI composition root.
|
| Component | Description |
|
||||||
- `CuratedJsonDataService` — implements `ICuratedDataService`; takes a
|
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
`CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in
|
| `src/main.cc` | Argument parsing and the Boost.DI composition root. |
|
||||||
its constructor, then parses and validates curated location, persona, and
|
| `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. |
|
||||||
forename/surname JSON, memoizing each result after its first load on a given
|
| `WikipediaEnrichmentService` | Queries Wikipedia extracts, caches results, and returns empty context on failure. `MockEnrichmentService` is the no-op substitute used in `--mocked` runs. |
|
||||||
instance. `MockCuratedDataService` is the in-memory substitute (4 fixed
|
| `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. |
|
||||||
locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in
|
| `MockGenerator` | Uses stable hash-based output so the same city/persona/name input always produces the same brewery or user. |
|
||||||
`--mocked` runs.
|
| `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. |
|
||||||
- `WikipediaEnrichmentService` — queries Wikipedia extracts, caches results,
|
| `SqliteExportService` | Creates a dated SQLite file per run and persists each successful user and brewery into normalized tables. |
|
||||||
returns empty context on failure. `MockEnrichmentService` is the no-op
|
| 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. |
|
||||||
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.
|
|
||||||
|
|
||||||
### Runtime behaviour
|
### Runtime behaviour
|
||||||
|
|
||||||
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
|
| Component / behavior | Details |
|
||||||
"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
|
| `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. |
|
||||||
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. |
|
||||||
`GetLocationContext()` returns an empty string when the web client is
|
| `MockGenerator` | Uses stable hashes for repeatable output in demos and Storybook runs. |
|
||||||
unavailable or when lookup/parsing fails.
|
| `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. |
|
||||||
`LlamaGenerator` validates model output as structured JSON. On validation
|
| `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. |
|
||||||
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.
|
|
||||||
|
|
||||||
### Process flow - activity diagram
|
### Process flow - activity diagram
|
||||||
|
|
||||||

|
See [Activity diagram](./diagrams/current/activity.md).
|
||||||
|
|
||||||
### Architectural overview - class diagram
|
### 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
|
`UserResult` payload. The same generated records are also written to a fresh
|
||||||
SQLite export file named with the current UTC timestamp.
|
SQLite export file named with the current UTC timestamp.
|
||||||
|
|
||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
| ------------------- | ------------------------------------------ |
|
| ------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| `name_en` | Brewery name in English. |
|
| `name_en` | Brewery name in English. |
|
||||||
| `description_en` | Brewery description in English. |
|
| `description_en` | Brewery description in English. |
|
||||||
| `name_local` | Brewery name in the local language. |
|
| `name_local` | Brewery name in the local language. |
|
||||||
| `description_local` | Brewery description 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 |
|
| 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
|
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.
|
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
|
> **Code style:** The code follows the Google C++ Style Guide, except that
|
||||||
> features modern C++20 throughout. Formatting follows the Google C++ Style
|
> indentation is 3 spaces instead of Google's 2, and uses C++20 throughout.
|
||||||
> Guide, with the exception of indentation width (3 spaces vs. Google's 2).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tested hardware
|
## 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 |
|
| Model | Gemma 4 E4B |
|
||||||
| Inference | llama.cpp with Metal |
|
| 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 |
|
| Model | Gemma 4 E4B |
|
||||||
| Inference | llama.cpp with CUDA 12.x |
|
| 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
|
- Keep `locations.json` structured enough to support discovery and future
|
||||||
filtering.
|
filtering.
|
||||||
- `personas.json`, `forenames-by-country.json`, and `surnames-by-country.json`
|
- `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.
|
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,
|
- Treat SQLite output as seed material for the app's brewery and user domains,
|
||||||
not production data.
|
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/`.
|
Paths below are relative to `tooling/pipeline/`.
|
||||||
|
|
||||||
- `src/main.cc` — argument parsing and DI composition root.
|
| Path | Purpose |
|
||||||
- `src/biergarten_pipeline_orchestrator/` — orchestration, sampling, logging,
|
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
and export.
|
| `src/main.cc` | Argument parsing and DI composition root. |
|
||||||
- `src/services/curated_data/` — `CuratedJsonDataService`, the file-backed
|
| `src/biergarten_pipeline_orchestrator/` | Orchestration, sampling, logging, and export. |
|
||||||
`ICuratedDataService`, and `MockCuratedDataService`, the in-memory
|
| `src/services/curated_data/` | `CuratedJsonDataService`, the file-backed `ICuratedDataService`, and `MockCuratedDataService`, the in-memory `ICuratedDataService` used in `--mocked` runs. |
|
||||||
`ICuratedDataService` used in `--mocked` runs.
|
| `src/services/enrichment/wikipedia/` | Enrichment service and cache. |
|
||||||
- `src/services/enrichment/wikipedia/` — enrichment service and cache.
|
| `src/services/sqlite/` | SQLite export implementation. |
|
||||||
- `src/services/sqlite/` — SQLite export implementation.
|
| `src/data_generation/llama/` | Local inference, prompt loading, and output validation. |
|
||||||
- `src/data_generation/llama/` — local inference, prompt loading, output
|
| `src/data_generation/mock/` | Deterministic fallback. |
|
||||||
validation.
|
| `runpod/` | Container build and runtime launcher. |
|
||||||
- `src/data_generation/mock/` — deterministic fallback.
|
|
||||||
- `runpod/` — container build and runtime launcher.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
The pipeline currently produces city-aware brewery and user records and dated
|
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
|
SQLite exports.
|
||||||
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 — 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
|
- Unit test JSON validation and retry logic against malformed, truncated, and
|
||||||
empty model outputs.
|
empty model outputs.
|
||||||
@@ -481,7 +465,7 @@ enough to exercise search, sort, and category filters.
|
|||||||
### Check-in system
|
### Check-in system
|
||||||
|
|
||||||
Produce timestamped check-in events between users and breweries. Use a J-curve
|
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.
|
appear occasionally. Add bursty behaviour around weekends and travel periods.
|
||||||
|
|
||||||
### Beer ratings
|
### 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**
|
Engineering breakdown for closing the gap between the **current** pipeline and
|
||||||
pipeline and the **planned** architecture documented in
|
the **planned** architecture in
|
||||||
[`diagrams/planned/class.puml`](./diagrams/planned/class.puml) and
|
[`diagrams/planned/class.md`](./diagrams/planned/class.md) /
|
||||||
[`diagrams/planned/activity.puml`](./diagrams/planned/activity.puml). Nothing in
|
[`diagrams/planned/activity.md`](./diagrams/planned/activity.md). Items are
|
||||||
`diagrams/planned/` is implemented yet — this file tracks what it would take to
|
grouped by layer, roughly by dependency order. For the current implementation,
|
||||||
get there. For the current implementation, see [README.md](./README.md).
|
see [README.md](./README.md).
|
||||||
|
|
||||||
Items are grouped by layer and roughly ordered by dependency: later groups build
|
The only concurrency today is a log dispatcher thread draining a
|
||||||
on types and services introduced earlier.
|
`BoundedChannel<LogEntry>` into spdlog; sampling, enrichment, generation, and
|
||||||
|
export run synchronously on the main thread — §6 introduces the planned worker
|
||||||
The only concurrency in the current pipeline is the log dispatcher thread
|
threads.
|
||||||
(`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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,222 +26,183 @@ architecture needs.
|
|||||||
|
|
||||||
`tooling/pipeline/includes/data_model/generated_models.h` and `models.h`.
|
`tooling/pipeline/includes/data_model/generated_models.h` and `models.h`.
|
||||||
|
|
||||||
- [ ] Add `Completeness` enum (`Full`, `Partial`, `Absent`) and a
|
- [ ] Add `Completeness` enum (`Full`/`Partial`/`Absent`) and a
|
||||||
`LocationContext` struct (`text`, `completeness`, `char_count`). Replace
|
`LocationContext` struct (`text`, `completeness`, `char_count`); replace
|
||||||
`EnrichedCity::region_context` (currently a plain `std::string`) with
|
`EnrichedCity::region_context` (a plain `string` today) with it.
|
||||||
`context : LocationContext`.
|
|
||||||
- [ ] Add `BeerStyle` (`name`, `description`, `min_abv`, `max_abv`, `min_ibu`,
|
- [ ] Add `BeerStyle` (`name`, `description`, `min_abv`, `max_abv`, `min_ibu`,
|
||||||
`max_ibu`).
|
`max_ibu`).
|
||||||
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` result payloads.
|
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` payloads.
|
||||||
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
|
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
|
||||||
`context_provided`, `generated_with`).
|
`context_provided`, `generated_with`).
|
||||||
- [x] Add `first_name`, `last_name`, `gender`, and `activity_weight` to
|
- [x] `UserResult` gained `first_name`/`last_name`/`gender`/`activity_weight`
|
||||||
`UserResult` (currently just `username`, `bio`). `first_name`/
|
(was just `username`/`bio`), copied from the sampled `Name`, not
|
||||||
`last_name`/`gender` are copied from the sampled `Name` (see below), not
|
|
||||||
LLM-invented.
|
LLM-invented.
|
||||||
- [x] Add `Name` (`first_name`, `last_name`, `gender`) — the sampled result
|
- [x] Added `Name` (`first_name`, `last_name`, `gender`) and `ForenameEntry`
|
||||||
handed to `DataGenerator::GenerateUser`. Add `ForenameEntry` (`name`,
|
(`name`, `gender`). Flatter than planned: no `NamesByCountry` wrapper —
|
||||||
`gender`). Landed as a flatter shape than originally planned here: no
|
`ICuratedDataService` exposes `ForenamesByCountryMap`/
|
||||||
`NamesByCountry` wrapper class. `curated_data_service.h` instead declares
|
`SurnamesByCountryMap` (keyed by ISO 3166-1) directly, and sampling is a
|
||||||
`ForenameList = std::vector<ForenameEntry>` and
|
free function, `SampleName(forenames, surnames, iso3166_1, rng)`, not a
|
||||||
`SurnameList = std::vector<std::string>`, and `ICuratedDataService`
|
class method.
|
||||||
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.
|
|
||||||
- [ ] Extend `GeneratedBrewery` with `brewery_id`, `context_completeness`,
|
- [ ] Extend `GeneratedBrewery` with `brewery_id`, `context_completeness`,
|
||||||
`metadata` (currently just `{location, brewery}`).
|
`metadata` (currently just `{location, brewery}`).
|
||||||
- [ ] Add `GeneratedBeer`, `GeneratedCheckin`, `GeneratedRating`,
|
- [ ] Add `GeneratedBeer`, `GeneratedCheckin`, `GeneratedRating`,
|
||||||
`GeneratedFollow` aggregate structs.
|
`GeneratedFollow` aggregate structs.
|
||||||
- [x] Add `UserRecord` (`location`, `user : UserResult`, `email`,
|
- [x] Added `UserRecord` (`location`, `user`, `email`, `date_of_birth`), the
|
||||||
`date_of_birth`) as the current stand-in for the planned `GeneratedUser` —
|
current stand-in for the planned `GeneratedUser`. `email`/ `date_of_birth`
|
||||||
`email` and `date_of_birth` are programmatically generated by the
|
are programmatic (never LLM-authored), for downstream auth-account
|
||||||
orchestrator, never LLM-authored, so a downstream auth-account seeding
|
seeding. Missing `user_id`/`password`/`metadata`; already exported via
|
||||||
consumer can register real accounts from the pipeline's SQLite export. No
|
`ProcessRecord(const UserRecord&)` (§5).
|
||||||
`password`, `user_id`, or `metadata` field yet, matching `BreweryRecord`'s
|
- [x] Added `UserPersona` (`name`, `description`, `style_affinities`).
|
||||||
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`).
|
|
||||||
|
|
||||||
## 2. Data Preloading
|
## 2. Data Preloading
|
||||||
|
|
||||||
`tooling/pipeline/includes/services/curated_data/`, fixture files.
|
`tooling/pipeline/includes/services/curated_data/`, fixture files.
|
||||||
|
|
||||||
- [x] Extract an interface; have `CuratedJsonDataService` implement it. Landed
|
- [x] Extracted `ICuratedDataService` (not `DataPreloader`); `Load*()` methods
|
||||||
as `ICuratedDataService` (`services/curated_data/curated_data_service.h`),
|
take no arguments, return `const&`. `CuratedJsonDataService` (renamed from
|
||||||
not `DataPreloader` — `LoadLocations()`, `LoadPersonas()`,
|
`JsonLoader`) takes a `CuratedDataFilePaths` DTO at construction and
|
||||||
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` are all virtual
|
memoizes each `Load*()` result. `MockCuratedDataService` (fixed data for
|
||||||
methods returning `const&` and take no arguments. `CuratedJsonDataService`
|
`US`/`DE`/`FR`/`BE`) binds in its place under `--mocked`.
|
||||||
(`services/curated_data/curated_json_data_service.h`, originally landed as
|
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` exists and is copied into the
|
||||||
`JsonLoader` under `json_handling/` before being renamed and moved) takes
|
Docker image, but no loader reads it and the native CMake build doesn't
|
||||||
a `CuratedDataFilePaths` DTO (`locations_path`, `personas_path`,
|
copy it into `build/` at all.
|
||||||
`forenames_path`, `surnames_path`) in its constructor rather than a path
|
- [x] Added `LoadPersonas()` and authored `personas.json`.
|
||||||
per `Load*()` call, and memoizes each result in a private `cache` struct
|
- [x] Added `LoadForenamesByCountry()` / `LoadSurnamesByCountry()` (two methods,
|
||||||
(`locations`, `personas`, `forenames_by_country`, `surnames_by_country`),
|
not one combined `LoadNamesByCountry()`), parsing vendored CC0 data from
|
||||||
so a second call on the same instance returns the cached result instead of
|
`sigpwned/popular-names-by-country-dataset` — see
|
||||||
re-parsing — safe because `CuratedJsonDataService` outlives every call
|
ETHICS-AND-KNOWN-ISSUES.md for provenance. Deliberately not pre-paired or
|
||||||
site (owned by `BiergartenPipelineOrchestrator` via
|
filtered to `locations.json`'s countries, to keep per-forename gender and
|
||||||
`unique_ptr<ICuratedDataService>` for the whole run). `main.cc`'s DI
|
support more countries later without re-sourcing.
|
||||||
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.
|
|
||||||
|
|
||||||
## 3. Policy / Strategy Layer
|
## 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` /
|
- [ ] `ContextStrategy` + `BreweryContextStrategy`/`BeerContextStrategy`. Today
|
||||||
`BeerContextStrategy`. Today
|
`WikipediaEnrichmentService::GetLocationContext` hardcodes a `"brewing"`
|
||||||
`WikipediaEnrichmentService::GetLocationContext` hardcodes a generic
|
query and a `"beer in {country}"` query directly.
|
||||||
`"brewing"` query and a `"beer in {country}"` query directly — no
|
- [ ] `SamplingStrategy` + `UniformSamplingStrategy`, replacing the inline
|
||||||
per-phase strategy selection.
|
`std::ranges::sample(...)` in `QueryCitiesWithCountries()`.
|
||||||
- [ ] `SamplingStrategy` interface + `UniformSamplingStrategy`, replacing the
|
- [ ] `BeerSelectionStrategy` + `RandomBeerSelectionStrategy` (depends on §1,
|
||||||
inline `std::ranges::sample(...)` call in
|
§2).
|
||||||
`BiergartenPipelineOrchestrator::QueryCitiesWithCountries()`.
|
- [ ] `CheckinDistributionStrategy` + `JCurveCheckinStrategy` /
|
||||||
- [ ] `BeerSelectionStrategy` interface + `RandomBeerSelectionStrategy`, to pick
|
`RandomCheckinStrategy` — the "Check-In System" README next step, made
|
||||||
styles per brewery from the `BeerStyle` palette (depends on §1 and §2).
|
concrete.
|
||||||
- [ ] `CheckinDistributionStrategy` interface + `JCurveCheckinStrategy` /
|
- [ ] `FollowGenerationStrategy` + `RandomFollowStrategy` /
|
||||||
`RandomCheckinStrategy` — this is the "Check-In System" item already
|
|
||||||
called out in README.md's Next Steps, made concrete.
|
|
||||||
- [ ] `FollowGenerationStrategy` interface + `RandomFollowStrategy` /
|
|
||||||
`ActivityWeightedFollowStrategy`.
|
`ActivityWeightedFollowStrategy`.
|
||||||
|
|
||||||
## 4. Data Generation
|
## 4. Data Generation
|
||||||
|
|
||||||
`tooling/pipeline/includes/data_generation/`, `src/data_generation/`.
|
`tooling/pipeline/includes/data_generation/`, `src/data_generation/`.
|
||||||
|
|
||||||
- [ ] Extend the `DataGenerator` interface with `GenerateBeer`,
|
- [ ] Extend `DataGenerator` with `GenerateBeer`, `GenerateCheckin`,
|
||||||
`GenerateCheckin`, `GenerateRating` (today: only `GenerateBrewery` and
|
`GenerateRating` (today: only `GenerateBrewery`/`GenerateUser`).
|
||||||
`GenerateUser`).
|
- [x] Added `OpenAIGenerator`, a third backend calling the OpenAI Chat
|
||||||
- [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop
|
Completions API with Structured Outputs (`json_schema`, `strict`) —
|
||||||
mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3
|
schema-valid JSON with no `IPromptFormatter`/GBNF grammar needed. Selected
|
||||||
attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`.
|
via `--openai`/`GeneratorMode::kOpenAI`; landed ahead of §3/§4. Now shown
|
||||||
Changed the `DataGenerator::GenerateUser` signature to
|
in `diagrams/planned/class.md` alongside `MockGenerator`/`LlamaGenerator`,
|
||||||
`(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult`,
|
noting only `GenerateBrewery`/`GenerateUser` are implemented so far.
|
||||||
matching what the activity diagram actually passes.
|
- [x] Implemented `LlamaGenerator::GenerateUser` for real (GBNF grammar,
|
||||||
`MockGenerator::GenerateUser` updated to match the new signature too.
|
`ValidateUserJson`, 3 retries with corrective feedback,
|
||||||
- [ ] Implement `MockGenerator::GenerateBeer` / `GenerateCheckin` /
|
`prompts/USER_GENERATION.md`). Signature is
|
||||||
`GenerateRating`.
|
`(city, persona, name) : UserResult`, matching the activity diagram.
|
||||||
|
- [ ] Implement
|
||||||
|
`MockGenerator::GenerateBeer`/`GenerateCheckin`/`GenerateRating`.
|
||||||
- [ ] Add `IPromptFormatter::ExpectedArchitecture()` and
|
- [ ] Add `IPromptFormatter::ExpectedArchitecture()` and
|
||||||
`LlamaGenerator::ValidateModelArchitecture()` so loading a GGUF that
|
`LlamaGenerator::ValidateModelArchitecture()` so a mismatched GGUF chat
|
||||||
doesn't match the configured chat template fails fast instead of silently
|
template fails fast instead of degrading silently.
|
||||||
producing degraded output.
|
- [ ] Add prompt templates for beer/checkin/rating, next to
|
||||||
- [ ] Add prompt template files for beer/checkin/rating generation next to the
|
`prompts/BREWERY_GENERATION.md`.
|
||||||
existing `prompts/BREWERY_GENERATION.md`.
|
|
||||||
|
|
||||||
## 5. Export Service
|
## 5. Export Service
|
||||||
|
|
||||||
`tooling/pipeline/includes/services/database/`, `src/services/sqlite/`.
|
`tooling/pipeline/includes/services/database/`, `src/services/sqlite/`.
|
||||||
|
|
||||||
- [x] `IExportService::ProcessRecord(const UserRecord&)` and
|
- [x] `IExportService::ProcessRecord(const UserRecord&)` and the `users` table
|
||||||
`SqliteExportService`'s `users` table (see `kCreateUsersTableSql` /
|
are implemented — every successful user is exported with its resolved
|
||||||
`kInsertUserSql` in
|
|
||||||
`includes/services/database/sqlite_statement_helpers.h`) are implemented —
|
|
||||||
`GenerateUsers()` exports every successful user alongside its resolved
|
|
||||||
`location_id`.
|
`location_id`.
|
||||||
- [ ] Extend `IExportService` with `ProcessBeer`, `ProcessCheckin`,
|
- [ ] Extend `IExportService` with `ProcessBeer`, `ProcessCheckin`,
|
||||||
`ProcessRating`, `ProcessFollow` (today
|
`ProcessRating`, `ProcessFollow`.
|
||||||
`ProcessRecord(const BreweryRecord&)` and
|
|
||||||
`ProcessRecord(const UserRecord&)` exist).
|
|
||||||
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
|
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
|
||||||
`SqliteExportService::InitializeSchema()` (`locations`, `breweries`, and
|
`InitializeSchema()` (`cities`/`breweries`/`brewery_addresses`/`users`/
|
||||||
`users` already exist) — see `kCreateLocationsTableSql` /
|
`user_addresses` already exist — see `sqlite_statement_helpers.h` for the
|
||||||
`kCreateBreweriesTableSql` / `kCreateUsersTableSql` in
|
pattern).
|
||||||
`includes/services/database/sqlite_statement_helpers.h` for the existing
|
- [ ] Add a `brewery_cache_` alongside `city_cache_`, and move from one open
|
||||||
pattern to follow.
|
transaction for the whole run to per-phase batched commits (`BEGIN` /
|
||||||
- [ ] Add a `brewery_cache_` alongside the existing `location_cache_`, and move
|
`COMMIT & BEGIN` on a batch threshold), per the planned activity diagram.
|
||||||
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.
|
|
||||||
|
|
||||||
## 6. Concurrency & Orchestration
|
## 6. Concurrency & Orchestration
|
||||||
|
|
||||||
`tooling/pipeline/includes/concurrency/`,
|
`tooling/pipeline/includes/concurrency/`,
|
||||||
`src/biergarten_pipeline_orchestrator/`.
|
`src/biergarten_pipeline_orchestrator/`.
|
||||||
|
|
||||||
- [ ] `BoundedChannel<T>` already exists and is production-tested — but it's
|
- [ ] `BoundedChannel<T>` exists and is production-tested but only wired to the
|
||||||
only wired to the single log channel today. Stand up the per-phase
|
log channel. Stand up the per-phase producer/consumer channels (`loc_ch`,
|
||||||
producer/consumer channels (`loc_ch`, `exp_ch`, etc.) the planned activity
|
`exp_ch`, etc.) with a dedicated LLM worker thread and SQLite worker
|
||||||
diagram describes, each with a dedicated LLM worker thread and SQLite
|
thread each; the brewery phase needs a third, a Geocode Worker between
|
||||||
worker thread.
|
them on its own `geo_ch` (§9).
|
||||||
- [ ] Rewrite `BiergartenPipelineOrchestrator::Run()` from one synchronous pass
|
- [ ] Rewrite `Run()` from one synchronous pass into phased methods —
|
||||||
over `generated_breweries_` into phased methods — `RunUserPhase` →
|
`RunUserPhase` → `RunBreweryAndBeerPhase` (`RunBeerPhase` sub-step once
|
||||||
`RunBreweryAndBeerPhase` (with a `RunBeerPhase` sub-step once
|
`brewery_pool_` fills) → `RunCheckinPhase`/`RunFollowPhase` (parallel) →
|
||||||
`brewery_pool_` is populated) → `RunCheckinPhase` / `RunFollowPhase`
|
`RunRatingPhase` — backed by `user_pool_`, `brewery_pool_`, `beer_pool_`,
|
||||||
(these two can run in parallel) → `RunRatingPhase` — backed by
|
`checkin_pool_`, `follow_pool_`.
|
||||||
`user_pool_`, `brewery_pool_`, `beer_pool_`, `checkin_pool_`,
|
- [ ] Structurally join worker threads (e.g. `std::jthread`) before `Run()`
|
||||||
`follow_pool_`.
|
returns, per the existing comment in `biergarten_pipeline_orchestrator.h`,
|
||||||
- [ ] Honor the structural-concurrency requirement already called out in a
|
so nothing logs to a closed channel during teardown.
|
||||||
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.
|
|
||||||
|
|
||||||
## 7. Enrichment
|
## 7. Enrichment
|
||||||
|
|
||||||
- [ ] Decide whether to restore the city/region-specific Wikipedia query that's
|
- [ ] Decide whether to restore the city/region-specific Wikipedia query
|
||||||
currently commented out in
|
currently commented out in `get_summary.cc` — the §3 `ContextStrategy`
|
||||||
`src/services/enrichment/wikipedia/get_summary.cc` (`GetLocationContext`).
|
work is a natural place to reintroduce it.
|
||||||
The `ContextStrategy` work in §3 is a natural place to reintroduce it via
|
|
||||||
`BreweryContextStrategy::QueriesFor()`.
|
|
||||||
- [ ] Pre-warm caches at startup (`PreWarmBeerStyleCache`,
|
- [ ] Pre-warm caches at startup (`PreWarmBeerStyleCache`,
|
||||||
`PreWarmLocationCache` in the planned activity diagram) instead of
|
`PreWarmLocationCache`) instead of fetching lazily per record.
|
||||||
fetching lazily per record, so the streaming phases never block on a cold
|
|
||||||
cache mid-run.
|
|
||||||
|
|
||||||
## 8. Fixtures / Build
|
## 8. Fixtures / Build
|
||||||
|
|
||||||
- [x] Author `personas.json`, `forenames-by-country.json`, and
|
- [x] Authored `personas.json`, `forenames-by-country.json`,
|
||||||
`surnames-by-country.json` (see §2).
|
`surnames-by-country.json` (§2).
|
||||||
- [ ] Fix the `beer-styles.json` build-tree gap: add it to the "Runtime Assets"
|
- [ ] 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
|
`configure_file` step in `CMakeLists.txt`.
|
||||||
image agree on what's available at runtime.
|
|
||||||
|
## 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
|
## Suggested build order
|
||||||
|
|
||||||
The planned activity diagram's own phase notes ("brewery*pool* is now fully
|
The planned activity diagram's phase notes imply a dependency order:
|
||||||
populated. Phase 1b may begin.", etc.) imply a dependency order. Roughly:
|
|
||||||
|
|
||||||
1. Domain models (§1) and data preloading (§2) — nothing else compiles without
|
1. Domain models (§1) and data preloading (§2) — nothing else compiles without
|
||||||
these.
|
these.
|
||||||
2. Export schema (§5) — so every later generation phase has somewhere to land
|
2. Export schema (§5) — so every generation phase has somewhere to land output.
|
||||||
its output.
|
|
||||||
3. Policy/strategy layer (§3) and generator interface (§4).
|
3. Policy/strategy layer (§3) and generator interface (§4).
|
||||||
4. Concurrency/orchestration rewrite (§6), which is the only piece that actually
|
4. Concurrency/orchestration rewrite (§6) — wires §1–§5 into the phased,
|
||||||
wires §1–§5 into the phased, parallel pipeline shown in the planned diagrams.
|
parallel pipeline the planned diagrams show.
|
||||||
5. Enrichment cache pre-warming and the city/region query decision (§7) — useful
|
5. Enrichment cache pre-warming and the city/region query decision (§7) — most
|
||||||
at any point, but most valuable once phases run concurrently.
|
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"
|
||||||
|
```
|
||||||
90
docs/pipeline/diagrams/current/activity.mmd
Normal file
90
docs/pipeline/diagrams/current/activity.mmd
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
flowchart LR
|
||||||
|
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"
|
||||||
|
```
|
||||||
290
docs/pipeline/diagrams/current/class.mmd
Normal file
290
docs/pipeline/diagrams/current/class.mmd
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
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"
|
||||||
|
```
|
||||||
213
docs/pipeline/diagrams/planned/activity.mmd
Normal file
213
docs/pipeline/diagrams/planned/activity.mmd
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
flowchart LR
|
||||||
|
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.
|
||||||
647
docs/pipeline/diagrams/planned/class.mmd
Normal file
647
docs/pipeline/diagrams/planned/class.mmd
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
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 |
611
docs/web/architecture.md
Normal file
611
docs/web/architecture.md
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
# 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"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DOMAIN["Domain Layer"]
|
||||||
|
direction LR
|
||||||
|
D1["Domain.Entities"]
|
||||||
|
D2["Domain.Exceptions"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph INFRA["Infrastructure Layer"]
|
||||||
|
direction LR
|
||||||
|
I1["Database.Connection"]
|
||||||
|
I3["Infrastructure.PasswordHashing"]
|
||||||
|
I4["Email.Templates"]
|
||||||
|
end
|
||||||
|
|
||||||
|
DB[("Database (SQL Server)")]
|
||||||
|
|
||||||
|
HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
|
||||||
|
S3 == "ProjectReference<br/>(handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification)" ==> S1
|
||||||
|
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` | Thin composition root: builds the host and calls the `ServiceCollectionExtensions` methods below. |
|
||||||
|
| `ServiceCollectionExtensions.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`
|
||||||
|
|
||||||
|
**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.PasswordHashing`
|
||||||
|
for Users, `Infrastructure.Email.Templates` for Emails)
|
||||||
|
- `Shared.Contracts`, `Shared.Application`
|
||||||
|
- No other `Features.*` project, with two exceptions: `Features.Users`
|
||||||
|
references `Features.PhotoUpload` directly for avatar uploads, and
|
||||||
|
`Features.Emails` references `Features.Users` directly to handle
|
||||||
|
`UserRegisteredNotification` and `ConfirmationEmailResendRequestedNotification`
|
||||||
|
|
||||||
|
**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` rather than the domain entity
|
||||||
|
directly. The one exception is `UserController.GetAuthenticated`, which
|
||||||
|
returns the caller's own `UserAccount` entity
|
||||||
|
|
||||||
|
#### 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 `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped |
|
||||||
|
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
|
||||||
|
|
||||||
|
**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.PasswordHashing` | Argon2id password hashing. |
|
||||||
|
| `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
|
||||||
|
|
||||||
|
**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
|
||||||
|
or query.
|
||||||
|
- 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
|
||||||
|
│ │ - users public user profile page (/users/:id)
|
||||||
|
│ ├── 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
|
||||||
|
- Render the public user profile route (`/users/:id`), including a user's
|
||||||
|
following list and (via brewery comments) links back to commenter profiles
|
||||||
|
- 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` |
|
||||||
|
| Production seed | `docker-compose.prod.seed.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).
|
||||||
143
docs/web/database.md
Normal file
143
docs/web/database.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
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 schema is split into six SQL Server schemas, one per feature area, each
|
||||||
|
living in its own numbered folder under
|
||||||
|
`web/backend/Database/Database.Migrations/scripts/`. The folder numbers
|
||||||
|
(`01`-`06`) fix the load order so foreign-key dependencies resolve — DbUp runs
|
||||||
|
embedded scripts in alphabetical-by-resource-name order, so e.g. `04-Brewery`
|
||||||
|
(which references `Auth`, `Geolocation`, and `Media`) runs after all three.
|
||||||
|
Each folder's `00-Schema.sql` creates its SQL schema before its tables run:
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts
|
||||||
|
├── 01-Auth (UserAccount, UserVerification, UserCredential)
|
||||||
|
│ ├── 00-Schema.sql
|
||||||
|
│ ├── 01-UserAccount.sql
|
||||||
|
│ ├── 02-UserVerification.sql
|
||||||
|
│ └── 03-UserCredential.sql
|
||||||
|
├── 02-Geolocation (Country, StateProvince, City)
|
||||||
|
│ ├── 00-Schema.sql
|
||||||
|
│ ├── 01-Country.sql
|
||||||
|
│ ├── 02-StateProvince.sql
|
||||||
|
│ └── 03-City.sql
|
||||||
|
├── 03-Media (Photo)
|
||||||
|
│ ├── 00-Schema.sql
|
||||||
|
│ └── 01-Photo.sql
|
||||||
|
├── 04-Brewery (BreweryPost, BreweryPostLocation, BreweryPostPhoto)
|
||||||
|
│ ├── 00-Schema.sql
|
||||||
|
│ ├── 01-BreweryPost.sql
|
||||||
|
│ ├── 02-BreweryPostLocation.sql
|
||||||
|
│ └── 03-BreweryPostPhoto.sql
|
||||||
|
├── 05-Beer (BeerStyle, BeerPost, BeerPostPhoto, BeerPostComment)
|
||||||
|
│ ├── 00-Schema.sql
|
||||||
|
│ ├── 01-BeerStyle.sql
|
||||||
|
│ ├── 02-BeerPost.sql
|
||||||
|
│ ├── 03-BeerPostPhoto.sql
|
||||||
|
│ └── 04-BeerPostComment.sql
|
||||||
|
└── 06-Social (UserFollow, UserProfile, UserAvatar)
|
||||||
|
├── 00-Schema.sql
|
||||||
|
├── 01-UserFollow.sql
|
||||||
|
├── 02-UserProfile.sql
|
||||||
|
└── 03-UserAvatar.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Each script is run in order by `Database.Migrations` (DbUp) on startup. Tables
|
||||||
|
are referenced schema-qualified in code (e.g. `Auth.UserAccount`), not under
|
||||||
|
the default `dbo` schema. 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`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` |
|
||||||
|
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar |
|
||||||
|
| `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`; `CityName` and `StateProvinceID` together are unique; 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; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; 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: Publish(UserRegisteredNotification)
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via UserRegisteredNotificationHandler — 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
|
||||||
|
|
||||||
|
alt Email sent successfully
|
||||||
|
Note right of Mediator: UserRegisteredNotificationHandler completes normally
|
||||||
|
else EmailDispatcher throws
|
||||||
|
Note right of Mediator: UserRegisteredNotificationHandler catches and logs the exception — registration is unaffected
|
||||||
|
end
|
||||||
|
deactivate Mediator
|
||||||
|
|
||||||
|
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken)
|
||||||
|
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"
|
||||||
|
```
|
||||||
78
docs/web/diagrams/auth/resend-confirmation.mmd
Normal file
78
docs/web/diagrams/auth/resend-confirmation.mmd
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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: Publish(ConfirmationEmailResendRequestedNotification)
|
||||||
|
activate Mediator
|
||||||
|
Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via ConfirmationEmailResendRequestedNotificationHandler
|
||||||
|
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
|
||||||
|
|
||||||
|
alt Email sent successfully
|
||||||
|
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler completes normally
|
||||||
|
else EmailDispatcher throws
|
||||||
|
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler catches and logs the exception — the resend request is unaffected
|
||||||
|
end
|
||||||
|
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
|
||||||
47
docs/web/diagrams/database-schema.md
Normal file
47
docs/web/diagrams/database-schema.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Database Schema
|
||||||
|
|
||||||
|
Six diagrams, one per SQL Server schema — mirrors the `Auth` / `Geolocation` /
|
||||||
|
`Media` / `Brewery` / `Beer` / `Social` schema split under
|
||||||
|
`web/backend/Database/Database.Migrations/scripts/`. 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 auth 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.
|
||||||
|
|
||||||
|
## Auth (`Auth`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
--8<-- "web/diagrams/database-schema/auth.mmd"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Social (`Social`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
--8<-- "web/diagrams/database-schema/social.mmd"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Media (`Media`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
--8<-- "web/diagrams/database-schema/media.mmd"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geolocation (`Geolocation`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
--8<-- "web/diagrams/database-schema/geolocation.mmd"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brewery (`Brewery`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
--8<-- "web/diagrams/database-schema/brewery.mmd"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beer (`Beer`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
--8<-- "web/diagrams/database-schema/beer.mmd"
|
||||||
|
```
|
||||||
44
docs/web/diagrams/database-schema/auth.mmd
Normal file
44
docs/web/diagrams/database-schema/auth.mmd
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
erDiagram
|
||||||
|
User ||--o{ Cred : "has credentials"
|
||||||
|
User ||--o| Verify : "has verification"
|
||||||
|
|
||||||
|
%% Cross-schema: defined in full in social.mmd / media.mmd / brewery.mmd / beer.mmd
|
||||||
|
User ||--o| Profile : "has profile"
|
||||||
|
User ||--o{ Follow : "follows"
|
||||||
|
User ||--o{ Follow : "followed by"
|
||||||
|
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
|
||||||
|
}
|
||||||
51
docs/web/diagrams/database-schema/beer.mmd
Normal file
51
docs/web/diagrams/database-schema/beer.mmd
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
erDiagram
|
||||||
|
BeerStyle ||--o{ Beer : "categorizes"
|
||||||
|
Beer ||--o{ BeerPhoto : "has photos"
|
||||||
|
Beer ||--o{ BeerComment : "has comments"
|
||||||
|
|
||||||
|
%% Cross-schema: defined in full in auth.mmd / brewery.mmd / media.mmd
|
||||||
|
User ||--o{ Beer : "posts"
|
||||||
|
Brewery ||--o{ Beer : "brews"
|
||||||
|
User ||--o{ BeerComment : "comments"
|
||||||
|
BeerPhoto }o--|| Photo : "uses photo"
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
BeerPhoto {
|
||||||
|
uniqueidentifier BeerPostPhotoID PK
|
||||||
|
uniqueidentifier BeerPostID FK
|
||||||
|
uniqueidentifier PhotoID FK
|
||||||
|
datetime LinkedAt
|
||||||
|
}
|
||||||
41
docs/web/diagrams/database-schema/brewery.mmd
Normal file
41
docs/web/diagrams/database-schema/brewery.mmd
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
erDiagram
|
||||||
|
Brewery ||--o| BreweryLocation : "has location"
|
||||||
|
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||||
|
Brewery ||--o{ Beer : "brews"
|
||||||
|
|
||||||
|
%% Cross-schema: defined in full in auth.mmd / geolocation.mmd / media.mmd / beer.mmd
|
||||||
|
User ||--o{ Brewery : "posts"
|
||||||
|
BreweryLocation }o--|| City : "located in"
|
||||||
|
BreweryPhoto }o--|| Photo : "uses photo"
|
||||||
|
|
||||||
|
Brewery {
|
||||||
|
uniqueidentifier BreweryPostID PK
|
||||||
|
nvarchar BreweryName
|
||||||
|
uniqueidentifier PostedByID FK
|
||||||
|
nvarchar Description
|
||||||
|
datetime CreatedAt
|
||||||
|
datetime UpdatedAt "nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
BreweryLocation {
|
||||||
|
uniqueidentifier BreweryPostLocationID PK
|
||||||
|
uniqueidentifier BreweryPostID "FK, UK"
|
||||||
|
nvarchar AddressLine1
|
||||||
|
nvarchar AddressLine2 "nullable"
|
||||||
|
nvarchar PostalCode
|
||||||
|
uniqueidentifier CityID FK
|
||||||
|
geography Coordinates "nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
BreweryPhoto {
|
||||||
|
uniqueidentifier BreweryPostPhotoID PK
|
||||||
|
uniqueidentifier BreweryPostID FK
|
||||||
|
uniqueidentifier PhotoID FK
|
||||||
|
datetime LinkedAt
|
||||||
|
}
|
||||||
26
docs/web/diagrams/database-schema/geolocation.mmd
Normal file
26
docs/web/diagrams/database-schema/geolocation.mmd
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
erDiagram
|
||||||
|
Country ||--o{ StateProvince : "has"
|
||||||
|
StateProvince ||--o{ City : "has"
|
||||||
|
|
||||||
|
%% Cross-schema: defined in full in brewery.mmd
|
||||||
|
City ||--o{ BreweryLocation : "located in"
|
||||||
|
|
||||||
|
Country {
|
||||||
|
uniqueidentifier CountryID PK
|
||||||
|
nvarchar CountryName
|
||||||
|
char ISO3166_1 UK
|
||||||
|
}
|
||||||
|
|
||||||
|
StateProvince {
|
||||||
|
uniqueidentifier StateProvinceID PK
|
||||||
|
nvarchar StateProvinceName
|
||||||
|
char ISO3166_2 UK
|
||||||
|
uniqueidentifier CountryID FK
|
||||||
|
}
|
||||||
|
|
||||||
|
%% CityName + StateProvinceID form a composite unique constraint (AK_CityStateProvince)
|
||||||
|
City {
|
||||||
|
uniqueidentifier CityID PK
|
||||||
|
nvarchar CityName UK
|
||||||
|
uniqueidentifier StateProvinceID FK,UK
|
||||||
|
}
|
||||||
19
docs/web/diagrams/database-schema/media.mmd
Normal file
19
docs/web/diagrams/database-schema/media.mmd
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
erDiagram
|
||||||
|
User ||--o{ Photo : "uploads"
|
||||||
|
|
||||||
|
%% Cross-schema: defined in full in auth.mmd / social.mmd / brewery.mmd / beer.mmd
|
||||||
|
Avatar }o--|| Photo : "uses photo"
|
||||||
|
Photo ||--o{ BreweryPhoto : "used in"
|
||||||
|
Photo ||--o{ BeerPhoto : "used in"
|
||||||
|
|
||||||
|
Photo {
|
||||||
|
uniqueidentifier PhotoID PK
|
||||||
|
nvarchar Hyperlink "nullable"
|
||||||
|
uniqueidentifier UploadedByID FK
|
||||||
|
datetime UploadedAt
|
||||||
|
}
|
||||||
34
docs/web/diagrams/database-schema/social.mmd
Normal file
34
docs/web/diagrams/database-schema/social.mmd
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
erDiagram
|
||||||
|
User ||--o| Profile : "has profile"
|
||||||
|
User ||--o{ Follow : "follows"
|
||||||
|
User ||--o{ Follow : "followed by"
|
||||||
|
Profile ||--o{ Avatar : "has avatar history"
|
||||||
|
|
||||||
|
%% Cross-schema: defined in full in auth.mmd / media.mmd
|
||||||
|
Avatar }o--|| Photo : "uses photo"
|
||||||
|
|
||||||
|
Profile {
|
||||||
|
uniqueidentifier UserProfileID PK
|
||||||
|
uniqueidentifier UserAccountID "FK, UK"
|
||||||
|
nvarchar Biography
|
||||||
|
}
|
||||||
|
|
||||||
|
Follow {
|
||||||
|
uniqueidentifier UserFollowID PK
|
||||||
|
uniqueidentifier UserAccountID FK
|
||||||
|
uniqueidentifier FollowingID FK
|
||||||
|
datetime CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
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, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. |
|
||||||
|
| **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)
|
||||||
13
docs/web/diagrams/deployment/prod.mmd
Normal file
13
docs/web/diagrams/deployment/prod.mmd
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
sequenceDiagram
|
||||||
|
box Production Environment (docker-compose.prod.yaml)
|
||||||
|
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
|
||||||
|
participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
|
||||||
|
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||||
|
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
|
||||||
|
end
|
||||||
|
|
||||||
|
ProdAPI-->>ProdS3: depends_on: seaweedfs healthy
|
||||||
|
ProdWeb-->>ProdAPI: depends_on: api.core healthy
|
||||||
|
ProdAPI->>ProdDB: Connect and serve
|
||||||
|
ProdAPI->>ProdS3: Store and read photos
|
||||||
|
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack
|
||||||
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
|
||||||
105
docs/web/diagrams/vertical-slice.md
Normal file
105
docs/web/diagrams/vertical-slice.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 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 — every slice-to-slice
|
||||||
|
dependency is one now that the email MediatR contract has moved off
|
||||||
|
`Shared.Application`: `Features.Users` on `Features.PhotoUpload` for avatar
|
||||||
|
uploads, and `Features.Emails` on `Features.Users` for the registration and
|
||||||
|
resend-confirmation notifications. 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`)
|
||||||
|
- `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 exceptions to slice isolation**:
|
||||||
|
- `Features.Users` takes a direct project reference to `Features.PhotoUpload`
|
||||||
|
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
|
||||||
|
- the command type lives in the target slice itself, not in
|
||||||
|
`Shared.Application`
|
||||||
|
- `Features.Emails` takes a direct project reference to `Features.Users`
|
||||||
|
- handles `UserRegisteredNotification` (published from
|
||||||
|
`RegisterUserHandler`) and `ConfirmationEmailResendRequestedNotification`
|
||||||
|
(published from `ResendConfirmationEmailHandler`)
|
||||||
|
- both notification types live in `Features.Users`
|
||||||
|
(`Features.Users.Notifications`), not in `Shared.Application`
|
||||||
|
|
||||||
|
- **Shared.Application**:
|
||||||
|
- `ValidationBehavior` (MediatR pipeline)
|
||||||
|
- 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` and each feature slice)
|
||||||
|
- `Configuration` (the shared options-binding project
|
||||||
|
referenced by `Database.Connection`, `Features.PhotoUpload`,
|
||||||
|
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
|
||||||
29
docs/web/diagrams/vertical-slice/host-wiring.mmd
Normal file
29
docs/web/diagrams/vertical-slice/host-wiring.mmd
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
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
|
||||||
38
docs/web/diagrams/vertical-slice/project-references.mmd
Normal file
38
docs/web/diagrams/vertical-slice/project-references.mmd
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
|
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
|
||||||
|
PwdHash["Infrastructure.PasswordHashing"]
|
||||||
|
EmailTemplates["Infrastructure.Email.Templates"]
|
||||||
|
end
|
||||||
|
|
||||||
|
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
|
||||||
|
EmailsSlice == project ref, handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification ==> UsersSlice
|
||||||
|
BrewerySlice --> FOUNDATION
|
||||||
|
LocationsSlice --> FOUNDATION
|
||||||
|
BeersSlice --> FOUNDATION
|
||||||
|
PhotoSlice --> FOUNDATION
|
||||||
|
UsersSlice --> FOUNDATION & PwdHash
|
||||||
|
EmailsSlice --> EmailTemplates
|
||||||
|
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]
|
||||||
558
docs/web/docker.md
Normal file
558
docs/web/docker.md
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project uses Docker Compose to orchestrate multiple services:
|
||||||
|
|
||||||
|
- SQL Server 2022 database
|
||||||
|
- Database migrations runner (DbUp)
|
||||||
|
- Database seeder
|
||||||
|
- .NET API
|
||||||
|
- React Router website (SSR frontend)
|
||||||
|
- Test runners
|
||||||
|
|
||||||
|
See the [deployment diagram](diagrams/deployment.md) for visual representation.
|
||||||
|
|
||||||
|
## Docker Compose environments
|
||||||
|
|
||||||
|
### 1. Development (`docker-compose.dev.yaml`)
|
||||||
|
|
||||||
|
**Purpose**: Local development with persistent data
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Persistent SQL Server volume
|
||||||
|
- NuGet package cache volume (speeds up rebuilds)
|
||||||
|
- Swagger UI enabled
|
||||||
|
- Seed data included
|
||||||
|
- Local Mailpit SMTP server for dev email testing
|
||||||
|
- Local SeaweedFS S3-compatible object storage for photo uploads
|
||||||
|
- `CLEAR_DATABASE=true` (drops and recreates schema)
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # SQL Server 2022 (port 1434)
|
||||||
|
database.migrations # DbUp migrations
|
||||||
|
database.seed # Seed initial data
|
||||||
|
api.core # Web API (ports 8080, 8081)
|
||||||
|
frontend # React Router website (port 3000)
|
||||||
|
mailpit # Local dev SMTP server + UI (port 8025)
|
||||||
|
seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start Development Environment**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access**:
|
||||||
|
|
||||||
|
- Website: http://localhost:3000
|
||||||
|
- API Swagger: http://localhost:8080/swagger
|
||||||
|
- Health Check: http://localhost:8080/health
|
||||||
|
- Mailpit UI: http://localhost:8025
|
||||||
|
- SQL Server: localhost:1434 (sa credentials from .env.dev)
|
||||||
|
- SeaweedFS S3 API: http://localhost:8333 (credentials from
|
||||||
|
`SEAWEEDFS_ACCESS_KEY_ID`/`SEAWEEDFS_SECRET_ACCESS_KEY` in .env.dev)
|
||||||
|
- SeaweedFS Filer UI: http://localhost:8888
|
||||||
|
- SeaweedFS Master UI: http://localhost:9333
|
||||||
|
|
||||||
|
**Stop Environment**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop services (keep volumes)
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
|
||||||
|
|
||||||
|
# Stop and remove volumes (fresh start)
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Testing (`docker-compose.test.yaml`)
|
||||||
|
|
||||||
|
**Purpose**: Automated CI/CD testing in an isolated environment
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Fresh database each run
|
||||||
|
- All test suites execute in parallel
|
||||||
|
- Test results exported to `./test-results/`
|
||||||
|
- Containers auto-exit after completion
|
||||||
|
- Fully isolated testnet network
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # Test database
|
||||||
|
database.migrations # Fresh schema
|
||||||
|
database.seed # Test data
|
||||||
|
api.specs # Reqnroll BDD tests
|
||||||
|
unit.tests # All Features.*.Tests and Shared.*.Tests unit test projects
|
||||||
|
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run Tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
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/
|
||||||
|
cat test-results/api-specs/results.trx
|
||||||
|
cat test-results/Features.Users.Tests.trx
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
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
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Production logging levels
|
||||||
|
- No database clearing
|
||||||
|
- Optimized build configurations
|
||||||
|
- Health checks on every long-running service
|
||||||
|
- Restart policies that survive a reboot (see
|
||||||
|
[Restart tolerance](#restart-tolerance))
|
||||||
|
- Security hardening
|
||||||
|
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
|
||||||
|
proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
|
||||||
|
reachable only from other containers on `prodnet` (named `biergarten-prodnet`
|
||||||
|
so the seed job can join it from its own Compose project)
|
||||||
|
- Seeding is a separate, explicitly run compose file
|
||||||
|
(`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing
|
||||||
|
catalogue as it is. See [Production data](#production-data) below.
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # Production SQL Server (internal only)
|
||||||
|
api.core # Production API (internal only, no published ports)
|
||||||
|
seaweedfs # S3-compatible object storage (internal only)
|
||||||
|
frontend # Production website (127.0.0.1:5656, the only host binding)
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
|
||||||
|
restart touches neither the schema nor the catalogue. Run them explicitly at
|
||||||
|
deploy time: see [Migrating production](#migrating-production) and
|
||||||
|
[Seeding production](#seeding-production-docker-composeprodseedyaml).
|
||||||
|
|
||||||
|
**Deploy Production**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One time, before the first `up`: the data volumes are external, so Compose
|
||||||
|
# expects them to exist already.
|
||||||
|
docker volume create biergartendata-prod
|
||||||
|
docker volume create seaweedfsdata-prod
|
||||||
|
|
||||||
|
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker seeds each volume with the ownership the image expects and labels it for
|
||||||
|
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
|
||||||
|
enforcing distributions such as RHEL, AlmaLinux and Fedora.
|
||||||
|
|
||||||
|
#### Migrating production
|
||||||
|
|
||||||
|
`database.migrations` is a run-once job rather than a service in the prod stack,
|
||||||
|
so the schema is migrated when an operator asks for it instead of on every boot.
|
||||||
|
Build the image and run it against the network the prod stack creates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t database.migrations \
|
||||||
|
-f web/backend/Database/Database.Migrations/Dockerfile \
|
||||||
|
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
|
||||||
|
web/backend
|
||||||
|
|
||||||
|
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
|
||||||
|
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
DbUp records every script it has applied, so a re-run applies only what is new.
|
||||||
|
Run this with `sqlserver` already up and healthy, and before the seed job.
|
||||||
|
|
||||||
|
#### Restart tolerance
|
||||||
|
|
||||||
|
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
|
||||||
|
`restart: unless-stopped`, so the stack returns on its own after a container
|
||||||
|
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
|
||||||
|
boot (`systemctl enable docker`). The migration and seed jobs stay out of the
|
||||||
|
stack, so a reboot replays neither.
|
||||||
|
|
||||||
|
The daemon replays restart policies without replaying the `depends_on` graph, so
|
||||||
|
after a reboot the containers come back in arbitrary order and each one tolerates
|
||||||
|
a dependency that arrives late. `api.core` opens its database and S3 connections
|
||||||
|
per request rather than at startup, and `frontend` answers with its own error
|
||||||
|
states while `api.core` is still coming up.
|
||||||
|
|
||||||
|
Health checks report when the stack has converged:
|
||||||
|
|
||||||
|
| Service | Probe |
|
||||||
|
| --- | --- |
|
||||||
|
| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries |
|
||||||
|
| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client |
|
||||||
|
| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up |
|
||||||
|
| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API |
|
||||||
|
|
||||||
|
`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to
|
||||||
|
finish on shutdown instead of being recovered on the next start.
|
||||||
|
|
||||||
|
Deploy and wait for the stack to report healthy, or check convergence after a
|
||||||
|
reboot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
|
||||||
|
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Seeding production (`docker-compose.prod.seed.yaml`)
|
||||||
|
|
||||||
|
`database.seed` lives in its own compose file, run on demand once the prod stack
|
||||||
|
is up, `sqlserver` is healthy, and the schema is migrated:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
|
||||||
|
run --rm --build database.seed
|
||||||
|
```
|
||||||
|
|
||||||
|
The job runs as the `biergarten-prod-seed` Compose project and attaches to the
|
||||||
|
external `biergarten-prodnet` network, which the main prod stack creates, so it
|
||||||
|
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
|
||||||
|
exits when seeding finishes; `--rm` removes the container. Bring the prod stack
|
||||||
|
up first, because the seed project has no visibility into the other project's
|
||||||
|
health checks, so a run started before SQL Server is accepting connections fails.
|
||||||
|
|
||||||
|
#### Production data
|
||||||
|
|
||||||
|
A production deployment seeded this way serves the AI-generated fixture data
|
||||||
|
produced by the pipeline. Per
|
||||||
|
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
|
||||||
|
anyone interacting with an application seeded from it has to be told upfront that
|
||||||
|
the content is AI-generated.
|
||||||
|
|
||||||
|
The website meets that obligation with `AiDisclosureDialog`
|
||||||
|
(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which
|
||||||
|
the root route renders on every full page load, whichever route the visitor lands
|
||||||
|
on. It is part of the server-rendered HTML rather than mounted after hydration, so
|
||||||
|
the disclosure is on screen with the first paint. Acknowledging it hides the
|
||||||
|
dialog for the remainder of that page load; a new load shows it again.
|
||||||
|
|
||||||
|
Keep the seed job and the dialog together: any deployment that runs
|
||||||
|
`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
|
||||||
|
ethics documentation.
|
||||||
|
|
||||||
|
### 4. Database only (`docker-compose.db.yaml`)
|
||||||
|
|
||||||
|
**Purpose**: Run just the database for local backend development outside Docker
|
||||||
|
(for example, `dotnet run` against a containerized SQL Server)
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # SQL Server 2022 (port 1434)
|
||||||
|
database.migrations # DbUp migrations
|
||||||
|
database.seed # Seed initial data
|
||||||
|
```
|
||||||
|
|
||||||
|
No `api.core` or `mailpit` container. Uses `.env.dev`, same as development.
|
||||||
|
|
||||||
|
**Start**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.db.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Minimal (`docker-compose.min.yaml`)
|
||||||
|
|
||||||
|
**Purpose**: Local dev where the API runs outside Docker but the database and
|
||||||
|
mail capture still run in containers
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # SQL Server 2022 (port 1433)
|
||||||
|
database.migrations # DbUp migrations
|
||||||
|
database.seed # Seed initial data
|
||||||
|
mailpit # Local dev SMTP server + UI (port 8025)
|
||||||
|
```
|
||||||
|
|
||||||
|
No `api.core` container. Uses `.env.local` instead of `.env.dev`, and exposes
|
||||||
|
SQL Server on the standard port 1433 (`docker-compose.db.yaml` and
|
||||||
|
`docker-compose.dev.yaml` both use 1434).
|
||||||
|
|
||||||
|
**Start**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.local -f web/docker-compose.min.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service dependencies
|
||||||
|
|
||||||
|
Docker Compose manages startup order using health checks:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
|
||||||
|
B --> C[database.seed: completes successfully]
|
||||||
|
C --> D[api.core / tests: start when ready]
|
||||||
|
E[seaweedfs: container started] --> D
|
||||||
|
```
|
||||||
|
|
||||||
|
In development, `api.core` also depends on `seaweedfs`
|
||||||
|
(`condition: service_started`, not a health check) alongside `database.seed`.
|
||||||
|
|
||||||
|
Production has a shorter chain, because the migration and seed jobs run outside
|
||||||
|
the stack: `api.core` waits for `seaweedfs` to pass its health check, and
|
||||||
|
`frontend` waits for `api.core` to report healthy. This ordering applies to
|
||||||
|
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens
|
||||||
|
on a host reboot.
|
||||||
|
|
||||||
|
**Health Check Example** (SQL Server):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency Configuration**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
api.core:
|
||||||
|
depends_on:
|
||||||
|
database.seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
### Persistent volumes
|
||||||
|
|
||||||
|
**Development**:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
|
||||||
|
- `sqlserverdata-test` - Temporary, typically removed after tests
|
||||||
|
|
||||||
|
**Production**:
|
||||||
|
|
||||||
|
- `biergartendata-prod` - Database files, external; survives `down -v`
|
||||||
|
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
|
||||||
|
|
||||||
|
See [Production data volumes](#production-data-volumes).
|
||||||
|
|
||||||
|
### Mounted volumes
|
||||||
|
|
||||||
|
**Test Results**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./test-results:/app/test-results
|
||||||
|
```
|
||||||
|
|
||||||
|
Test results are written to host filesystem for CI/CD integration.
|
||||||
|
|
||||||
|
### Production data volumes
|
||||||
|
|
||||||
|
`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`.
|
||||||
|
Compose removes only the volumes it owns, so `docker compose down -v` tears the
|
||||||
|
stack down and leaves the database and the uploaded photos in place. Deleting
|
||||||
|
them takes an explicit `docker volume rm`.
|
||||||
|
|
||||||
|
The trade-off is that Compose creates neither one, and `up` fails with a clear
|
||||||
|
error until both exist, hence the `docker volume create` step in
|
||||||
|
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
|
||||||
|
carry their literal name rather than a `<project>_` prefix, so `docker volume ls`
|
||||||
|
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the
|
||||||
|
`web_`-prefixed volumes of the dev and test stacks.
|
||||||
|
|
||||||
|
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
|
||||||
|
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
|
||||||
|
`/var/lib/docker`.
|
||||||
|
|
||||||
|
Back up the database through SQL Server rather than by copying its files, since a
|
||||||
|
file copy taken while the engine is running can be torn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P '<db-password>' -C \
|
||||||
|
-Q "BACKUP DATABASE [<db-name>] TO DISK = '/var/opt/mssql/data/backup.bak'"
|
||||||
|
docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak .
|
||||||
|
```
|
||||||
|
|
||||||
|
Object storage can be archived directly, with the stack stopped:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \
|
||||||
|
alpine tar czf /backup/seaweedfs.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Networks
|
||||||
|
|
||||||
|
Each environment uses isolated bridge networks:
|
||||||
|
|
||||||
|
- `devnet` - Development network
|
||||||
|
- `testnet` - Testing network (fully isolated)
|
||||||
|
- `prodnet` - Production network
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
All containers are configured via environment variables from `.env` files:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env_file: ".env.dev" # or .env.test, .env.prod
|
||||||
|
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: "Development"
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||||
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
```
|
||||||
|
|
||||||
|
For complete list, see [Environment Variables](environment-variables.md).
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
### View services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Running services
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
|
||||||
|
|
||||||
|
# All containers (including stopped)
|
||||||
|
docker ps -a
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f api.core
|
||||||
|
|
||||||
|
# Last 100 lines
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execute commands in container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive shell
|
||||||
|
docker exec -it dev-env-api-core bash
|
||||||
|
|
||||||
|
# Run command (replace <db-password> with the value of DB_PASSWORD from .env.dev)
|
||||||
|
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<db-password>' -C
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restart all services
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart
|
||||||
|
|
||||||
|
# Restart specific service
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart api.core
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build all images
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build
|
||||||
|
|
||||||
|
# Build specific service
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.core
|
||||||
|
|
||||||
|
# Build without cache
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and remove containers
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
|
||||||
|
|
||||||
|
# Remove containers and volumes
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
||||||
|
|
||||||
|
# Remove containers, volumes, and images
|
||||||
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --rmi all
|
||||||
|
|
||||||
|
# System-wide cleanup
|
||||||
|
docker system prune -af --volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dockerfile structure
|
||||||
|
|
||||||
|
### Multi-stage build
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["Project/Project.csproj", "Project/"]
|
||||||
|
RUN dotnet restore
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet build -c Release
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/build .
|
||||||
|
ENTRYPOINT ["dotnet", "Project.dll"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional resources
|
||||||
|
|
||||||
|
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||||
|
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
|
||||||
|
- [SQL Server Docker Images](https://hub.docker.com/_/microsoft-mssql-server)
|
||||||
@@ -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
|
This document covers the environment variables used by the Biergarten stack.
|
||||||
Biergarten stack.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -19,8 +26,10 @@ Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
|||||||
|
|
||||||
### Frontend (`web/frontend`)
|
### Frontend (`web/frontend`)
|
||||||
|
|
||||||
The active website reads runtime values from the server environment for its auth
|
The website reads runtime values from the server environment for its auth and
|
||||||
and API integration.
|
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` reads from the shell environment.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -34,9 +43,11 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
|||||||
|
|
||||||
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
|
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
|
||||||
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
|
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
|
||||||
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`,
|
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`,
|
||||||
`SMTP_PASSWORD`) with freshly randomized ones via `openssl`. Non-secret values
|
`SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`,
|
||||||
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
|
```bash
|
||||||
cd web
|
cd web
|
||||||
@@ -100,7 +111,8 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
|||||||
**Security Requirements**:
|
**Security Requirements**:
|
||||||
|
|
||||||
- Use at least 32 characters per secret
|
- 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
|
- Generate secrets with a cryptographically secure random function
|
||||||
- Never reuse secrets across token types or environments
|
- Never reuse secrets across token types or environments
|
||||||
- Rotate secrets periodically in production
|
- Rotate secrets periodically in production
|
||||||
@@ -166,25 +178,39 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
|||||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
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`)
|
## Frontend variables (`web/frontend`)
|
||||||
|
|
||||||
The active website does not use the old Next.js/Prisma environment model. Its
|
The website's core runtime variables are:
|
||||||
core runtime variables are:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
|
API_BASE_URL=http://api.core:8080 # Base URL for the .NET API (container DNS name in Docker)
|
||||||
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||||
NODE_ENV=development # Standard Node runtime mode
|
NODE_ENV=development # Standard Node runtime mode
|
||||||
|
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).
|
||||||
|
|
||||||
### Frontend variable details
|
### Frontend variable details
|
||||||
|
|
||||||
#### `API_BASE_URL`
|
#### `API_BASE_URL`
|
||||||
|
|
||||||
- **Required**: Yes for local development
|
- **Required**: Yes for local development
|
||||||
- **Default in code**: `http://localhost:8080`
|
- **Default in code**: `http://localhost:8080`
|
||||||
- **Used by**: `web/frontend/app/lib/auth.server.ts`
|
- **Used by**: `web/frontend/app/features/auth/auth.server.ts`,
|
||||||
- **Purpose**: Routes website auth actions to the .NET API
|
`web/frontend/app/features/breweries/breweries.server.ts`
|
||||||
|
- **Purpose**: Routes website auth and API calls to the .NET API
|
||||||
|
|
||||||
#### `SESSION_SECRET`
|
#### `SESSION_SECRET`
|
||||||
|
|
||||||
@@ -199,9 +225,15 @@ NODE_ENV=development # Standard Node runtime mode
|
|||||||
- **Typical values**: `development`, `production`, `test`
|
- **Typical values**: `development`, `production`, `test`
|
||||||
- **Purpose**: Controls secure cookie behavior and runtime mode
|
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||||
|
|
||||||
|
#### `PORT`
|
||||||
|
|
||||||
|
- **Required**: No
|
||||||
|
- **Default**: `3000`
|
||||||
|
- **Purpose**: Port the built server (`build/server/index.js`) binds to
|
||||||
|
|
||||||
### SMTP configuration (backend)
|
### SMTP configuration (backend)
|
||||||
|
|
||||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
|
Read by `Features.Emails/Services/SmtpEmailProvider.cs` for sending confirmation and
|
||||||
account emails.
|
account emails.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -214,13 +246,40 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
|
|||||||
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
|
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on
|
- **Implementation**: `Features.Emails/Services/SmtpEmailProvider.cs` throws on
|
||||||
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
|
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
|
||||||
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
|
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
|
||||||
UI on http://localhost:8025)
|
UI on http://localhost:8025)
|
||||||
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
|
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
|
||||||
so on)
|
so on)
|
||||||
|
|
||||||
|
### File storage configuration (backend)
|
||||||
|
|
||||||
|
Read by `Features.PhotoUpload/Services/S3FileStorageProvider.cs` for uploading
|
||||||
|
and retrieving photos via an S3-compatible object store.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
|
||||||
|
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
|
||||||
|
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
|
||||||
|
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
|
||||||
|
SEAWEEDFS_BUCKET=my-bucket # Required, no default
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Implementation**: `S3FileStorageProvider` throws on construction if any of
|
||||||
|
the four required values is missing
|
||||||
|
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
|
||||||
|
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
|
||||||
|
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
|
||||||
|
SeaweedFS requires and AWS S3 also accepts
|
||||||
|
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
|
||||||
|
used by `GetPresignedUrl` and reaching the API response as
|
||||||
|
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
|
||||||
|
this has to be the address the browser uses (`http://localhost:8333` with the
|
||||||
|
dev compose file, which publishes the S3 API port), separate from the
|
||||||
|
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
|
||||||
|
same value wherever one address serves both
|
||||||
|
|
||||||
## Docker-specific variables
|
## Docker-specific variables
|
||||||
|
|
||||||
### SQL Server container
|
### SQL Server container
|
||||||
@@ -251,21 +310,22 @@ web/.env.prod # Production config (gitignored)
|
|||||||
**Setup**:
|
**Setup**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp web/.env.example web/.env.dev
|
cd web
|
||||||
# Edit web/.env.dev with your values
|
./generate-env.sh .env.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Legacy frontend variables
|
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
|
||||||
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
|
`.env.dev`/`.env.test`/`.env.prod` file. A plain
|
||||||
been removed from this active reference, since that app is retained for
|
`cp web/.env.example web/.env.dev` works too, but leaves the placeholder secrets
|
||||||
reference only and is not run as part of the active stack.
|
from the template in place; edit them manually if you go that route.
|
||||||
|
|
||||||
**Docker Compose Mapping**:
|
**Docker Compose Mapping**:
|
||||||
|
|
||||||
- `web/docker-compose.dev.yaml` → `web/.env.dev`
|
- `web/docker-compose.dev.yaml` → `web/.env.dev`
|
||||||
- `web/docker-compose.test.yaml` → `web/.env.test`
|
- `web/docker-compose.test.yaml` → `web/.env.test`
|
||||||
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
||||||
|
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
|
||||||
|
|
||||||
## Variable reference table
|
## Variable reference table
|
||||||
|
|
||||||
@@ -288,12 +348,18 @@ reference only and is not run as part of the active stack.
|
|||||||
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
|
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
|
||||||
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
||||||
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
||||||
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
|
||||||
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
|
||||||
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
|
||||||
|
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
|
||||||
|
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
|
||||||
|
| `API_BASE_URL` | | ✓ | ✓ | Yes | Website-to-API base URL |
|
||||||
|
| `SESSION_SECRET` | | ✓ | ✓ | Yes | Website session signing |
|
||||||
|
| `NODE_ENV` | | ✓ | ✓ | No | Runtime mode |
|
||||||
|
| `PORT` | | ✓ | ✓ | No | Frontend server port |
|
||||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes\*\* | API binding address |
|
||||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||||
@@ -302,6 +368,10 @@ reference only and is not run as part of the active stack.
|
|||||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
|
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
|
||||||
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
|
`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
|
## Validation
|
||||||
|
|
||||||
### Backend validation
|
### Backend validation
|
||||||
@@ -309,15 +379,17 @@ reference only and is not run as part of the active stack.
|
|||||||
Variables are validated at startup:
|
Variables are validated at startup:
|
||||||
|
|
||||||
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
|
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
|
||||||
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
|
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`,
|
||||||
values) cause the application to fail with an `InvalidOperationException`
|
`SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`,
|
||||||
|
`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
|
- No minimum length is enforced on the token secrets in code; the "minimum 32
|
||||||
characters" guidance above is a recommendation, not an enforced check
|
characters" guidance above is a recommendation, not an enforced check
|
||||||
|
|
||||||
### Frontend validation
|
### Frontend validation
|
||||||
|
|
||||||
The active website relies on runtime defaults for local development and the
|
The website relies on runtime defaults for local development and on the server
|
||||||
surrounding server environment in deployed environments.
|
environment when deployed.
|
||||||
|
|
||||||
- `API_BASE_URL` defaults to `http://localhost:8080`
|
- `API_BASE_URL` defaults to `http://localhost:8080`
|
||||||
- `SESSION_SECRET` falls back to a development-only local secret
|
- `SESSION_SECRET` falls back to a development-only local secret
|
||||||
@@ -340,6 +412,9 @@ REFRESH_TOKEN_SECRET=<generated-with-openssl>
|
|||||||
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||||
WEBSITE_BASE_URL=http://localhost:3000
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Frontend session signing
|
||||||
|
SESSION_SECRET=<generated-with-openssl>
|
||||||
|
|
||||||
# SMTP (Mailpit in dev)
|
# SMTP (Mailpit in dev)
|
||||||
SMTP_HOST=mailpit
|
SMTP_HOST=mailpit
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
@@ -347,6 +422,13 @@ SMTP_USE_SSL=false
|
|||||||
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
||||||
SMTP_FROM_NAME=The Biergarten App
|
SMTP_FROM_NAME=The Biergarten App
|
||||||
|
|
||||||
|
# SeaweedFS (S3-compatible storage)
|
||||||
|
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||||
|
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
||||||
|
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
|
||||||
|
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
|
||||||
|
SEAWEEDFS_BUCKET=my-bucket
|
||||||
|
|
||||||
# Migration
|
# Migration
|
||||||
CLEAR_DATABASE=true
|
CLEAR_DATABASE=true
|
||||||
|
|
||||||
@@ -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
|
This guide covers local setup for the Biergarten stack: the .NET backend in
|
||||||
in `web/backend` and the active React Router frontend in `web/frontend`.
|
`web/backend` and the React Router frontend in `web/frontend`.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **.NET SDK 10+**
|
- **.NET SDK 10+**
|
||||||
- **Node.js 18+**
|
- **Node.js 22+**
|
||||||
- **Docker Desktop** or equivalent Docker Engine setup
|
- **Docker Desktop** or equivalent Docker Engine setup
|
||||||
- **Java 8+** if you want to regenerate PlantUML diagrams
|
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||||
|
|
||||||
@@ -58,16 +65,19 @@ section for details on `generate-env.sh`.
|
|||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev
|
This starts SQL Server, migrations, seeding, the API, Mailpit (local dev SMTP),
|
||||||
SMTP).
|
and SeaweedFS (local dev S3-compatible object storage for photo uploads).
|
||||||
|
|
||||||
Available endpoints:
|
Available endpoints:
|
||||||
|
|
||||||
- API Swagger: http://localhost:8080/swagger
|
| Service | URL |
|
||||||
- Health Check: http://localhost:8080/health
|
| ---------------- | ----------------------------- |
|
||||||
- Mailpit UI: http://localhost:8025
|
| 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
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
@@ -124,6 +134,10 @@ export ACCESS_TOKEN_SECRET="<generated>"
|
|||||||
export REFRESH_TOKEN_SECRET="<generated>"
|
export REFRESH_TOKEN_SECRET="<generated>"
|
||||||
export CONFIRMATION_TOKEN_SECRET="<generated>"
|
export CONFIRMATION_TOKEN_SECRET="<generated>"
|
||||||
export WEBSITE_BASE_URL="http://localhost:3000"
|
export WEBSITE_BASE_URL="http://localhost:3000"
|
||||||
|
export SEAWEEDFS_SERVICE_URL="http://localhost:8333"
|
||||||
|
export SEAWEEDFS_ACCESS_KEY_ID="<generated>"
|
||||||
|
export SEAWEEDFS_SECRET_ACCESS_KEY="<generated>"
|
||||||
|
export SEAWEEDFS_BUCKET="my-bucket"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Run migrations and seed
|
### 2. Run migrations and seed
|
||||||
@@ -140,13 +154,13 @@ dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
|||||||
dotnet run --project API/API.Core/API.Core.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
|
`archive/next-js-web-app/` contains an archived Next.js frontend, kept for
|
||||||
only and is not the active website.
|
reference; it is not part of the running stack.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
- Review [Architecture](../architecture.md)
|
- Review [Architecture](architecture.md)
|
||||||
- Run backend and frontend checks from [Testing](testing.md)
|
- Run backend and frontend checks from [Testing](testing.md)
|
||||||
- Use [Docker Guide](docker.md) for container troubleshooting
|
- Use [Docker Guide](docker.md) for container troubleshooting
|
||||||
488
docs/web/testing.md
Normal file
488
docs/web/testing.md
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project uses a multi-layered testing approach across backend and frontend:
|
||||||
|
|
||||||
|
| 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests
|
||||||
|
```
|
||||||
|
|
||||||
|
This command:
|
||||||
|
|
||||||
|
1. Starts a fresh SQL Server instance
|
||||||
|
2. Runs database migrations
|
||||||
|
3. Seeds test data
|
||||||
|
4. Executes all test suites in parallel, including the frontend's Storybook
|
||||||
|
Vitest and Playwright suites (`frontend.tests`, which needs no database)
|
||||||
|
5. Exports results to `./test-results/`
|
||||||
|
6. Returns once all three test containers have exited
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
### View test results
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List test result files
|
||||||
|
ls -la test-results/
|
||||||
|
|
||||||
|
# View specific test results
|
||||||
|
cat test-results/api-specs/results.trx
|
||||||
|
cat test-results/Features.Users.Tests.trx
|
||||||
|
cat test-results/Features.Breweries.Tests.trx
|
||||||
|
cat test-results/Features.Emails.Tests.trx
|
||||||
|
cat test-results/Features.PhotoUpload.Tests.trx
|
||||||
|
cat test-results/frontend/vitest-storybook.xml
|
||||||
|
cat test-results/frontend/playwright-storybook.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove test containers and volumes
|
||||||
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running tests locally
|
||||||
|
|
||||||
|
You can run individual test projects locally without Docker:
|
||||||
|
|
||||||
|
### Feature slice and shared unit tests
|
||||||
|
|
||||||
|
Each feature slice has its own test project, covering its command/query
|
||||||
|
handlers. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has
|
||||||
|
its own test project under `Shared/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/backend
|
||||||
|
dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
|
||||||
|
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
|
||||||
|
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
|
||||||
|
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
|
||||||
|
dotnet test Shared/Shared.Application.Tests/Shared.Application.Tests.csproj
|
||||||
|
|
||||||
|
# Or run all of them at once via the solution:
|
||||||
|
for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- No database required (handlers are tested with their repository/service
|
||||||
|
dependencies mocked via Moq)
|
||||||
|
|
||||||
|
### Frontend Storybook tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/frontend
|
||||||
|
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,
|
||||||
|
toasts, and the theme gallery
|
||||||
|
- Runs in browser mode via Vitest and Storybook integration
|
||||||
|
|
||||||
|
### Frontend Playwright Storybook tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/frontend
|
||||||
|
npm install
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- Storybook dependencies installed
|
||||||
|
- Playwright browser dependencies installed
|
||||||
|
- The command will start or reuse the Storybook server defined in
|
||||||
|
`playwright.storybook.config.ts`
|
||||||
|
|
||||||
|
### Running Storybook tests with a visible browser
|
||||||
|
|
||||||
|
Both of the above run headless by default. To watch the browser while debugging
|
||||||
|
a failure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Playwright Storybook suite
|
||||||
|
npx playwright test -c playwright.storybook.config.ts --headed
|
||||||
|
# or the interactive step-through runner
|
||||||
|
npx playwright test -c playwright.storybook.config.ts --ui
|
||||||
|
|
||||||
|
# Vitest/story play-function suite
|
||||||
|
npx vitest run --project storybook --browser.headless=false
|
||||||
|
# or Vitest's own UI (drop `run` for watch mode)
|
||||||
|
npx vitest --project storybook --browser.headless=false --ui
|
||||||
|
```
|
||||||
|
|
||||||
|
The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
||||||
|
|
||||||
|
## Test coverage
|
||||||
|
|
||||||
|
### Current coverage
|
||||||
|
|
||||||
|
**Features.Users.Tests**:
|
||||||
|
|
||||||
|
- User registration with validation
|
||||||
|
- User login with JWT token generation
|
||||||
|
- Password hashing and verification (Argon2id)
|
||||||
|
- Email confirmation and confirmation-email resend
|
||||||
|
- Refresh token exchange
|
||||||
|
- JWT token generation, validation, and claims handling
|
||||||
|
- Invalid credentials and 404 error responses (via API.Specs)
|
||||||
|
- Account management: username, email, password, and profile updates, and
|
||||||
|
account deletion (blocked while dependent posts, comments, photos, or follows
|
||||||
|
exist)
|
||||||
|
- User get-by-id/get-all queries
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Features.Emails.Tests**:
|
||||||
|
|
||||||
|
- Registration and resend-confirmation email dispatch handlers
|
||||||
|
|
||||||
|
**Features.PhotoUpload.Tests**:
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
**Frontend UI Coverage**:
|
||||||
|
|
||||||
|
- Shared submit button states
|
||||||
|
- Form field happy path and error presentation
|
||||||
|
- Navbar guest, authenticated, and mobile behavior
|
||||||
|
- Theme gallery rendering across Biergarten themes
|
||||||
|
- Toast interactions and themed notification display
|
||||||
|
|
||||||
|
### Planned coverage
|
||||||
|
|
||||||
|
- [ ] Password reset functionality
|
||||||
|
- [ ] Beer post operations
|
||||||
|
- [ ] User follow/unfollow
|
||||||
|
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||||
|
|
||||||
|
## Testing frameworks and tools
|
||||||
|
|
||||||
|
### xUnit
|
||||||
|
|
||||||
|
- Primary unit testing framework
|
||||||
|
- Used for handler and service layer tests
|
||||||
|
- Supports parallel test execution
|
||||||
|
|
||||||
|
### Reqnroll (Gherkin/BDD)
|
||||||
|
|
||||||
|
- Behavior-driven development framework
|
||||||
|
- Used for API integration tests
|
||||||
|
- Human-readable test scenarios in `.feature` files
|
||||||
|
|
||||||
|
### FluentAssertions
|
||||||
|
|
||||||
|
- Expressive assertion library
|
||||||
|
- Makes test assertions more readable
|
||||||
|
- Used across all test projects
|
||||||
|
|
||||||
|
### Moq
|
||||||
|
|
||||||
|
- Mocking framework for .NET
|
||||||
|
- Used in Service layer tests
|
||||||
|
- Enables isolated unit testing
|
||||||
|
|
||||||
|
## Test structure
|
||||||
|
|
||||||
|
### API.Specs (integration tests)
|
||||||
|
|
||||||
|
```
|
||||||
|
API.Specs/
|
||||||
|
├── Features/
|
||||||
|
│ ├── Registration.feature # Registration scenarios
|
||||||
|
│ ├── Login.feature # Login scenarios
|
||||||
|
│ ├── Confirmation.feature # Email confirmation scenarios
|
||||||
|
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
|
||||||
|
│ ├── TokenRefresh.feature # Refresh token scenarios
|
||||||
|
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
|
||||||
|
│ ├── NotFound.feature # 404 handling
|
||||||
|
│ └── PublicUserProfile.feature # Public profile & authenticated account scenarios
|
||||||
|
├── Steps/
|
||||||
|
│ ├── AuthSteps.cs # Step definitions for the Auth features
|
||||||
|
│ ├── UserSteps.cs # Step definitions for the public profile & user-account features
|
||||||
|
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
||||||
|
├── Mocks/
|
||||||
|
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
||||||
|
│ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
|
||||||
|
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
|
||||||
|
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Feature**:
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Feature: User Registration
|
||||||
|
As a user
|
||||||
|
I want to register
|
||||||
|
So that I can access the platform
|
||||||
|
|
||||||
|
Scenario: Successful user registration
|
||||||
|
Given I have valid registration details
|
||||||
|
When I register a new account
|
||||||
|
Then I should receive a JWT token
|
||||||
|
And my account should be created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Features.Users.Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
Features.Users.Tests/
|
||||||
|
├── Commands/
|
||||||
|
│ ├── RegisterUserHandlerTests.cs
|
||||||
|
│ ├── RegisterUserValidatorTests.cs
|
||||||
|
│ ├── ConfirmUserHandlerTests.cs
|
||||||
|
│ ├── ResendConfirmationEmailHandlerTests.cs
|
||||||
|
│ ├── RefreshTokenHandlerTests.cs
|
||||||
|
│ ├── RefreshTokenValidatorTests.cs
|
||||||
|
│ ├── LoginValidatorTests.cs # tests LoginCommand's validator
|
||||||
|
│ ├── UpdateUsernameHandlerTests.cs
|
||||||
|
│ ├── UpdateUsernameValidatorTests.cs
|
||||||
|
│ ├── UpdateEmailHandlerTests.cs
|
||||||
|
│ ├── UpdateEmailValidatorTests.cs
|
||||||
|
│ ├── UpdatePasswordHandlerTests.cs
|
||||||
|
│ ├── UpdatePasswordValidatorTests.cs
|
||||||
|
│ ├── UpdateProfileHandlerTests.cs
|
||||||
|
│ ├── UpdateProfileValidatorTests.cs
|
||||||
|
│ └── DeleteAccountHandlerTests.cs
|
||||||
|
├── Queries/
|
||||||
|
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
|
||||||
|
│ ├── GetAllUsersHandlerTests.cs
|
||||||
|
│ ├── GetPublicUserProfileByIdHandlerTests.cs
|
||||||
|
│ └── GetUserByIdHandlerTests.cs
|
||||||
|
├── Services/
|
||||||
|
│ ├── TokenServiceRefreshTests.cs
|
||||||
|
│ └── TokenServiceValidationTests.cs
|
||||||
|
└── TestSupport/ # shared test fixtures/helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
`Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same
|
||||||
|
shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a
|
||||||
|
`*ValidatorTests.cs` file beside each handler whose command or query has a
|
||||||
|
FluentValidation validator (`Features.Breweries.Tests` has one of these in
|
||||||
|
`Queries/` as well, for the nearby-search query). `Features.Emails.Tests`
|
||||||
|
differs: its handlers respond to MediatR notifications, so its test files live
|
||||||
|
under `Notifications/`.
|
||||||
|
|
||||||
|
## Writing tests
|
||||||
|
|
||||||
|
### Unit test example (xUnit)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class LoginHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var authRepoMock = new Mock<IAuthRepository>();
|
||||||
|
var passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||||
|
var tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
|
||||||
|
// ...set up mocks for a known user/credential...
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.AccessToken.Should().NotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration test example (Reqnroll)
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: User login with valid credentials
|
||||||
|
Given a registered user with username "testuser"
|
||||||
|
When I POST to "/api/auth/login" with valid credentials
|
||||||
|
Then the response status should be 200
|
||||||
|
And the response should contain a JWT token
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CI/CD command
|
||||||
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
- `0` - All tests passed
|
||||||
|
- 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/frontend
|
||||||
|
npm ci
|
||||||
|
npm run test:storybook
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Install** (macOS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install act
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run the workflow** from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
act push \
|
||||||
|
-j containerized-tests \
|
||||||
|
-W .github/workflows/tests.yml \
|
||||||
|
-P ubuntu-latest=catthehacker/ubuntu:act-latest \
|
||||||
|
--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.
|
||||||
|
- `--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
|
||||||
|
`.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
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests failing due to database connection
|
||||||
|
|
||||||
|
Ensure SQL Server is running and environment variables are set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port conflicts
|
||||||
|
|
||||||
|
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
||||||
|
`docker-compose.test.yaml`.
|
||||||
|
|
||||||
|
### Stale test data
|
||||||
|
|
||||||
|
Clean up test database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### View container logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
1. **Isolation**: Each test should be independent and not rely on other tests
|
||||||
|
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
||||||
|
3. **Mocking**: Mock external dependencies in unit tests
|
||||||
|
4. **Descriptive Names**: Use clear, descriptive test method names
|
||||||
|
5. **Arrange-Act-Assert**: Follow AAA pattern in unit tests
|
||||||
|
6. **Given-When-Then**: Follow GWT pattern in BDD scenarios
|
||||||
293
docs/web/token-validation.md
Normal file
293
docs/web/token-validation.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
---
|
||||||
|
title: JWT token validation architecture
|
||||||
|
last-updated: 2026-08-31
|
||||||
|
tags:
|
||||||
|
- jwt
|
||||||
|
- authentication
|
||||||
|
- security
|
||||||
|
- tokens
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
### Infrastructure layer
|
||||||
|
|
||||||
|
#### [ITokenInfrastructure](../../web/backend/Features/Features.Users/Services/ITokenInfrastructure.cs)
|
||||||
|
|
||||||
|
Low-level JWT operations.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
|
||||||
|
- `GenerateJwt()` - Creates signed JWT tokens
|
||||||
|
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
[JwtInfrastructure.cs](../../web/backend/Features/Features.Users/Services/JwtInfrastructure.cs)
|
||||||
|
|
||||||
|
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||||
|
- Algorithm: HS256 (HMAC-SHA256)
|
||||||
|
- Validates token lifetime, signature, and well-formedness
|
||||||
|
|
||||||
|
### Features.Users slice
|
||||||
|
|
||||||
|
#### [ITokenService](../../web/backend/Features/Features.Users/Services/ITokenService.cs)
|
||||||
|
|
||||||
|
Token generation and validation live on the same slice-internal service.
|
||||||
|
|
||||||
|
**Generation methods:**
|
||||||
|
|
||||||
|
- `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:**
|
||||||
|
|
||||||
|
- `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)
|
||||||
|
- `Username` (string)
|
||||||
|
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
[TokenService.cs](../../web/backend/Features/Features.Users/Services/TokenService.cs)
|
||||||
|
|
||||||
|
- Reads token secrets from environment variables
|
||||||
|
- Extracts and validates claims (Sub, UniqueName)
|
||||||
|
- Throws `UnauthorizedException` on validation failure
|
||||||
|
|
||||||
|
`TokenService` and the lower-level `ITokenInfrastructure` (JWT
|
||||||
|
signing/verification) it depends on are both registered by `Features.Users`'
|
||||||
|
own `AddFeaturesUsers()` extension method. `JwtAuthenticationHandler`
|
||||||
|
(host-level auth middleware) also depends on `ITokenInfrastructure` directly,
|
||||||
|
resolving it from the same DI container.
|
||||||
|
|
||||||
|
### Integration points
|
||||||
|
|
||||||
|
#### [ConfirmUserHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserHandler.cs)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
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()`, 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:**
|
||||||
|
|
||||||
|
1. Receives a user ID via `ResendConfirmationEmailCommand`
|
||||||
|
2. Looks up the user and checks they aren't already verified
|
||||||
|
3. Generates a fresh confirmation token via `ITokenService`
|
||||||
|
4. Sends `SendResendConfirmationEmailCommand` over MediatR, handled by the
|
||||||
|
`Features.Emails` slice, with no direct project reference between the two
|
||||||
|
slices
|
||||||
|
|
||||||
|
#### [RefreshTokenHandler](../../web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenHandler.cs)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Receives a refresh token via `RefreshTokenCommand`
|
||||||
|
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
|
||||||
|
retrieves the user account, and issues a new access/refresh token pair
|
||||||
|
3. Maps the result onto `LoginPayload`
|
||||||
|
|
||||||
|
#### [AuthController](../../web/backend/Features/Features.Users/Controllers/AuthController.cs)
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
|
||||||
|
- `POST /api/auth/register` - Register new user
|
||||||
|
- `POST /api/auth/login` - Authenticate user
|
||||||
|
- `POST /api/auth/confirm?token=...` - Confirm email
|
||||||
|
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
|
||||||
|
- `POST /api/auth/refresh` - Refresh access token
|
||||||
|
|
||||||
|
Account management (require a valid access token; user ID comes from the token,
|
||||||
|
never the request body):
|
||||||
|
|
||||||
|
- `PATCH /api/auth/username` - Change the authenticated user's username
|
||||||
|
- `PATCH /api/auth/email` - Change the authenticated user's email address;
|
||||||
|
resets `EmailConfirmed` to false, so the user must re-confirm via
|
||||||
|
`POST /api/auth/confirm/resend`
|
||||||
|
- `PATCH /api/auth/password` - Change the authenticated user's password;
|
||||||
|
requires the current password
|
||||||
|
- `PATCH /api/auth/profile` - Update the authenticated user's profile (first
|
||||||
|
name, last name, date of birth)
|
||||||
|
- `DELETE /api/auth/account` - Permanently delete the authenticated user's
|
||||||
|
account; blocked while the account still has associated posts, comments,
|
||||||
|
photos, or follows
|
||||||
|
|
||||||
|
## Validation security
|
||||||
|
|
||||||
|
### Token secrets
|
||||||
|
|
||||||
|
Three independent secrets enable:
|
||||||
|
|
||||||
|
- **Key rotation** - Rotate each secret type independently
|
||||||
|
- **Isolation** - Compromise of one secret doesn't affect others
|
||||||
|
- **Different expiration** - Different token types can expire at different rates
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
|
||||||
|
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
|
||||||
|
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation checks
|
||||||
|
|
||||||
|
Each token is validated for:
|
||||||
|
|
||||||
|
1. **Signature Verification** - Token must be signed with correct secret
|
||||||
|
2. **Expiration** - Token must not be expired (checked against current time)
|
||||||
|
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
|
||||||
|
4. **Claims Format** - UserId claim must be a valid GUID
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
All of the following live in `Features.Users.Tests`.
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
|
||||||
|
**Services/TokenServiceValidationTests.cs**
|
||||||
|
|
||||||
|
- Happy path: Valid token extraction
|
||||||
|
- Error cases: Invalid, expired, malformed tokens
|
||||||
|
- Missing/invalid claims scenarios
|
||||||
|
|
||||||
|
**Services/TokenServiceRefreshTests.cs**
|
||||||
|
|
||||||
|
- Successful refresh with valid token
|
||||||
|
- Invalid/expired refresh token rejection
|
||||||
|
- Non-existent user handling
|
||||||
|
|
||||||
|
**Commands/RefreshTokenHandlerTests.cs**
|
||||||
|
|
||||||
|
- Verifies the handler maps `ITokenService.RefreshTokenAsync()`'s result onto
|
||||||
|
`LoginPayload`
|
||||||
|
|
||||||
|
**Commands/ConfirmUserHandlerTests.cs**
|
||||||
|
|
||||||
|
- Successful confirmation with valid token
|
||||||
|
- Token validation failures
|
||||||
|
- User not found scenarios
|
||||||
|
|
||||||
|
**Commands/ResendConfirmationEmailHandlerTests.cs**
|
||||||
|
|
||||||
|
- Sends a fresh confirmation email when the user exists and is unverified
|
||||||
|
- No-ops when the user doesn't exist or is already verified
|
||||||
|
|
||||||
|
### BDD tests (Reqnroll)
|
||||||
|
|
||||||
|
**TokenRefresh.feature**
|
||||||
|
|
||||||
|
- Successful token refresh
|
||||||
|
- Invalid/expired token rejection
|
||||||
|
- Missing token validation
|
||||||
|
|
||||||
|
**Confirmation.feature**
|
||||||
|
|
||||||
|
- Successful email confirmation
|
||||||
|
- Expired/tampered token rejection
|
||||||
|
- Missing token validation
|
||||||
|
|
||||||
|
**AccessTokenValidation.feature**
|
||||||
|
|
||||||
|
- Protected endpoint access token validation
|
||||||
|
- Invalid/expired access token rejection
|
||||||
|
- Token type mismatch (refresh used as access token)
|
||||||
|
|
||||||
|
## Future enhancements
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
4. **Structured Logging** - Log token validation attempts - Track failed
|
||||||
|
validation reasons - Alert on repeated validation failures (brute force
|
||||||
|
detection)
|
||||||
@@ -1,85 +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.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
|
|
||||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
|
||||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
### 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) |
|
|
||||||
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `Photo` | A photo uploaded by a user account |
|
|
||||||
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
|
|
||||||
| `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 only seeds
|
|
||||||
tables that have a feature slice to write through (see the table above).
|
|
||||||
|
|
||||||
## 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 repository-level exception: `Features.Auth`'s `AuthRepository` 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 `Auth Repository.IsDuplicateVerificationViolation`.
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,147 +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.Auth] as AuthSlice
|
|
||||||
[Features.Breweries] as BrewerySlice
|
|
||||||
[Features.UserManagement] as UserSlice
|
|
||||||
[Features.Emails] as EmailsSlice
|
|
||||||
[Features.Locations] as LocationsSlice
|
|
||||||
note right of AuthSlice
|
|
||||||
Each slice owns its own:
|
|
||||||
- Controller (HTTP endpoints)
|
|
||||||
- Commands/Queries + Handlers
|
|
||||||
- Validators
|
|
||||||
- Repository (Dapper)
|
|
||||||
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
|
|
||||||
- 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 ..> AuthSlice : AddApplicationPart\n+ MediatR scan
|
|
||||||
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
|
||||||
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
|
|
||||||
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
|
||||||
|
|
||||||
Seed ..> AuthSlice : AddFeaturesAuth()\n(DI only, no API/MediatR)
|
|
||||||
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
|
||||||
Seed ..> LocationsSlice : AddFeaturesLocations()
|
|
||||||
|
|
||||||
AuthSlice --> Sql
|
|
||||||
AuthSlice --> JWT
|
|
||||||
AuthSlice --> PwdHash
|
|
||||||
AuthSlice --> SharedContracts
|
|
||||||
AuthSlice --> SharedApp
|
|
||||||
|
|
||||||
BrewerySlice --> Sql
|
|
||||||
BrewerySlice --> SharedContracts
|
|
||||||
BrewerySlice --> SharedApp
|
|
||||||
|
|
||||||
UserSlice --> Sql
|
|
||||||
UserSlice --> SharedContracts
|
|
||||||
UserSlice --> SharedApp
|
|
||||||
|
|
||||||
LocationsSlice --> Sql
|
|
||||||
|
|
||||||
EmailsSlice --> Email
|
|
||||||
EmailsSlice --> EmailTemplates
|
|
||||||
EmailsSlice --> SharedApp
|
|
||||||
|
|
||||||
' The one cross-slice interaction: a MediatR command contract, not a project reference
|
|
||||||
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
|
|
||||||
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
|
||||||
|
|
||||||
Sql --> Domain
|
|
||||||
|
|
||||||
AuthSlice --> Tables : Dapper SQL
|
|
||||||
BrewerySlice --> Tables : Dapper SQL
|
|
||||||
UserSlice --> Tables : Dapper SQL
|
|
||||||
LocationsSlice --> Tables : Dapper SQL
|
|
||||||
|
|
||||||
AuthSlice --> Domain
|
|
||||||
BrewerySlice --> Domain
|
|
||||||
UserSlice --> Domain
|
|
||||||
LocationsSlice --> Domain
|
|
||||||
AuthSlice --> 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 AuthSlice
|
|
||||||
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,208 +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 "UserAvatar (*)" as Avatar {
|
|
||||||
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
|
|
||||||
--
|
|
||||||
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
|
||||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
|
||||||
}
|
|
||||||
|
|
||||||
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| Avatar : "has"
|
|
||||||
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.Auth's DapperUserStore - inline SQL, no
|
|
||||||
stored procedures. Profile data still goes through
|
|
||||||
Features.UserManagement's UserAccountRepository.
|
|
||||||
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,334 +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.Auth.Tests
|
|
||||||
- Features.Breweries.Tests
|
|
||||||
- Features.UserManagement.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.Auth.Tests.trx" as Result2
|
|
||||||
file "Features.Breweries.Tests.trx" as Result3
|
|
||||||
file "Features.UserManagement.Tests.trx" as Result4
|
|
||||||
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
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
# Docker guide
|
|
||||||
|
|
||||||
This document covers Docker deployment, configuration, and troubleshooting for
|
|
||||||
The Biergarten App.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The project uses Docker Compose to orchestrate multiple services:
|
|
||||||
|
|
||||||
- SQL Server 2022 database
|
|
||||||
- Database migrations runner (DbUp)
|
|
||||||
- Database seeder
|
|
||||||
- .NET API
|
|
||||||
- Test runners
|
|
||||||
|
|
||||||
See the [deployment diagram](diagrams-out/deployment.svg) for visual
|
|
||||||
representation.
|
|
||||||
|
|
||||||
## Docker Compose environments
|
|
||||||
|
|
||||||
### 1. Development (`docker-compose.dev.yaml`)
|
|
||||||
|
|
||||||
**Purpose**: Local development with persistent data
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
- Persistent SQL Server volume
|
|
||||||
- NuGet package cache volume (speeds up rebuilds)
|
|
||||||
- Swagger UI enabled
|
|
||||||
- Seed data included
|
|
||||||
- Local Mailpit SMTP server for dev email testing
|
|
||||||
- `CLEAR_DATABASE=true` (drops and recreates schema)
|
|
||||||
|
|
||||||
**Services**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
sqlserver # SQL Server 2022 (port 1434)
|
|
||||||
database.migrations # DbUp migrations
|
|
||||||
database.seed # Seed initial data
|
|
||||||
api.core # Web API (ports 8080, 8081)
|
|
||||||
mailpit # Local dev SMTP server + UI (port 8025)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Start Development Environment**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Access**:
|
|
||||||
|
|
||||||
- API Swagger: http://localhost:8080/swagger
|
|
||||||
- Health Check: http://localhost:8080/health
|
|
||||||
- Mailpit UI: http://localhost:8025
|
|
||||||
- SQL Server: localhost:1434 (sa credentials from .env.dev)
|
|
||||||
|
|
||||||
**Stop Environment**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop services (keep volumes)
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
|
|
||||||
|
|
||||||
# Stop and remove volumes (fresh start)
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Testing (`docker-compose.test.yaml`)
|
|
||||||
|
|
||||||
**Purpose**: Automated CI/CD testing in an isolated environment
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
- Fresh database each run
|
|
||||||
- All test suites execute in parallel
|
|
||||||
- Test results exported to `./test-results/`
|
|
||||||
- Containers auto-exit after completion
|
|
||||||
- Fully isolated testnet network
|
|
||||||
|
|
||||||
**Services**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
sqlserver # Test database
|
|
||||||
database.migrations # Fresh schema
|
|
||||||
database.seed # Test data
|
|
||||||
api.specs # Reqnroll BDD tests
|
|
||||||
unit.tests # All Features.*.Tests unit test projects
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run Tests**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
|
||||||
|
|
||||||
# View results
|
|
||||||
ls -la test-results/
|
|
||||||
cat test-results/api-specs/results.trx
|
|
||||||
cat test-results/Features.Auth.Tests.trx
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Production (`docker-compose.prod.yaml`)
|
|
||||||
|
|
||||||
**Purpose**: Production-ready deployment
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
- Production logging levels
|
|
||||||
- No database clearing
|
|
||||||
- Optimized build configurations
|
|
||||||
- Health checks enabled
|
|
||||||
- Restart policies (unless-stopped)
|
|
||||||
- Security hardening
|
|
||||||
|
|
||||||
**Services**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
sqlserver # Production SQL Server
|
|
||||||
database.migrations # Schema updates only
|
|
||||||
api.core # Production API
|
|
||||||
```
|
|
||||||
|
|
||||||
**Deploy Production**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Database only (`docker-compose.db.yaml`)
|
|
||||||
|
|
||||||
**Purpose**: Run just the database for local backend development outside Docker
|
|
||||||
(for example, `dotnet run` against a containerized SQL Server)
|
|
||||||
|
|
||||||
**Services**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
sqlserver # SQL Server 2022 (port 1434)
|
|
||||||
database.migrations # DbUp migrations
|
|
||||||
database.seed # Seed initial data
|
|
||||||
```
|
|
||||||
|
|
||||||
No `api.core` or `mailpit` container. Uses `.env.dev`, same as development.
|
|
||||||
|
|
||||||
**Start**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.db.yaml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Minimal (`docker-compose.min.yaml`)
|
|
||||||
|
|
||||||
**Purpose**: Local dev where the API runs outside Docker but the database and
|
|
||||||
mail capture still run in containers
|
|
||||||
|
|
||||||
**Services**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
sqlserver # SQL Server 2022 (port 1433)
|
|
||||||
database.migrations # DbUp migrations
|
|
||||||
database.seed # Seed initial data
|
|
||||||
mailpit # Local dev SMTP server + UI (port 8025)
|
|
||||||
```
|
|
||||||
|
|
||||||
No `api.core` container. Uses `.env.local` instead of `.env.dev`, and exposes
|
|
||||||
SQL Server on the standard port 1433 (`docker-compose.db.yaml` and
|
|
||||||
`docker-compose.dev.yaml` both use 1434).
|
|
||||||
|
|
||||||
**Start**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.local -f web/docker-compose.min.yaml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Service dependencies
|
|
||||||
|
|
||||||
Docker Compose manages startup order using health checks:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
|
|
||||||
B --> C[database.seed: completes successfully]
|
|
||||||
C --> D[api.core / tests: start when ready]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Health Check Example** (SQL Server):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD-SHELL",
|
|
||||||
"sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'",
|
|
||||||
]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 30s
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependency Configuration**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
api.core:
|
|
||||||
depends_on:
|
|
||||||
database.seed:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
## Volumes
|
|
||||||
|
|
||||||
### Persistent volumes
|
|
||||||
|
|
||||||
**Development**:
|
|
||||||
|
|
||||||
- `sqlserverdata-dev` - Database files persist between restarts
|
|
||||||
- `nuget-cache-dev` - NuGet package cache (speeds up builds)
|
|
||||||
|
|
||||||
**Testing**:
|
|
||||||
|
|
||||||
- `sqlserverdata-test` - Temporary, typically removed after tests
|
|
||||||
|
|
||||||
**Production**:
|
|
||||||
|
|
||||||
- `sqlserverdata-prod` - Production database files
|
|
||||||
- `nuget-cache-prod` - Production NuGet cache
|
|
||||||
|
|
||||||
### Mounted volumes
|
|
||||||
|
|
||||||
**Test Results**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- ./test-results:/app/test-results
|
|
||||||
```
|
|
||||||
|
|
||||||
Test results are written to host filesystem for CI/CD integration.
|
|
||||||
|
|
||||||
## Networks
|
|
||||||
|
|
||||||
Each environment uses isolated bridge networks:
|
|
||||||
|
|
||||||
- `devnet` - Development network
|
|
||||||
- `testnet` - Testing network (fully isolated)
|
|
||||||
- `prodnet` - Production network
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
All containers are configured via environment variables from `.env` files:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
env_file: ".env.dev" # or .env.test, .env.prod
|
|
||||||
|
|
||||||
environment:
|
|
||||||
ASPNETCORE_ENVIRONMENT: "Development"
|
|
||||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
|
||||||
DB_SERVER: "${DB_SERVER}"
|
|
||||||
DB_NAME: "${DB_NAME}"
|
|
||||||
DB_USER: "${DB_USER}"
|
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
|
||||||
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
|
||||||
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
|
||||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
|
||||||
```
|
|
||||||
|
|
||||||
For complete list, see [Environment Variables](environment-variables.md).
|
|
||||||
|
|
||||||
## Common commands
|
|
||||||
|
|
||||||
### View services
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Running services
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
|
|
||||||
|
|
||||||
# All containers (including stopped)
|
|
||||||
docker ps -a
|
|
||||||
```
|
|
||||||
|
|
||||||
### View logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All services
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
|
|
||||||
|
|
||||||
# Specific service
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f api.core
|
|
||||||
|
|
||||||
# Last 100 lines
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
|
|
||||||
```
|
|
||||||
|
|
||||||
### Execute commands in container
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Interactive shell
|
|
||||||
docker exec -it dev-env-api-core bash
|
|
||||||
|
|
||||||
# Run command (replace <db-password> with the value of DB_PASSWORD from .env.dev)
|
|
||||||
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<db-password>' -C
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restart services
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Restart all services
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart
|
|
||||||
|
|
||||||
# Restart specific service
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart api.core
|
|
||||||
|
|
||||||
# Rebuild and restart
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build images
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build all images
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build
|
|
||||||
|
|
||||||
# Build specific service
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.core
|
|
||||||
|
|
||||||
# Build without cache
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean up
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop and remove containers
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
|
|
||||||
|
|
||||||
# Remove containers and volumes
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
|
||||||
|
|
||||||
# Remove containers, volumes, and images
|
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --rmi all
|
|
||||||
|
|
||||||
# System-wide cleanup
|
|
||||||
docker system prune -af --volumes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dockerfile structure
|
|
||||||
|
|
||||||
### Multi-stage build
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Stage 1: Build
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|
||||||
WORKDIR /src
|
|
||||||
COPY ["Project/Project.csproj", "Project/"]
|
|
||||||
RUN dotnet restore
|
|
||||||
COPY . .
|
|
||||||
RUN dotnet build -c Release
|
|
||||||
|
|
||||||
# Stage 2: Runtime
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=build /app/build .
|
|
||||||
ENTRYPOINT ["dotnet", "Project.dll"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Additional resources
|
|
||||||
|
|
||||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
|
||||||
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
|
|
||||||
- [SQL Server Docker Images](https://hub.docker.com/_/microsoft-mssql-server)
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
# Testing
|
|
||||||
|
|
||||||
This document describes the testing strategy and how to run tests for The
|
|
||||||
Biergarten App.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
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.Auth.Tests`, `Features.Breweries.Tests`,
|
|
||||||
`Features.UserManagement.Tests`, `Features.Emails.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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
|
||||||
```
|
|
||||||
|
|
||||||
This command:
|
|
||||||
|
|
||||||
1. Starts a fresh SQL Server instance
|
|
||||||
2. Runs database migrations
|
|
||||||
3. Seeds test data
|
|
||||||
4. Executes all test suites in parallel
|
|
||||||
5. Exports results to `./test-results/`
|
|
||||||
6. Exits when tests complete
|
|
||||||
|
|
||||||
### View test results
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List test result files
|
|
||||||
ls -la test-results/
|
|
||||||
|
|
||||||
# View specific test results
|
|
||||||
cat test-results/api-specs/results.trx
|
|
||||||
cat test-results/Features.Auth.Tests.trx
|
|
||||||
cat test-results/Features.Breweries.Tests.trx
|
|
||||||
cat test-results/Features.UserManagement.Tests.trx
|
|
||||||
cat test-results/Features.Emails.Tests.trx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean up
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Remove test containers and volumes
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running tests locally
|
|
||||||
|
|
||||||
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
|
|
||||||
handlers:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web/backend
|
|
||||||
dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj
|
|
||||||
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
|
|
||||||
dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj
|
|
||||||
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
|
|
||||||
|
|
||||||
# Or run all of them at once via the solution:
|
|
||||||
for proj in Features/*.Tests; do dotnet test "$proj"; done
|
|
||||||
```
|
|
||||||
|
|
||||||
**Requirements**:
|
|
||||||
|
|
||||||
- No database required (handlers are tested with their repository/service
|
|
||||||
dependencies mocked via Moq)
|
|
||||||
|
|
||||||
### Frontend Storybook tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web/frontend
|
|
||||||
npm install
|
|
||||||
npm run test:storybook
|
|
||||||
```
|
|
||||||
|
|
||||||
**Purpose**:
|
|
||||||
|
|
||||||
- Verifies shared stories such as form fields, submit buttons, navbar states,
|
|
||||||
toasts, and the theme gallery
|
|
||||||
- Runs in browser mode via Vitest and Storybook integration
|
|
||||||
|
|
||||||
### Frontend Playwright Storybook tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web/frontend
|
|
||||||
npm install
|
|
||||||
npm run test:storybook:playwright
|
|
||||||
```
|
|
||||||
|
|
||||||
**Requirements**:
|
|
||||||
|
|
||||||
- Storybook dependencies installed
|
|
||||||
- Playwright browser dependencies installed
|
|
||||||
- The command will start or reuse the Storybook server defined in
|
|
||||||
`playwright.storybook.config.ts`
|
|
||||||
|
|
||||||
### Running Storybook tests with a visible browser
|
|
||||||
|
|
||||||
Both of the above run headless by default. To watch the browser while debugging
|
|
||||||
a failure:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Playwright Storybook suite
|
|
||||||
npx playwright test -c playwright.storybook.config.ts --headed
|
|
||||||
# or the interactive step-through runner
|
|
||||||
npx playwright test -c playwright.storybook.config.ts --ui
|
|
||||||
|
|
||||||
# Vitest/story play-function suite
|
|
||||||
npx vitest run --project storybook --browser.headless=false
|
|
||||||
# or Vitest's own UI (drop `run` for watch mode)
|
|
||||||
npx vitest --project storybook --browser.headless=false --ui
|
|
||||||
```
|
|
||||||
|
|
||||||
The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
|
||||||
|
|
||||||
## Test coverage
|
|
||||||
|
|
||||||
### Current coverage
|
|
||||||
|
|
||||||
**Features.Auth.Tests**:
|
|
||||||
|
|
||||||
- User registration with validation
|
|
||||||
- User login with JWT token generation
|
|
||||||
- Password hashing and verification (Argon2id)
|
|
||||||
- Email confirmation and confirmation-email resend
|
|
||||||
- Refresh token exchange
|
|
||||||
- JWT token generation, validation, and claims handling
|
|
||||||
- Invalid credentials and 404 error responses (via API.Specs)
|
|
||||||
- Account management: username, email, password, and profile updates, and
|
|
||||||
account deletion (blocked while dependent posts, comments, photos, or follows
|
|
||||||
exist)
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
**Features.UserManagement.Tests**:
|
|
||||||
|
|
||||||
- User get-by-id/get-all queries and the (currently unrouted) update command
|
|
||||||
|
|
||||||
**Features.Emails.Tests**:
|
|
||||||
|
|
||||||
- Registration and resend-confirmation email dispatch handlers
|
|
||||||
|
|
||||||
**Frontend UI Coverage**:
|
|
||||||
|
|
||||||
- Shared submit button states
|
|
||||||
- Form field happy path and error presentation
|
|
||||||
- Navbar guest, authenticated, and mobile behavior
|
|
||||||
- Theme gallery rendering across Biergarten themes
|
|
||||||
- Toast interactions and themed notification display
|
|
||||||
|
|
||||||
### Planned coverage
|
|
||||||
|
|
||||||
- [ ] Password reset functionality
|
|
||||||
- [ ] Beer post operations
|
|
||||||
- [ ] User follow/unfollow
|
|
||||||
- [ ] Image upload service
|
|
||||||
- [ ] Frontend route integration coverage beyond Storybook stories
|
|
||||||
|
|
||||||
## Testing frameworks and tools
|
|
||||||
|
|
||||||
### xUnit
|
|
||||||
|
|
||||||
- Primary unit testing framework
|
|
||||||
- Used for handler and service layer tests
|
|
||||||
- Supports parallel test execution
|
|
||||||
|
|
||||||
### Reqnroll (Gherkin/BDD)
|
|
||||||
|
|
||||||
- Behavior-driven development framework
|
|
||||||
- Used for API integration tests
|
|
||||||
- Human-readable test scenarios in `.feature` files
|
|
||||||
|
|
||||||
### FluentAssertions
|
|
||||||
|
|
||||||
- Expressive assertion library
|
|
||||||
- Makes test assertions more readable
|
|
||||||
- Used across all test projects
|
|
||||||
|
|
||||||
### Moq
|
|
||||||
|
|
||||||
- Mocking framework for .NET
|
|
||||||
- Used in Service layer tests
|
|
||||||
- Enables isolated unit testing
|
|
||||||
|
|
||||||
## Test structure
|
|
||||||
|
|
||||||
### API.Specs (integration tests)
|
|
||||||
|
|
||||||
```
|
|
||||||
API.Specs/
|
|
||||||
├── Features/
|
|
||||||
│ ├── Registration.feature # Registration scenarios
|
|
||||||
│ ├── Login.feature # Login scenarios
|
|
||||||
│ ├── Confirmation.feature # Email confirmation scenarios
|
|
||||||
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
|
|
||||||
│ ├── TokenRefresh.feature # Refresh token scenarios
|
|
||||||
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
|
|
||||||
│ └── NotFound.feature # 404 handling
|
|
||||||
├── Steps/
|
|
||||||
│ ├── AuthSteps.cs # Step definitions for the Auth features
|
|
||||||
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
|
||||||
├── Mocks/
|
|
||||||
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
|
||||||
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider
|
|
||||||
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example Feature**:
|
|
||||||
|
|
||||||
```gherkin
|
|
||||||
Feature: User Registration
|
|
||||||
As a user
|
|
||||||
I want to register
|
|
||||||
So that I can access the platform
|
|
||||||
|
|
||||||
Scenario: Successful user registration
|
|
||||||
Given I have valid registration details
|
|
||||||
When I register a new account
|
|
||||||
Then I should receive a JWT token
|
|
||||||
And my account should be created
|
|
||||||
```
|
|
||||||
|
|
||||||
### Features.Auth.Tests
|
|
||||||
|
|
||||||
```
|
|
||||||
Features.Auth.Tests/
|
|
||||||
├── Commands/
|
|
||||||
│ ├── RegisterUserHandlerTests.cs
|
|
||||||
│ ├── ConfirmUserHandlerTests.cs
|
|
||||||
│ ├── ResendConfirmationEmailHandlerTests.cs
|
|
||||||
│ ├── RefreshTokenHandlerTests.cs
|
|
||||||
│ ├── UpdateUsernameHandlerTests.cs
|
|
||||||
│ ├── UpdateEmailHandlerTests.cs
|
|
||||||
│ ├── UpdatePasswordHandlerTests.cs
|
|
||||||
│ ├── UpdateProfileHandlerTests.cs
|
|
||||||
│ └── DeleteAccountHandlerTests.cs
|
|
||||||
├── Queries/
|
|
||||||
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
|
|
||||||
└── Services/
|
|
||||||
├── TokenServiceRefreshTests.cs
|
|
||||||
└── TokenServiceValidationTests.cs
|
|
||||||
```
|
|
||||||
|
|
||||||
Each of the other three slices (`Features.Breweries.Tests`,
|
|
||||||
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
|
|
||||||
shape: a `Commands/`/`Queries/` folder with one test file per handler.
|
|
||||||
|
|
||||||
## Writing tests
|
|
||||||
|
|
||||||
### Unit test example (xUnit)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class LoginHandlerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var authRepoMock = new Mock<IAuthRepository>();
|
|
||||||
var passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
|
||||||
var tokenServiceMock = new Mock<ITokenService>();
|
|
||||||
var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
|
|
||||||
// ...set up mocks for a known user/credential...
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result.AccessToken.Should().NotBeNullOrEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration test example (Reqnroll)
|
|
||||||
|
|
||||||
```gherkin
|
|
||||||
Scenario: User login with valid credentials
|
|
||||||
Given a registered user with username "testuser"
|
|
||||||
When I POST to "/api/auth/login" with valid credentials
|
|
||||||
Then the response status should be 200
|
|
||||||
And the response should contain a JWT token
|
|
||||||
```
|
|
||||||
|
|
||||||
## Continuous integration
|
|
||||||
|
|
||||||
Tests run automatically in CI/CD pipelines using the test Docker Compose
|
|
||||||
configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CI/CD command
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
|
|
||||||
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 down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit codes:
|
|
||||||
|
|
||||||
- `0` - All tests passed
|
|
||||||
- Non-zero - Test failures occurred
|
|
||||||
|
|
||||||
Frontend UI checks should also be included in CI for the active website
|
|
||||||
workspace:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web/frontend
|
|
||||||
npm ci
|
|
||||||
npm run test:storybook
|
|
||||||
npm run test:storybook:playwright
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Tests failing due to database connection
|
|
||||||
|
|
||||||
Ensure SQL Server is running and environment variables are set:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port conflicts
|
|
||||||
|
|
||||||
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
|
||||||
`docker-compose.test.yaml`.
|
|
||||||
|
|
||||||
### Stale test data
|
|
||||||
|
|
||||||
Clean up test database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### View container logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best practices
|
|
||||||
|
|
||||||
1. **Isolation**: Each test should be independent and not rely on other tests
|
|
||||||
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
|
||||||
3. **Mocking**: Mock external dependencies in unit tests
|
|
||||||
4. **Descriptive Names**: Use clear, descriptive test method names
|
|
||||||
5. **Arrange-Act-Assert**: Follow AAA pattern in unit tests
|
|
||||||
6. **Given-When-Then**: Follow GWT pattern in BDD scenarios
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
# Token validation architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Core project 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
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Infrastructure layer
|
|
||||||
|
|
||||||
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
|
|
||||||
|
|
||||||
Low-level JWT operations.
|
|
||||||
|
|
||||||
**Methods:**
|
|
||||||
|
|
||||||
- `GenerateJwt()` - Creates signed JWT tokens
|
|
||||||
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
[JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
|
|
||||||
|
|
||||||
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
|
||||||
- Algorithm: HS256 (HMAC-SHA256)
|
|
||||||
- Validates token lifetime, signature, and well-formedness
|
|
||||||
|
|
||||||
### Features.Auth slice
|
|
||||||
|
|
||||||
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
|
|
||||||
|
|
||||||
Both token generation and validation live on the same slice-internal service
|
|
||||||
(there is no separate validation service/class).
|
|
||||||
|
|
||||||
**Generation methods:**
|
|
||||||
|
|
||||||
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
|
|
||||||
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
|
|
||||||
- `GenerateConfirmationToken(UserAccount)` - 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
|
|
||||||
|
|
||||||
**Returns (validation):** `ValidatedToken` record containing:
|
|
||||||
|
|
||||||
- `UserId` (Guid)
|
|
||||||
- `Username` (string)
|
|
||||||
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
|
|
||||||
|
|
||||||
- Reads token secrets from environment variables
|
|
||||||
- Extracts and validates claims (Sub, UniqueName)
|
|
||||||
- Throws `UnauthorizedException` on validation failure
|
|
||||||
|
|
||||||
`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()`
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Integration points
|
|
||||||
|
|
||||||
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
|
|
||||||
1. Receives confirmation token from user via `ConfirmUserCommand`
|
|
||||||
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
|
|
||||||
3. Extracts user ID from validated token
|
|
||||||
4. Calls `IAuthRepository.ConfirmUserAccountAsync()` to update database
|
|
||||||
5. Returns confirmation result
|
|
||||||
|
|
||||||
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
|
|
||||||
1. Receives a user ID via `ResendConfirmationEmailCommand`
|
|
||||||
2. Looks up the user and checks they aren't already verified
|
|
||||||
3. Generates a fresh confirmation token via `ITokenService`
|
|
||||||
4. Sends `SendResendConfirmationEmailCommand` over MediatR, handled by the
|
|
||||||
`Features.Emails` slice, with no direct project reference between the two
|
|
||||||
slices
|
|
||||||
|
|
||||||
#### [RefreshTokenHandler](../../web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs)
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
|
|
||||||
1. Receives a refresh token via `RefreshTokenCommand`
|
|
||||||
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
|
|
||||||
retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and
|
|
||||||
issues a new access/refresh token pair
|
|
||||||
3. Maps the result onto `LoginPayload`
|
|
||||||
|
|
||||||
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
|
|
||||||
|
|
||||||
**Endpoints:**
|
|
||||||
|
|
||||||
- `POST /api/auth/register` - Register new user
|
|
||||||
- `POST /api/auth/login` - Authenticate user
|
|
||||||
- `POST /api/auth/confirm?token=...` - Confirm email
|
|
||||||
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
|
|
||||||
- `POST /api/auth/refresh` - Refresh access token
|
|
||||||
|
|
||||||
Account management (require a valid access token; user ID comes from the token,
|
|
||||||
never the request body):
|
|
||||||
|
|
||||||
- `PATCH /api/auth/username` - Change the authenticated user's username
|
|
||||||
- `PATCH /api/auth/email` - Change the authenticated user's email address;
|
|
||||||
resets `EmailConfirmed` to false, so the user must re-confirm via
|
|
||||||
`POST /api/auth/confirm/resend`
|
|
||||||
- `PATCH /api/auth/password` - Change the authenticated user's password;
|
|
||||||
requires the current password
|
|
||||||
- `PATCH /api/auth/profile` - Update the authenticated user's profile (first
|
|
||||||
name, last name, date of birth)
|
|
||||||
- `DELETE /api/auth/account` - Permanently delete the authenticated user's
|
|
||||||
account; blocked while the account still has associated posts, comments,
|
|
||||||
photos, or follows
|
|
||||||
|
|
||||||
## Validation security
|
|
||||||
|
|
||||||
### Token secrets
|
|
||||||
|
|
||||||
Three independent secrets enable:
|
|
||||||
|
|
||||||
- **Key rotation** - Rotate each secret type independently
|
|
||||||
- **Isolation** - Compromise of one secret doesn't affect others
|
|
||||||
- **Different expiration** - Different token types can expire at different rates
|
|
||||||
|
|
||||||
**Environment Variables:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
|
|
||||||
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
|
|
||||||
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
|
|
||||||
```
|
|
||||||
|
|
||||||
### Validation checks
|
|
||||||
|
|
||||||
Each token is validated for:
|
|
||||||
|
|
||||||
1. **Signature Verification** - Token must be signed with correct secret
|
|
||||||
2. **Expiration** - Token must not be expired (checked against current time)
|
|
||||||
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
|
|
||||||
4. **Claims Format** - UserId claim must be a valid GUID
|
|
||||||
|
|
||||||
### 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"
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
All of the following live in `Features.Auth.Tests`.
|
|
||||||
|
|
||||||
### Unit tests
|
|
||||||
|
|
||||||
**Services/TokenServiceValidationTests.cs**
|
|
||||||
|
|
||||||
- Happy path: Valid token extraction
|
|
||||||
- Error cases: Invalid, expired, malformed tokens
|
|
||||||
- Missing/invalid claims scenarios
|
|
||||||
|
|
||||||
**Services/TokenServiceRefreshTests.cs**
|
|
||||||
|
|
||||||
- Successful refresh with valid token
|
|
||||||
- Invalid/expired refresh token rejection
|
|
||||||
- Non-existent user handling
|
|
||||||
|
|
||||||
**Commands/RefreshTokenHandlerTests.cs**
|
|
||||||
|
|
||||||
- Verifies the handler maps `ITokenService.RefreshTokenAsync()`'s result onto
|
|
||||||
`LoginPayload`
|
|
||||||
|
|
||||||
**Commands/ConfirmUserHandlerTests.cs**
|
|
||||||
|
|
||||||
- Successful confirmation with valid token
|
|
||||||
- Token validation failures
|
|
||||||
- User not found scenarios
|
|
||||||
|
|
||||||
**Commands/ResendConfirmationEmailHandlerTests.cs**
|
|
||||||
|
|
||||||
- Sends a fresh confirmation email when the user exists and is unverified
|
|
||||||
- No-ops when the user doesn't exist or is already verified
|
|
||||||
|
|
||||||
### BDD tests (Reqnroll)
|
|
||||||
|
|
||||||
**TokenRefresh.feature**
|
|
||||||
|
|
||||||
- Successful token refresh
|
|
||||||
- Invalid/expired token rejection
|
|
||||||
- Missing token validation
|
|
||||||
|
|
||||||
**Confirmation.feature**
|
|
||||||
|
|
||||||
- Successful email confirmation
|
|
||||||
- Expired/tampered token rejection
|
|
||||||
- Missing token validation
|
|
||||||
|
|
||||||
**AccessTokenValidation.feature**
|
|
||||||
|
|
||||||
- Protected endpoint access token validation
|
|
||||||
- Invalid/expired access token rejection
|
|
||||||
- Token type mismatch (refresh used as access token)
|
|
||||||
|
|
||||||
## Future enhancements
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
4. **Structured Logging**
|
|
||||||
- Log token validation attempts
|
|
||||||
- Track failed validation reasons
|
|
||||||
- Alert on repeated validation failures (brute force detection)
|
|
||||||
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
|
||||||
@@ -39,6 +39,14 @@ ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
|
|||||||
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
|
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
|
||||||
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Frontend (Website) Configuration
|
||||||
|
# ======================
|
||||||
|
|
||||||
|
# Cookie session signing secret for the React Router website container
|
||||||
|
# IMPORTANT: Generate a secure secret (minimum 32 characters)
|
||||||
|
# Command: openssl rand -base64 32
|
||||||
|
SESSION_SECRET=your-secure-session-secret-key
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
@@ -56,3 +64,16 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
|||||||
SMTP_FROM_NAME=The Biergarten App
|
SMTP_FROM_NAME=The Biergarten App
|
||||||
|
|
||||||
WEBSITE_BASE_URL=http://localhost:3000
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# SeaweedFS (S3) Configuration
|
||||||
|
# ======================
|
||||||
|
# Credentials and default bucket for the local SeaweedFS S3-compatible storage.
|
||||||
|
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
|
||||||
|
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
|
||||||
|
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
|
||||||
|
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||||
|
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
||||||
|
SEAWEEDFS_ACCESS_KEY_ID=admin
|
||||||
|
SEAWEEDFS_SECRET_ACCESS_KEY=secret
|
||||||
|
SEAWEEDFS_BUCKET=my-bucket
|
||||||
|
|||||||
@@ -7,23 +7,27 @@
|
|||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="API.Specs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||||
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
|
||||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using Infrastructure.Configuration;
|
using Features.Users.Services;
|
||||||
using Infrastructure.Jwt;
|
using Configuration;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
@@ -25,17 +25,13 @@ public class JwtAuthenticationHandler(
|
|||||||
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable.
|
||||||
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
/// Fails if the secret is not configured, the <c>Authorization</c> header is
|
||||||
/// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
|
|
||||||
/// missing or not <c>Bearer</c>-scheme, or the token fails validation.
|
/// missing or not <c>Bearer</c>-scheme, or the token fails validation.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
|
||||||
string? secret = configuration[ConfigurationKeys.AccessTokenSecret];
|
string? secret = configuration[ConfigurationKeys.AccessTokenSecret];
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
|
||||||
secret = configuration[ConfigurationKeys.JwtSecretKeyFallback];
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
|
|
||||||
/// <c>Program.cs</c>.
|
|
||||||
/// </remarks>
|
|
||||||
[ApiController]
|
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
|
||||||
[Route("error")]
|
|
||||||
public class NotFoundController : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a 404 response for any request that did not match a route.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("404")]
|
|
||||||
public IActionResult Handle404()
|
|
||||||
{
|
|
||||||
return NotFound(new { message = "Route not found." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Shared.Contracts;
|
|
||||||
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sample controller demonstrating an endpoint that requires JWT authentication.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
|
||||||
public class ProtectedController : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the authenticated caller's user ID and username extracted from the JWT claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns><c>userId</c> and <c>username</c> may be <c>null</c> if not present in the token.</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public ActionResult<ResponseBody<object>> Get()
|
|
||||||
{
|
|
||||||
string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
|
||||||
string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
|
||||||
|
|
||||||
return Ok(
|
|
||||||
new ResponseBody<object>
|
|
||||||
{
|
|
||||||
Message = "Protected endpoint accessed successfully",
|
|
||||||
Payload = new { userId, username },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,22 +22,20 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab
|
|||||||
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
|
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
|
||||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
|
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
|
||||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
|
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
|
||||||
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
|
|
||||||
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
|
|
||||||
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
|
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
|
||||||
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
|
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
|
||||||
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
|
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
|
||||||
|
COPY ["Features/Features.Emails.Templates/Features.Emails.Templates.csproj", "Features/Features.Emails.Templates/"]
|
||||||
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
|
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
|
||||||
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
|
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
|
||||||
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
|
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
|
||||||
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
|
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
|
||||||
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
|
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
|
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
|
COPY ["Configuration/Configuration.csproj", "Configuration/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
|
||||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
|
||||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||||
|
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
|
||||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||||
RUN dotnet restore "Biergarten.API.slnx"
|
RUN dotnet restore "Biergarten.API.slnx"
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ namespace API.Core;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
||||||
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
||||||
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
||||||
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public void OnException(ExceptionContext context)
|
public void OnException(ExceptionContext context)
|
||||||
{
|
{
|
||||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||||
|
|
||||||
|
|||||||
@@ -1,131 +1,86 @@
|
|||||||
using API.Core;
|
using System.Security.Claims;
|
||||||
using API.Core.Authentication;
|
using Shared.Contracts;
|
||||||
using Database.Connection.DependencyInjection;
|
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
||||||
using Features.Auth.Controllers;
|
|
||||||
using Features.Auth.DependencyInjection;
|
|
||||||
using Features.Breweries.Controllers;
|
|
||||||
using Features.Breweries.DependencyInjection;
|
|
||||||
using Features.Emails.DependencyInjection;
|
|
||||||
using Features.Emails.Services;
|
|
||||||
using Features.UserManagement.Controllers;
|
|
||||||
using Features.UserManagement.DependencyInjection;
|
|
||||||
using FluentValidation;
|
|
||||||
using FluentValidation.AspNetCore;
|
|
||||||
using Infrastructure.Jwt;
|
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using Shared.Application.Behaviors;
|
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
namespace API.Core;
|
||||||
|
|
||||||
// Add current controllers to the exception filter
|
internal class Program
|
||||||
builder
|
{
|
||||||
.Services.AddControllers(options =>
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
options.Filters.Add<GlobalExceptionFilter>();
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
})
|
|
||||||
.AddApplicationPart(typeof(BreweryController).Assembly)
|
|
||||||
.AddApplicationPart(typeof(UserController).Assembly)
|
|
||||||
.AddApplicationPart(typeof(AuthController).Assembly);
|
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Logging.ClearProviders().AddConsole();
|
||||||
builder.Services.AddSwaggerGen(options =>
|
|
||||||
{
|
if (!builder.Environment.IsProduction())
|
||||||
options.AddSecurityDefinition(
|
builder.Logging.AddDebug();
|
||||||
"Bearer",
|
|
||||||
new OpenApiSecurityScheme
|
builder
|
||||||
{
|
.Services.AddApiControllers()
|
||||||
Name = "Authorization",
|
.AddValidationAndMediatR()
|
||||||
Type = SecuritySchemeType.Http,
|
.AddApplicationServices()
|
||||||
Scheme = "Bearer",
|
.AddFeatureModules()
|
||||||
BearerFormat = "JWT",
|
.AddCoreInfrastructure()
|
||||||
In = ParameterLocation.Header,
|
.AddJwtAuthentication();
|
||||||
Description = "Enter a JWT access token.",
|
|
||||||
}
|
WebApplication app = builder.Build();
|
||||||
);
|
|
||||||
options.AddSecurityRequirement(
|
app.UseSwagger()
|
||||||
new OpenApiSecurityRequirement
|
.UseSwaggerUI()
|
||||||
{
|
.UseHttpsRedirection()
|
||||||
{
|
.UseAuthentication()
|
||||||
new OpenApiSecurityScheme
|
.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapOpenApi();
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
if (app.Environment.IsEnvironment("Testing"))
|
||||||
|
app.MapGet(
|
||||||
|
"/api/protected",
|
||||||
|
(ClaimsPrincipal user) =>
|
||||||
{
|
{
|
||||||
Reference = new OpenApiReference
|
string? userId = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
{
|
string? username = user.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
Type = ReferenceType.SecurityScheme,
|
|
||||||
Id = "Bearer",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
|
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
return Results.Ok(
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
|
new ResponseBody<object>
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
|
{
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
Message = "Protected endpoint accessed successfully",
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
Payload = new { userId, username },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
).RequireAuthorization(policy => policy.AddAuthenticationSchemes("JWT").RequireAuthenticatedUser());
|
||||||
|
|
||||||
// Add MediatR.
|
// Easter egg, as per tradition with the previous versions of the Biergarten App.
|
||||||
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
app.Map(
|
||||||
builder.Services.AddMediatR(cfg =>
|
"/teapot",
|
||||||
{
|
() =>
|
||||||
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
Results.Json(
|
||||||
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
|
new ResponseBody
|
||||||
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
|
{
|
||||||
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
|
Message =
|
||||||
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
"I'm a little teapot, short and stout. Here is my handle, here is my spout!",
|
||||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
},
|
||||||
});
|
statusCode: 418
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
app.MapFallback(() =>
|
||||||
|
Results.NotFound(
|
||||||
|
new ResponseBody { Message = "Are you lost? That route does not exist." }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
// Configure logging for container output
|
app.Services.GetRequiredService<IHostApplicationLifetime>()
|
||||||
builder.Logging.ClearProviders();
|
.ApplicationStopping.Register(() =>
|
||||||
builder.Logging.AddConsole();
|
{
|
||||||
if (!builder.Environment.IsProduction())
|
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||||
builder.Logging.AddDebug();
|
});
|
||||||
|
|
||||||
builder.Services.AddDatabaseConnection();
|
app.Run();
|
||||||
|
}
|
||||||
builder.Services.AddFeaturesBreweries();
|
}
|
||||||
builder.Services.AddFeaturesUserManagement();
|
|
||||||
builder.Services.AddFeaturesAuth();
|
|
||||||
builder.Services.AddFeaturesEmails();
|
|
||||||
|
|
||||||
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
|
||||||
|
|
||||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
|
||||||
|
|
||||||
builder
|
|
||||||
.Services.AddAuthentication("JWT")
|
|
||||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
|
||||||
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
app.MapOpenApi();
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
// Health check endpoint (used by Docker health checks and orchestrators)
|
|
||||||
app.MapHealthChecks("/health");
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
app.MapFallbackToController("Handle404", "NotFound");
|
|
||||||
|
|
||||||
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
|
||||||
lifetime.ApplicationStopping.Register(() =>
|
|
||||||
{
|
|
||||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
|
|||||||
121
web/backend/API/API.Core/ServiceCollectionExtensions.cs
Normal file
121
web/backend/API/API.Core/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using API.Core.Authentication;
|
||||||
|
using Database.Connection.DependencyInjection;
|
||||||
|
using Features.Breweries.Controllers;
|
||||||
|
using Features.Breweries.DependencyInjection;
|
||||||
|
using Features.Emails.DependencyInjection;
|
||||||
|
using Features.Emails.Services;
|
||||||
|
using Features.ImageUploads.Commands.UploadPhoto;
|
||||||
|
using Features.ImageUploads.DependencyInjection;
|
||||||
|
using Features.Locations.Controllers;
|
||||||
|
using Features.Locations.DependencyInjection;
|
||||||
|
using Features.Users.Controllers;
|
||||||
|
using Features.Users.DependencyInjection;
|
||||||
|
using FluentValidation;
|
||||||
|
using FluentValidation.AspNetCore;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Shared.Application.Behaviors;
|
||||||
|
|
||||||
|
namespace API.Core;
|
||||||
|
|
||||||
|
internal static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
extension(IServiceCollection services)
|
||||||
|
{
|
||||||
|
public IServiceCollection AddApiControllers() =>
|
||||||
|
services
|
||||||
|
.AddControllers(options => options.Filters.Add<GlobalExceptionFilter>())
|
||||||
|
.AddApplicationPart(typeof(BreweryController).Assembly)
|
||||||
|
.AddApplicationPart(typeof(AuthController).Assembly)
|
||||||
|
.AddApplicationPart(typeof(CityController).Assembly)
|
||||||
|
.Services;
|
||||||
|
|
||||||
|
public IServiceCollection AddValidationAndMediatR()
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddOpenApi()
|
||||||
|
.AddValidatorsFromAssemblyContaining<Program>()
|
||||||
|
.AddValidatorsFromAssembly(typeof(BreweryController).Assembly)
|
||||||
|
.AddValidatorsFromAssembly(typeof(AuthController).Assembly)
|
||||||
|
.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly)
|
||||||
|
.AddFluentValidationAutoValidation()
|
||||||
|
.AddMediatR(cfg =>
|
||||||
|
{
|
||||||
|
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
|
||||||
|
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
|
||||||
|
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IServiceCollection AddApplicationServices()
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddEndpointsApiExplorer()
|
||||||
|
.AddSwaggerGen(options =>
|
||||||
|
{
|
||||||
|
options.AddSecurityDefinition(
|
||||||
|
"Bearer",
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Name = "Authorization",
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "Bearer",
|
||||||
|
BearerFormat = "JWT",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Description = "Enter a JWT access token.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
options.AddSecurityRequirement(
|
||||||
|
new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference
|
||||||
|
{
|
||||||
|
Type = ReferenceType.SecurityScheme,
|
||||||
|
Id = "Bearer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHealthChecks();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IServiceCollection AddFeatureModules() =>
|
||||||
|
services
|
||||||
|
.AddDatabaseConnection()
|
||||||
|
.AddFeaturesBreweries()
|
||||||
|
.AddFeaturesUsers()
|
||||||
|
.AddFeaturesEmails()
|
||||||
|
.AddFeaturesPhotoUpload()
|
||||||
|
.AddFeaturesLocations();
|
||||||
|
|
||||||
|
public IServiceCollection AddCoreInfrastructure() =>
|
||||||
|
services.AddScoped<GlobalExceptionFilter>();
|
||||||
|
|
||||||
|
public IServiceCollection AddJwtAuthentication()
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddAuthentication("JWT")
|
||||||
|
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||||
|
"JWT",
|
||||||
|
options => { }
|
||||||
|
);
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -12,22 +12,20 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab
|
|||||||
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
|
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
|
||||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
|
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
|
||||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
|
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
|
||||||
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
|
|
||||||
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
|
|
||||||
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
|
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
|
||||||
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
|
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
|
||||||
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
|
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
|
||||||
|
COPY ["Features/Features.Emails.Templates/Features.Emails.Templates.csproj", "Features/Features.Emails.Templates/"]
|
||||||
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
|
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
|
||||||
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
|
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
|
||||||
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
|
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
|
||||||
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
|
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
|
||||||
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
|
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
|
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
|
COPY ["Configuration/Configuration.csproj", "Configuration/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
|
||||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
|
||||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||||
|
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
|
||||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||||
RUN dotnet restore "Biergarten.API.slnx"
|
RUN dotnet restore "Biergarten.API.slnx"
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
95
web/backend/API/API.Specs/Features/Breweries.feature
Normal file
95
web/backend/API/API.Specs/Features/Breweries.feature
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
Feature: Brewery Management
|
||||||
|
As an authenticated user
|
||||||
|
I want to create, view, update and delete brewery posts
|
||||||
|
So that I can share information about breweries with the community
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the API is running
|
||||||
|
|
||||||
|
Scenario: Successful brewery creation
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
When I create a brewery with values:
|
||||||
|
| BreweryName | Description | AddressLine1 | PostalCode |
|
||||||
|
| Roundhouse Brewery | A cozy local brewery | 123 Main St | 90210 |
|
||||||
|
Then the response has HTTP status 201
|
||||||
|
And the response JSON should have "breweryName" equal "Roundhouse Brewery"
|
||||||
|
|
||||||
|
Scenario: Brewery creation fails without authentication
|
||||||
|
Given a city exists
|
||||||
|
When I create a brewery with values:
|
||||||
|
| BreweryName | Description | AddressLine1 | PostalCode |
|
||||||
|
| No Auth Brew | A brewery | 1 Nowhere St | 00000 |
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
|
Scenario: Brewery creation fails with a non-existent city
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I create a brewery with a non-existent city
|
||||||
|
Then the response has HTTP status 404
|
||||||
|
|
||||||
|
Scenario: Retrieve an existing brewery by ID
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
When I retrieve the brewery by ID
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "breweryName" equal "Test Brewery"
|
||||||
|
|
||||||
|
Scenario: Retrieving a non-existent brewery returns not found
|
||||||
|
When I retrieve a brewery by a non-existent ID
|
||||||
|
Then the response has HTTP status 404
|
||||||
|
|
||||||
|
Scenario: Owner can update their brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
When I update the brewery with values:
|
||||||
|
| BreweryName | Description |
|
||||||
|
| Renamed Brewery | An updated description |
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "breweryName" equal "Renamed Brewery"
|
||||||
|
|
||||||
|
Scenario: A different user cannot update someone else's brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
And I am logged in as a different user
|
||||||
|
When I update the brewery with values:
|
||||||
|
| BreweryName | Description |
|
||||||
|
| Hijacked | Nope |
|
||||||
|
Then the response has HTTP status 403
|
||||||
|
|
||||||
|
Scenario: Updating a non-existent brewery returns not found
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I update a non-existent brewery
|
||||||
|
Then the response has HTTP status 404
|
||||||
|
|
||||||
|
Scenario: Owner can delete their brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
When I delete the brewery
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And retrieving the brewery by ID should now return HTTP status 404
|
||||||
|
|
||||||
|
Scenario: A different user cannot delete someone else's brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
And I am logged in as a different user
|
||||||
|
When I delete the brewery
|
||||||
|
Then the response has HTTP status 403
|
||||||
|
|
||||||
|
Scenario: Deleting a non-existent brewery returns not found
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I delete a non-existent brewery
|
||||||
|
Then the response has HTTP status 404
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user