Compare commits

27 Commits

Author SHA1 Message Date
Aaron Po
b31be494d7 Update documentation 2026-04-08 22:24:23 -04:00
Aaron Po
7807f0bc2a Add beer styles json 2026-04-08 21:26:35 -04:00
Aaron Po
772ef0cdfb Update CMakeLists.txt 2026-04-08 21:25:11 -04:00
Aaron Po
a6e2ea21d0 fix include 2026-04-08 21:24:59 -04:00
Aaron Po
a7cbf7507f fix location.h 2026-04-08 21:07:28 -04:00
Aaron Po
3c7e74e3c1 update readme 2026-04-08 11:27:37 -04:00
Aaron Po
b1ac3a6068 fix: remove outdated data source information from help message 2026-04-07 18:02:21 -04:00
Aaron Po
06d329cac5 refactor 2026-04-07 17:55:15 -04:00
Aaron Po
54c403526b fix: improve error handling and logging in data generation pipeline 2026-04-07 13:36:59 -04:00
Aaron Po
b8e96a6d45 replace SQLite geo pipeline with curated in-memory locations 2026-04-07 02:28:15 -04:00
Aaron Po
60ee2ecf74 add prompts 2026-04-03 15:53:04 -04:00
Aaron Po
e4e16a5084 fix: address critical correctness, reliability, and design issues in pipeline
CORRECTNESS FIXES:
- json_loader: Add RollbackTransaction() and call it on exception instead of
  CommitTransaction(). Prevents partial data corruption on parse/disk errors.
- wikipedia_service: Fix invalid MediaWiki API parameter explaintext=true ->
  explaintext=1. Now returns plain text instead of HTML markup in contexts.
- helpers: Fix ParseTwoLineResponse filter to only remove known thinking tags
  (<think>, <reasoning>, <reflect>) instead of any <...> pattern. Prevents
  silently removing legitimate output like <username>content</username>.

RELIABILITY & DESIGN IMPROVEMENTS:
- load/main: Make n_ctx (context window size) configurable via --n-ctx flag
  (default 2048, range 1-32768) to support larger models like Qwen3-14B.
- generate_brewery: Prevent retry prompt growth by extracting location context
  into constant and using compact retry format (error + schema + location only).
  Avoids token truncation on final retry attempts.
- database: Fix data representativeness by changing QueryCities from
  ORDER BY name (alphabetic bias) to ORDER BY RANDOM() for unbiased sampling.
  Convert all SQLITE_STATIC to SQLITE_TRANSIENT to prevent use-after-free risks.

POLISH:
- infer: Advance sampling seed between generation calls to improve diversity
  across brewery and user generation.
- data_downloader: Remove unnecessary commit hash truncation; use full hash.
- json_loader: Fix misleading log message from "RapidJSON" to "Boost.JSON".
2026-04-03 11:58:00 -04:00
Aaron Po
8d306bf691 Update documentation for llama 2026-04-02 23:24:06 -04:00
Aaron Po
077f6ab4ae edit prompt 2026-04-02 22:56:18 -04:00
Aaron Po
534403734a Refactor BiergartenDataGenerator and LlamaGenerator 2026-04-02 22:46:00 -04:00
Aaron Po
3af053f0eb format codebase 2026-04-02 21:46:46 -04:00
Aaron Po
ba165d8aa7 Separate llama generator class src file into method files 2026-04-02 21:37:46 -04:00
Aaron Po
eb9a2767b4 Refactor web client interface and related components 2026-04-02 18:55:58 -04:00
Aaron Po
29ea47fdb6 update cli arg handling 2026-04-02 18:41:25 -04:00
Aaron Po
52e2333304 Reorganize directory structure 2026-04-02 18:27:01 -04:00
Aaron Po
a1f0ca5b20 Refactor DataDownloader and CURLWebClient: update constructor and modify FileExists method signature 2026-04-02 18:06:40 -04:00
Aaron Po
2ea8aa52b4 update readme and add clangformat and clang tidy 2026-04-02 17:12:22 -04:00
Aaron Po
98083ab40c Pipeline: add CURL/WebClient & Wikipedia service
Introduce a pluggable web client interface and concrete CURL implementation: adds IWebClient, CURLWebClient, and CurlGlobalState (headers + curl_web_client.cpp). DataDownloader now accepts an IWebClient and delegates downloads. Add WikipediaService for cached Wikipedia summary lookups. Refactor SqliteDatabase to return full City records and update consumers accordingly. Improve JsonLoader to use batched transactions during streaming parses. Enhance LlamaGenerator with sampling options, increased token limits, JSON extraction/validation, and other parsing helpers. Modernize CMake: set policy/version, add project_options, simplify FetchContent usage (spdlog), require Boost components (program_options/json), list pipeline sources explicitly, and tweak post-build/memcheck targets. Update README to match implementation changes and new CLI/config conventions.
2026-04-02 16:29:16 -04:00
Aaron Po
ac136f7179 Enhance brewery generation: add country name parameter and improve prompt handling 2026-04-02 01:04:41 -04:00
Aaron Po
280c9c61bd Implement Llama-based brewery and user data generation; remove mock generator and related files 2026-04-01 23:29:16 -04:00
Aaron Po
248a51b35f cleanup 2026-04-01 21:35:02 -04:00
Aaron Po
35aa7bc0df Begin work on biergarten data generator pipeline 2026-04-01 21:18:45 -04:00
1229 changed files with 192637 additions and 93142 deletions

View File

@@ -1,52 +0,0 @@
---
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`.

View File

@@ -1,55 +0,0 @@
---
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`.

13
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.2.1",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}

View File

@@ -1,5 +1,7 @@
{
"printWidth": 100,
"$schema": "https://json.schemastore.org/csharpier.json",
"printWidth": 80,
"useTabs": false,
"indentSize": 4,
"endOfLine": "lf",

View File

@@ -39,14 +39,6 @@ ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-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
@@ -57,23 +49,8 @@ SESSION_SECRET=your-secure-session-secret-key
# provider (e.g., SendGrid, Mailgun, Amazon SES).
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=ayerble
SMTP_PASSWORD=checkers
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
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

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
archive/** linguist-vendored

View File

@@ -7,7 +7,6 @@ assignees: []
---
## User Story
**As a** (who wants to accomplish something)
**I want to** (what they want to accomplish)
**So that** (why they want to accomplish that thing)
@@ -16,18 +15,29 @@ assignees: []
### Scenario 1
Given ... When ... Then ...
Given ...
When ...
Then ...
### Scenario 2
Given ... When ... Then ...
Given ...
When ...
Then ...
### Scenario 3
Given ... When ... Then ...
Given ...
When ...
Then ...
## Subtasks
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3

View File

@@ -1,101 +0,0 @@
## 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.

View File

@@ -1,51 +0,0 @@
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

View File

@@ -1,61 +0,0 @@
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

18
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
scratchpad.txt
# dependencies
/node_modules
/.pnp
@@ -16,13 +16,12 @@ scratchpad.txt
/build
# project-specific build artifacts
/web/frontend/build/
/web/frontend/storybook-static/
/web/frontend/.react-router/
/web/frontend/playwright-report/
/web/frontend/test-results/
/src/Website/build/
/src/Website/storybook-static/
/src/Website/.react-router/
/src/Website/playwright-report/
/src/Website/test-results/
/test-results/
/docs/rendered-diagrams/
# misc
.DS_Store
@@ -502,8 +501,3 @@ FodyWeavers.xsd
*storybook.log
storybook-static
web/test-results/frontend/vitest-storybook.xml
web/test-results/frontend/playwright-storybook.xml
# mkdocs build output
/site

File diff suppressed because it is too large Load Diff

203
README.md
View File

@@ -1,182 +1,159 @@
# The Biergarten App
The Biergarten App is a full-stack directory and discovery platform for
breweries. It provides:
- JWT-based user authentication, with email confirmation and refresh-token
reissue
- A searchable database of brewery locations
- An offline data-generation pipeline that uses large language models (LLMs)
via Llama.cpp and Wikipedia to synthesize realistic seed data
The repository is organized into:
- A .NET backend (Web API + database migrations/seed) under `web/backend/`
- A server-rendered React website (React Router + Vite) under `web/frontend/`
- A C++20 data-generation CLI under `tooling/pipeline/`
Specialized documentation (setup, architecture, docker, testing, diagrams, and
pipeline notes) lives under `docs/`.
The Biergarten App is a multi-project monorepo with a .NET backend and an active React
Router frontend in `src/Website`. The current website focuses on account flows, theme
switching, shared UI components, Storybook coverage, and integration with the API.
## Documentation
Website + backend (active stack):
- [Getting Started](docs/getting-started.md) - Local setup for backend and active website
- [Architecture](docs/architecture.md) - Current backend and frontend architecture
- [Docker Guide](docs/docker.md) - Container-based backend development and testing
- [Testing](docs/testing.md) - Backend and frontend test commands
- [Environment Variables](docs/environment-variables.md) - Active configuration reference
- [Token Validation](docs/token-validation.md) - JWT validation architecture
- [Legacy Website Archive](docs/archive/legacy-website-v1.md) - Archived notes for the old Next.js frontend
- [Getting Started](docs/web/getting-started.md)
- [Architecture](docs/web/architecture.md)
- [Database](docs/web/database.md)
- [Docker Guide](docs/web/docker.md)
- [Testing](docs/web/testing.md)
- [Environment Variables](docs/web/environment-variables.md)
- [Token Validation](docs/web/token-validation.md)
## Diagrams
Data generation pipeline (C++):
- [Architecture](docs/diagrams-out/architecture.svg) - Layered architecture
- [Deployment](docs/diagrams-out/deployment.svg) - Docker topology
- [Authentication Flow](docs/diagrams-out/authentication-flow.svg) - Auth sequence
- [Database Schema](docs/diagrams-out/database-schema.svg) - Entity relationships
- [Pipeline README](docs/pipeline/README.md)
- [Ethics & Known Issues](docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md)
## Current status
## Current Status
Active areas in the repository:
- .NET 10 backend (vertical-slice architecture with MediatR) + SQL Server
- React 19 website (React Router 8 + Vite)
- Shared Biergarten theme system + Storybook coverage
- Auth flows and account/email integration (local Mailpit in dev compose)
- Photo upload to S3-compatible object storage (local SeaweedFS in dev compose)
- Data generation pipeline with C++ and Llama.cpp
- .NET 10 backend with layered architecture and SQL Server
- React Router 7 website in `src/Website`
- Shared Biergarten theme system with a theme guide route
- Storybook stories and browser-based checks for shared UI
- Auth demo flows for home, login, register, dashboard, logout, and confirmation
- Toast-based feedback for auth outcomes
Archived/reference areas:
Legacy area retained for reference:
- `archive/next-js-web-app/` contains an older Next.js frontend retained for
reference
- `src/Website-v1` contains the archived Next.js frontend and is no longer the active website
## Tech stack
## Tech Stack
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
- **Frontend**: React 19, React Router 8, Vite 7, Tailwind CSS 4, DaisyUI 5,
Leaflet/React Leaflet (brewery maps)
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
- **Infrastructure**: Docker, Docker Compose
- **Security**: Argon2id password hashing, JSON Web Tokens (JWT) for
access/refresh/confirmation
- **Data Pipeline**: C++20, CMake, Boost, libcurl, SQLite, llama.cpp
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation tokens
## Requirements
## Quick Start
- .NET SDK 10 or later
- Node.js 22 or later
- Docker Desktop, or an equivalent Docker Engine setup
See [Getting Started](docs/web/getting-started.md) for the full
prerequisite list, including the pipeline's C++ toolchain.
## Quick start
For full setup details, use [Getting Started](docs/web/getting-started.md).
This section is the shortest path to a working dev environment.
### Full stack (Docker)
### Backend
```bash
git clone https://github.com/aaronpo97/the-biergarten-app
cd the-biergarten-app
cd web && ./generate-env.sh .env.dev && cd ..
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up --build -d
cp .env.example .env.dev
docker compose -f docker-compose.dev.yaml up -d
```
`generate-env.sh` copies `.env.example` and replaces the secret-bearing values
with freshly randomized ones; a plain `cp` leaves placeholder secrets in
place. See [Environment Variables](docs/web/environment-variables.md#generating-an-env-file)
for details.
Backend access:
Access:
- Website: http://localhost:3000
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI (dev SMTP): http://localhost:8025
- SeaweedFS S3 API (dev object storage): http://localhost:8333
### Frontend (Node, outside Docker)
### Frontend
```bash
cd web/frontend
cd src/Website
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret npm run dev
```
Optional frontend tools:
```bash
cd web/frontend
cd src/Website
npm run storybook
npm run test:storybook
npm run test:storybook:playwright
```
## Repository structure
## Repository Structure
```text
web/
backend/ .NET API: API.Core (host), Features.* (vertical slices),
Shared.*, Domain.*, Infrastructure.*, Database.* projects
frontend/ React Router website + Storybook + Playwright/Vitest
tooling/
pipeline/ C++20 seed-data generation CLI (CMake)
docs/
web/ Architecture, setup, docker, testing, diagrams
pipeline/ Pipeline docs, ethics notes, diagrams
archive/
next-js-web-app/ Older Next.js frontend (reference only)
src/Core/ Backend projects (.NET)
src/Website/ Active React Router frontend
src/Website-v1/ Archived legacy Next.js frontend
docs/ Active project documentation
docs/archive/ Archived legacy documentation
```
## Key Features
Implemented today:
- User registration and login against the API
- JWT-based auth with access, refresh, and confirmation flows
- SQL Server migrations and seed projects
- Shared form components and auth screens
- Theme switching with Lager, Stout, Cassis, and Weizen variants
- Storybook documentation and automated story interaction tests
- Toast feedback for auth-related outcomes
Planned next:
- Brewery discovery and management
- Beer reviews and ratings
- Social follow relationships
- Geospatial brewery experiences
- Additional frontend routes beyond the auth demo
## Testing
Create a test environment file, then run the full test stack (backend and
frontend) with Docker:
Backend suites:
- `API.Specs` - integration tests
- `Infrastructure.Repository.Tests` - repository unit tests
- `Service.Auth.Tests` - service unit tests
Frontend suites:
- Storybook interaction tests via Vitest
- Storybook browser regression checks via Playwright
Run all backend tests with Docker:
```bash
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 -f docker-compose.test.yaml up --abort-on-container-exit
```
The same command runs in CI via
[`.github/workflows/tests.yml`](.github/workflows/tests.yml) on every push
and pull request to `main`. See [Testing](docs/web/testing.md) for the
full command list.
See [Testing](docs/testing.md) for the full command list.
## Configuration
Common active variables:
- Backend/Docker: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`,
`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`,
`WEBSITE_BASE_URL`
- Frontend runtime: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
- Backend: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`
- Frontend: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
See [Environment Variables](docs/web/environment-variables.md) for details.
See [Environment Variables](docs/environment-variables.md) for details.
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Run the checks in [Testing](docs/web/testing.md) (backend tests,
`npm run lint`, `npm run typecheck`)
5. Push to the branch (`git push origin feature/amazing-feature`)
6. Open a Pull Request
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Workflow
1. Start development environment: `docker compose -f docker-compose.dev.yaml up -d`
2. Make changes to code
3. Run tests: `docker compose -f docker-compose.test.yaml up --abort-on-container-exit`
4. Rebuild if needed: `docker compose -f docker-compose.dev.yaml up -d --build api.core`
## Support
Report bugs and request features via
[GitHub Issues](https://github.com/aaronpo97/the-biergarten-app/issues).
## License
GPL-3.0. See [LICENSE.md](LICENSE.md).
- **Documentation**: [docs/](docs/)
- **Architecture**: See [Architecture Guide](docs/architecture.md)

View File

@@ -1,31 +0,0 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
DECLARE
@Inserted TABLE
(
UserAccountID UNIQUEIDENTIFIER
);
INSERT INTO UserAccount
(Username,
FirstName,
LastName,
DateOfBirth,
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
RETURN
END
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -1,18 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -1,38 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City
(StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION
END;

View File

@@ -1,32 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -1,59 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@CityID UNIQUEIDENTIFIER,
@AddressLine1 NVARCHAR(256),
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID;
END

View File

@@ -1,17 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET
NOCOUNT ON;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.Timer,
bpl.BreweryPostLocationID,
bpl.CityID,
bpl.AddressLine1,
bpl.AddressLine2,
bpl.PostalCode,
bpl.Coordinates
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -1,9 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *
FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID;
END

View File

@@ -1,77 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -1,5 +1,5 @@
services:
sqlserver:
sqlserver:
env_file: ".env.dev"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
@@ -9,22 +9,18 @@ services:
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
ports:
- "1434:1433"
- "1433:1433"
volumes:
- sqlserverdata-dev:/var/opt/mssql
healthcheck:
test:
[
"CMD-SHELL",
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
]
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- devnet
database.migrations:
database.migrations:
env_file: ".env.dev"
image: database.migrations
container_name: dev-env-database-migrations
@@ -32,8 +28,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
@@ -48,17 +44,15 @@ services:
networks:
- devnet
database.seed:
database.seed:
env_file: ".env.dev"
image: database.seed
container_name: dev-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
@@ -69,41 +63,14 @@ services:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: "no"
networks:
- devnet
seaweedfs:
env_file: ".env.dev"
image: chrislusf/seaweedfs
container_name: dev-env-seaweedfs
ports:
- "8333:8333" # S3 API
- "8888:8888" # Filer UI
- "9333:9333" # Master UI
- "23646:23646" # Admin UI
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-dev:/data
restart: unless-stopped
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
seaweedfsdata-dev:
driver: local
networks:
devnet:

View File

@@ -1,5 +1,4 @@
services:
sqlserver:
env_file: ".env.dev"
image: mcr.microsoft.com/mssql/server:2022-latest
@@ -10,22 +9,17 @@ services:
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
ports:
- "1434:1433"
- "1433:1433"
volumes:
- sqlserverdata-dev:/var/opt/mssql
healthcheck:
test:
[
"CMD-SHELL",
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
]
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- devnet
database.migrations:
env_file: ".env.dev"
image: database.migrations
@@ -34,8 +28,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
@@ -57,10 +51,8 @@ services:
depends_on:
database.migrations:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
@@ -71,11 +63,6 @@ services:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: "no"
networks:
- devnet
@@ -87,10 +74,8 @@ services:
depends_on:
database.seed:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
context: ./src/Core
dockerfile: API/API.Core/Dockerfile
args:
BUILD_CONFIGURATION: Release
@@ -110,77 +95,30 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: unless-stopped
networks:
- devnet
volumes:
- nuget-cache-dev:/root/.nuget/packages
frontend:
env_file: ".env.dev"
image: frontend
container_name: dev-env-frontend
depends_on:
api.core:
condition: service_started
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: "development"
PORT: "3000"
API_BASE_URL: "http://api.core:8080"
SESSION_SECRET: "${SESSION_SECRET}"
restart: unless-stopped
networks:
- devnet
seaweedfs:
env_file: ".env.dev"
image: chrislusf/seaweedfs
container_name: dev-env-seaweedfs
ports:
- "8333:8333" # S3 API
- "8888:8888" # Filer UI
- "9333:9333" # Master UI
- "23646:23646" # Admin UI
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-dev:/data
restart: unless-stopped
networks:
- devnet
mailpit:
image: axllent/mailpit:latest
container_name: dev-env-mailpit
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP server
restart: unless-stopped
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
seaweedfsdata-dev:
driver: local
networks:
devnet:

View File

@@ -13,11 +13,7 @@ services:
volumes:
- sqlserverdata-dev:/var/opt/mssql
healthcheck:
test:
[
"CMD-SHELL",
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
]
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
interval: 10s
timeout: 5s
retries: 12
@@ -32,8 +28,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
@@ -69,10 +65,8 @@ services:
depends_on:
database.migrations:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
@@ -83,41 +77,14 @@ services:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: "no"
networks:
- devnet
seaweedfs:
env_file: ".env.local"
image: chrislusf/seaweedfs
container_name: dev-env-seaweedfs
ports:
- "8333:8333" # S3 API
- "8888:8888" # Filer UI
- "9333:9333" # Master UI
- "23646:23646" # Admin UI
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-dev:/data
restart: unless-stopped
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
seaweedfsdata-dev:
driver: local
networks:
devnet:

87
docker-compose.prod.yaml Normal file
View File

@@ -0,0 +1,87 @@
services:
sqlserver:
env_file: ".env.prod"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
container_name: prod-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
volumes:
- sqlserverdata-prod:/var/opt/mssql
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- prodnet
database.migrations:
env_file: ".env.prod"
image: database.migrations
container_name: prod-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- prodnet
api.core:
env_file: ".env.prod"
image: api.core
container_name: prod-env-api-core
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core
dockerfile: API/API.Core/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
ports:
- "8080:8080"
- "8081:8081"
environment:
ASPNETCORE_ENVIRONMENT: "Production"
ASPNETCORE_URLS: "http://0.0.0.0:8080"
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}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped
networks:
- prodnet
volumes:
- nuget-cache-prod:/root/.nuget/packages
volumes:
sqlserverdata-prod:
driver: local
nuget-cache-prod:
driver: local
networks:
prodnet:
driver: bridge

View File

@@ -12,11 +12,7 @@ services:
volumes:
- sqlserverdata-test:/var/opt/mssql
healthcheck:
test:
[
"CMD-SHELL",
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
]
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
interval: 10s
timeout: 5s
retries: 12
@@ -32,8 +28,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
@@ -55,10 +51,8 @@ services:
depends_on:
database.migrations:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
@@ -69,29 +63,10 @@ services:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: "no"
networks:
- testnet
seaweedfs:
env_file: ".env.test"
image: chrislusf/seaweedfs
container_name: test-env-seaweedfs
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-test:/data
restart: unless-stopped
networks:
- testnet
api.specs:
env_file: ".env.test"
image: api.specs
@@ -100,7 +75,7 @@ services:
database.seed:
condition: service_completed_successfully
build:
context: ./backend
context: ./src/Core
dockerfile: API/API.Specs/Dockerfile
args:
BUILD_CONFIGURATION: Release
@@ -120,16 +95,16 @@ services:
networks:
- testnet
unit.tests:
repository.tests:
env_file: ".env.test"
image: unit.tests
container_name: test-env-unit-tests
image: repository.tests
container_name: test-env-repository-tests
depends_on:
database.seed:
condition: service_completed_successfully
build:
context: ./backend
dockerfile: Dockerfile.tests
context: ./src/Core
dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:
@@ -140,12 +115,20 @@ services:
networks:
- testnet
frontend.tests:
image: frontend.tests
container_name: test-env-frontend-tests
service.auth.tests:
env_file: ".env.test"
image: service.auth.tests
container_name: test-env-service-auth-tests
depends_on:
database.seed:
condition: service_completed_successfully
build:
context: ./frontend
dockerfile: Dockerfile.tests
context: ./src/Core
dockerfile: Service/Service.Auth.Tests/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
volumes:
- ./test-results:/app/test-results
restart: "no"
@@ -155,8 +138,6 @@ services:
volumes:
sqlserverdata-test:
driver: local
seaweedfsdata-test:
driver: local
networks:
testnet:

427
docs/architecture.md Normal file
View File

@@ -0,0 +1,427 @@
# 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 and a layered architecture
- **Frontend**: React 19 + React Router 7 website in `src/Website`
- **Architecture Style**: Layered backend plus server-rendered React frontend
The legacy Next.js frontend has been retained in `src/Website-v1` for reference only and is
documented in [archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Diagrams
For visual representations, see:
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture diagram
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) - Authentication workflow
- [database-schema.svg](diagrams-out/database-schema.svg) - Database relationships
## Backend Architecture
### Layered Architecture Pattern
The backend follows a strict layered architecture:
```
┌─────────────────────────────────────┐
│ API Layer (Controllers) │
│ - HTTP Endpoints │
│ - Request/Response mapping │
│ - Swagger/OpenAPI │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Service Layer (Business Logic) │
│ - Authentication logic │
│ - User management │
│ - Validation & orchestration │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Infrastructure Layer (Tools) │
│ - JWT token generation │
│ - Password hashing (Argon2id) │
│ - Email services │
│ - Repository implementations │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Domain Layer (Entities) │
│ - UserAccount, UserCredential │
│ - Pure POCO classes │
│ - No external dependencies │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Database (SQL Server) │
│ - Stored procedures │
│ - Tables & constraints │
└─────────────────────────────────────┘
```
### Layer Responsibilities
#### API Layer (`API.Core`)
**Purpose**: HTTP interface and request handling
**Components**:
- Controllers (`AuthController`, `UserController`)
- Middleware for error handling
- Swagger/OpenAPI documentation
- Health check endpoints
**Dependencies**:
- Service layer
- ASP.NET Core framework
**Rules**:
- No business logic
- Only request/response transformation
- Delegates all work to Service layer
#### Service Layer (`Service.Auth`, `Service.UserManagement`)
**Purpose**: Business logic and orchestration
**Components**:
- Authentication services (login, registration)
- User management services
- Business rule validation
- Transaction coordination
**Dependencies**:
- Infrastructure layer (repositories, JWT, password hashing)
- Domain entities
**Rules**:
- Contains all business logic
- Coordinates multiple infrastructure components
- No direct database access (uses repositories)
- Returns domain models, not DTOs
#### Infrastructure Layer
**Purpose**: Technical capabilities and external integrations
**Components**:
- **Infrastructure.Repository**: Data access via stored procedures
- **Infrastructure.Jwt**: JWT token generation and validation
- **Infrastructure.PasswordHashing**: Argon2id password hashing
- **Infrastructure.Email**: Email sending capabilities
- **Infrastructure.Email.Templates**: Email template rendering
**Dependencies**:
- Domain entities
- External libraries (ADO.NET, JWT, Argon2, etc.)
**Rules**:
- Implements technical concerns
- No business logic
- Reusable across services
#### Domain Layer (`Domain.Entities`)
**Purpose**: Core business entities and models
**Components**:
- `UserAccount` - User profile data
- `UserCredential` - Authentication credentials
- `UserVerification` - Account verification state
**Dependencies**:
- None (pure domain)
**Rules**:
- Plain Old CLR Objects (POCOs)
- No framework dependencies
- No infrastructure references
- Represents business concepts
### Design Patterns
#### Repository Pattern
**Purpose**: Abstract database access behind interfaces
**Implementation**:
- `IAuthRepository` - Authentication queries
- `IUserAccountRepository` - User account queries
- `DefaultSqlConnectionFactory` - Connection management
**Benefits**:
- Testable (easy to mock)
- SQL-first approach (stored procedures)
- Centralized data access logic
**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` registers all services
**Lifetimes**:
- Scoped: Repositories, Services (per request)
- Singleton: Connection factories, JWT configuration
- Transient: Utilities, helpers
#### SQL-First Approach
**Purpose**: Leverage database capabilities
**Strategy**:
- All queries via stored procedures
- No ORM (Entity Framework not used)
- Database handles complex logic
- Application focuses on orchestration
**Stored Procedure Examples**:
- `USP_RegisterUser` - User registration
- `USP_GetUserAccountByUsername` - User lookup
- `USP_RotateUserCredential` - Password update
## Frontend Architecture
### Active Website (`src/Website`)
The current website is a React Router 7 application with server-side rendering enabled.
```text
src/Website/
├── app/
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
│ ├── lib/ Auth helpers, schemas, and theme metadata
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
│ ├── 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 the auth demo and theme guide routes
- Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, and confirmation
- 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 `src/Website-v1`. Active product and
engineering documentation should point to `src/Website`, while legacy notes live in
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Security Architecture
### Authentication Flow
1. **Registration**:
- User submits credentials
- Password hashed with Argon2id
- User account created
- JWT token issued
2. **Login**:
- User submits credentials
- Password verified against hash
- JWT token issued
- Token stored client-side
3. **API Requests**:
- Client sends JWT in Authorization header
- Middleware validates 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
### SQL-First Philosophy
**Principles**:
1. Database is source of truth
2. Complex queries in stored procedures
3. Database handles referential integrity
4. Application orchestrates, database executes
**Benefits**:
- Performance optimization via execution plans
- Centralized query logic
- Version-controlled schema (migrations)
- Easier query profiling and tuning
### Migration Strategy
**Tool**: DbUp
**Process**:
1. Write SQL migration script
2. Embed in `Database.Migrations` project
3. Run migrations on startup
4. Idempotent and versioned
**Migration Files**:
```
scripts/
├── 001-CreateUserTables.sql
├── 002-CreateLocationTables.sql
├── 003-CreateBreweryTables.sql
└── ...
```
### Data Seeding
**Purpose**: Populate development/test databases
**Implementation**: `Database.Seed` project
**Seed Data**:
- Countries, states/provinces, cities
- Test user accounts
- Sample breweries (future)
## 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
**Environments**:
- Development (`docker-compose.dev.yaml`)
- Testing (`docker-compose.test.yaml`)
- Production (`docker-compose.prod.yaml`)
For details, see [Docker Guide](docker.md).
### Health Checks
**SQL Server**: Validates database connectivity **API**: Checks service health and
dependencies
**Configuration**:
```yaml
healthcheck:
test: ["CMD-SHELL", "sqlcmd health check"]
interval: 10s
retries: 12
start_period: 30s
```
## Testing Architecture
### Test Pyramid
```
┌──────────────┐
│ Integration │ ← API.Specs (Reqnroll)
│ Tests │
├──────────────┤
│ Unit Tests │ ← Service.Auth.Tests
│ (Service) │ Repository.Tests
├──────────────┤
│ Unit Tests │
│ (Repository) │
└──────────────┘
```
**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).

View File

@@ -0,0 +1,56 @@
# Legacy Website Archive (`src/Website-v1`)
This archive captures high-level notes about the previous Biergarten frontend so active
project documentation can focus on the current website in `src/Website`.
## Status
- `src/Website-v1` is retained for historical reference only
- It is not the active frontend used by current setup, docs, or testing guidance
- New product and engineering work should target `src/Website`
## Legacy Stack Summary
The archived frontend used a different application model from the current website:
- Next.js 14
- React 18
- Prisma
- Postgres / Neon-hosted database workflows
- Next.js API routes and server-side controllers
- Additional third-party integrations such as Cloudinary, Mapbox, and SparkPost
## Why It Was Archived
The active website moved to a React Router-based frontend that talks directly to the .NET
API. As part of that shift, the main docs were updated to describe:
- `src/Website` as the active frontend
- React Router route modules and server rendering
- Storybook-based component documentation and tests
- Current frontend runtime variables: `API_BASE_URL`, `SESSION_SECRET`, and `NODE_ENV`
## Legacy Documentation Topics Moved Out of Active Docs
The following categories were removed from active documentation and intentionally archived:
- Next.js application structure guidance
- Prisma and Postgres frontend setup
- Legacy frontend environment variables
- External service setup that only applied to `src/Website-v1`
- Old frontend local setup instructions
## When To Use This Archive
Use this file only if you need to:
- inspect the historical frontend implementation
- compare old flows against the current website
- migrate or recover legacy logic from `src/Website-v1`
For all active work, use:
- [Getting Started](../getting-started.md)
- [Architecture](../architecture.md)
- [Environment Variables](../environment-variables.md)
- [Testing](../testing.md)

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

View File

@@ -0,0 +1,75 @@
@startuml architecture
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam packageStyle rectangle
title The Biergarten App - Layered Architecture
package "API Layer" #E3F2FD {
[API.Core\nASP.NET Core Web API] as API
note right of API
- Controllers (Auth, User)
- Swagger/OpenAPI
- Middleware
- Health Checks
end note
}
package "Service Layer" #F3E5F5 {
[Service.Auth] as AuthSvc
[Service.UserManagement] as UserSvc
note right of AuthSvc
- Business Logic
- Validation
- Orchestration
end note
}
package "Infrastructure Layer" #FFF3E0 {
[Infrastructure.Repository] as Repo
[Infrastructure.Jwt] as JWT
[Infrastructure.PasswordHashing] as PwdHash
[Infrastructure.Email] as Email
}
package "Domain Layer" #E8F5E9 {
[Domain.Entities] as Domain
note right of Domain
- UserAccount
- UserCredential
- UserVerification
end note
}
database "SQL Server" {
[Stored Procedures] as SP
[Tables] as Tables
}
' Relationships
API --> AuthSvc
API --> UserSvc
AuthSvc --> Repo
AuthSvc --> JWT
AuthSvc --> PwdHash
AuthSvc --> Email
UserSvc --> Repo
Repo --> SP
Repo --> Domain
SP --> Tables
AuthSvc --> Domain
UserSvc --> Domain
' Notes
note left of Repo
SQL-first approach
All queries via
stored procedures
end note
@enduml

View File

@@ -0,0 +1,298 @@
@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
box "Service Layer" #LightBlue
participant "RegisterService" as RegSvc
participant "LoginService" as LoginSvc
participant "TokenService" as TokenSvc
participant "EmailService" as EmailSvc
end box
box "Infrastructure Layer" #LightGreen
participant "Argon2\nInfrastructure" as Argon2
participant "JWT\nInfrastructure" as JWT
participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template
end box
box "Repository Layer" #LightYellow
participant "AuthRepository" as AuthRepo
participant "UserAccount\nRepository" as UserRepo
end box
database "SQL Server\nStored Procedures" as DB
== Registration Flow ==
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
activate API
note right of API
FluentValidation runs:
- 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
API -> API: Validate request\n(FluentValidation)
alt Validation fails
API -> User: 400 Bad Request\n{errors: {...}}
else Validation succeeds
API -> RegSvc: RegisterAsync(userAccount, password)
activate RegSvc
RegSvc -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
activate DB
DB --> AuthRepo: null (user doesn't exist)
deactivate DB
deactivate AuthRepo
RegSvc -> AuthRepo: GetUserByEmailAsync(email)
activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail
activate DB
DB --> AuthRepo: null (email doesn't exist)
deactivate DB
deactivate AuthRepo
alt User/Email already exists
RegSvc -> API: throw ConflictException
API -> User: 409 Conflict\n"Username or email already exists"
else User doesn't exist
RegSvc -> Argon2: Hash(password)
activate Argon2
note right of Argon2
Argon2id parameters:
- Salt: 16 bytes (128-bit)
- Memory: 64MB
- Iterations: 4
- Parallelism: CPU count
- Hash output: 32 bytes
end note
Argon2 -> Argon2: Generate random salt\n(16 bytes)
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
Argon2 --> RegSvc: "base64(salt):base64(hash)"
deactivate Argon2
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash)
activate AuthRepo
AuthRepo -> DB: EXEC USP_RegisterUser
activate DB
note right of DB
Transaction begins:
1. INSERT UserAccount
2. INSERT UserCredential
(with hashed password)
Transaction commits
end note
DB -> DB: BEGIN TRANSACTION
DB -> DB: INSERT INTO UserAccount\n(Username, FirstName, LastName,\nEmail, DateOfBirth)
DB -> DB: OUTPUT INSERTED.UserAccountID
DB -> DB: INSERT INTO UserCredential\n(UserAccountId, Hash)
DB -> DB: COMMIT TRANSACTION
DB --> AuthRepo: UserAccountId (GUID)
deactivate DB
AuthRepo --> RegSvc: UserAccount entity
deactivate AuthRepo
RegSvc -> TokenSvc: GenerateAccessToken(userAccount)
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
* jti: unique token ID
end note
JWT -> JWT: Create JWT with claims
JWT -> JWT: Sign with secret key
JWT --> TokenSvc: Access Token
deactivate JWT
TokenSvc --> RegSvc: Access Token
deactivate TokenSvc
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
Refresh Token:
- Expires: 21 days
- Same structure as access token
end note
JWT --> TokenSvc: Refresh Token
deactivate JWT
TokenSvc --> RegSvc: Refresh Token
deactivate TokenSvc
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken)
activate EmailSvc
EmailSvc -> 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 --> EmailSvc: HTML email content
deactivate Template
EmailSvc -> SMTP: SendAsync(email, subject, body)
activate SMTP
note right of SMTP
SMTP Configuration:
- Host: from env (SMTP_HOST)
- Port: from env (SMTP_PORT)
- TLS: StartTLS
- Auth: username/password
end note
SMTP -> SMTP: Create MIME message
SMTP -> SMTP: Connect to SMTP server
SMTP -> SMTP: Authenticate
SMTP -> SMTP: Send email
SMTP -> SMTP: Disconnect
SMTP --> EmailSvc: Success / Failure
deactivate SMTP
alt Email sent successfully
EmailSvc --> RegSvc: emailSent = true
else Email failed
EmailSvc --> RegSvc: emailSent = false\n(error suppressed)
end
deactivate EmailSvc
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent)
deactivate RegSvc
API -> API: Create response body
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
end
end
deactivate API
== Login Flow ==
User -> API: POST /api/auth/login\n{username, password}
activate API
API -> API: Validate request\n(FluentValidation)
alt Validation fails
API -> User: 400 Bad Request\n{errors: {...}}
else Validation succeeds
API -> LoginSvc: LoginAsync(username, password)
activate LoginSvc
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
activate DB
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username
DB --> AuthRepo: UserAccount entity
deactivate DB
deactivate AuthRepo
alt User not found
LoginSvc -> API: throw UnauthorizedException\n"Invalid username or password"
API -> User: 401 Unauthorized
else User found
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
activate AuthRepo
AuthRepo -> DB: EXEC USP_GetActiveUserCredentialByUserAccountId
activate DB
note right of DB
SELECT FROM UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0
end note
DB --> AuthRepo: UserCredential entity
deactivate DB
deactivate AuthRepo
alt No active credential
LoginSvc -> API: throw UnauthorizedException
API -> User: 401 Unauthorized
else Active credential found
LoginSvc -> Argon2: Verify(password, storedHash)
activate Argon2
note right of Argon2
1. Split stored hash: "salt:hash"
2. Extract salt
3. Hash provided password\n with same salt
4. Constant-time comparison
end note
Argon2 -> Argon2: Parse salt from stored hash
Argon2 -> Argon2: Hash provided password\nwith extracted salt
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
Argon2 --> LoginSvc: true/false
deactivate Argon2
alt Password invalid
LoginSvc -> API: throw UnauthorizedException
API -> User: 401 Unauthorized
else Password valid
LoginSvc -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...)
activate JWT
JWT --> TokenSvc: Access Token
deactivate JWT
TokenSvc --> LoginSvc: Access Token
deactivate TokenSvc
LoginSvc -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...)
activate JWT
JWT --> TokenSvc: Refresh Token
deactivate JWT
TokenSvc --> LoginSvc: Refresh Token
deactivate TokenSvc
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken)
deactivate LoginSvc
API -> User: 200 OK\n{\n message: "Logged in successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken\n }\n}
end
end
end
end
deactivate API
== Error Handling (Global Exception Filter) ==
note over API
GlobalExceptionFilter catches:
- ValidationException → 400 Bad Request
- ConflictException → 409 Conflict
- NotFoundException → 404 Not Found
- UnauthorizedException → 401 Unauthorized
- ForbiddenException → 403 Forbidden
- All others → 500 Internal Server Error
end note
@enduml

View File

@@ -0,0 +1,104 @@
@startuml database-schema
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam linetype ortho
title Key Database Schema - User & Authentication
entity "UserAccount" as User {
* UserAccountId: INT <<PK>>
--
* Username: NVARCHAR(30) <<UNIQUE>>
* Email: NVARCHAR(255) <<UNIQUE>>
* FirstName: NVARCHAR(50)
* LastName: NVARCHAR(50)
Bio: NVARCHAR(500)
CreatedAt: DATETIME2
UpdatedAt: DATETIME2
LastLoginAt: DATETIME2
}
entity "UserCredential" as Cred {
* UserCredentialId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
* PasswordHash: VARBINARY(32)
* PasswordSalt: VARBINARY(16)
CredentialRotatedAt: DATETIME2
CredentialExpiresAt: DATETIME2
CredentialRevokedAt: DATETIME2
* IsActive: BIT
CreatedAt: DATETIME2
}
entity "UserVerification" as Verify {
* UserVerificationId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
* IsVerified: BIT
VerifiedAt: DATETIME2
VerificationToken: NVARCHAR(255)
TokenExpiresAt: DATETIME2
}
entity "UserAvatar" as Avatar {
* UserAvatarId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
PhotoId: INT <<FK>>
* IsActive: BIT
CreatedAt: DATETIME2
}
entity "UserFollow" as Follow {
* UserFollowId: INT <<PK>>
--
* FollowerUserId: INT <<FK>>
* FollowedUserId: INT <<FK>>
CreatedAt: DATETIME2
}
entity "Photo" as Photo {
* PhotoId: INT <<PK>>
--
* Url: NVARCHAR(500)
* CloudinaryPublicId: NVARCHAR(255)
Width: INT
Height: INT
Format: NVARCHAR(10)
CreatedAt: DATETIME2
}
' Relationships
User ||--o{ Cred : "has"
User ||--o| Verify : "has"
User ||--o{ Avatar : "has"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
Avatar }o--|| Photo : "refers to"
note right of Cred
Password hashing:
- Algorithm: Argon2id
- Memory: 64MB
- Iterations: 4
- Salt: 128-bit
- Hash: 256-bit
end note
note right of Verify
Account verification
via email token
with expiry
end note
note bottom of User
Core stored procedures:
- USP_RegisterUser
- USP_GetUserAccountByUsername
- USP_RotateUserCredential
- USP_UpdateUserAccount
end note
@enduml

View File

@@ -0,0 +1,227 @@
@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
}
}
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 "Infrastructure.Repository.Tests\n(Unit Tests)" as RepoTests {
component "xUnit + DbMocker" as RepoComp
note right
Tests:
- AuthRepository
- UserAccountRepository
- SQL command building
Uses: Mock connections
No real database needed
end note
}
node "Service.Auth.Tests\n(Unit Tests)" as SvcTests {
component "xUnit + Moq" as SvcComp
note right
Tests:
- RegisterService
- LoginService
- Token generation
Uses: Mocked dependencies
No database or infrastructure
end note
}
}
folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1
file "repository-tests/\n results.trx" as Result2
file "service-auth-tests/\n results.trx" as Result3
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
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
RepoTests ..> TestDB : Mock (no connection)
SvcTests ..> TestDB : Mock (no connection)
TestMig .up.> TestDB : depends_on
TestSeed .up.> TestMig : depends_on
Specs .up.> TestSeed : depends_on
' Test results export
Specs --> Results : Export TRX
RepoTests --> Results : Export TRX
SvcTests --> Results : Export TRX
' 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
' Production note
note as ProductionNote
<b>Production Deployment (not shown):</b>
Would include:
• Azure SQL Database / AWS RDS
• Azure Container Apps / ECS
• Azure Key Vault for secrets
• Application Insights / CloudWatch
• Load balancer
• HTTPS termination
• CDN for static assets
end note
@enduml

327
docs/docker.md Normal file
View File

@@ -0,0 +1,327 @@
# 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/pdf/deployment.pdf) for visual representation.
## Docker Compose Environments
### 1. Development (`docker-compose.dev.yaml`)
**Purpose**: Local development with persistent data
**Features**:
- Persistent SQL Server volume
- Hot reload support
- Swagger UI enabled
- Seed data included
- `CLEAR_DATABASE=true` (drops and recreates schema)
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1433)
database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
```
**Start Development Environment**:
```bash
docker compose -f docker-compose.dev.yaml up -d
```
**Access**:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- SQL Server: localhost:1433 (sa credentials from .env.dev)
**Stop Environment**:
```bash
# Stop services (keep volumes)
docker compose -f docker-compose.dev.yaml down
# Stop and remove volumes (fresh start)
docker compose -f docker-compose.dev.yaml down -v
```
### 2. Testing (`docker-compose.test.yaml`)
**Purpose**: Automated CI/CD testing in 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
repository.tests # Repository unit tests
service.auth.tests # Service unit tests
```
**Run Tests**:
```bash
# Run all tests
docker compose -f 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/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
# Clean up
docker compose -f 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 -f docker-compose.prod.yaml up -d
```
## Service Dependencies
Docker Compose manages startup order using health checks:
```mermaid
sqlserver (health check)
↓
database.migrations (completes successfully)
↓
database.seed (completes successfully)
↓
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.
**Code Volumes** (development only):
```yaml
volumes:
- ./src:/app/src # Hot reload for development
```
## 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}'
JWT_SECRET: '${JWT_SECRET}'
```
For complete list, see [Environment Variables](environment-variables.md).
## Common Commands
### View Services
```bash
# Running services
docker compose -f docker-compose.dev.yaml ps
# All containers (including stopped)
docker ps -a
```
### View Logs
```bash
# All services
docker compose -f docker-compose.dev.yaml logs -f
# Specific service
docker compose -f docker-compose.dev.yaml logs -f api.core
# Last 100 lines
docker compose -f 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
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'password' -C
```
### Restart Services
```bash
# Restart all services
docker compose -f docker-compose.dev.yaml restart
# Restart specific service
docker compose -f docker-compose.dev.yaml restart api.core
# Rebuild and restart
docker compose -f docker-compose.dev.yaml up -d --build api.core
```
### Build Images
```bash
# Build all images
docker compose -f docker-compose.dev.yaml build
# Build specific service
docker compose -f docker-compose.dev.yaml build api.core
# Build without cache
docker compose -f docker-compose.dev.yaml build --no-cache
```
### Clean Up
```bash
# Stop and remove containers
docker compose -f docker-compose.dev.yaml down
# Remove containers and volumes
docker compose -f docker-compose.dev.yaml down -v
# Remove containers, volumes, and images
docker compose -f 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)

View File

@@ -0,0 +1,304 @@
# Environment Variables
This document covers the active environment variables used by the current Biergarten
stack.
## Overview
The application uses environment variables for:
- **.NET API backend** - database connections, token secrets, runtime settings
- **React Router website** - API base URL and session signing
- **Docker containers** - environment-specific orchestration
## Configuration Patterns
### Backend (.NET API)
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (`src/Website`)
The active website reads runtime values from the server environment for its auth and API
integration.
### Docker
Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
- `.env.dev` - Development
- `.env.test` - Testing
- `.env.prod` - Production
## Backend Variables (.NET API)
### Database Connection
**Option 1: Component-Based (Recommended for Docker)**
Build connection string from individual components:
```bash
DB_SERVER=sqlserver,1433 # SQL Server host and port
DB_NAME=Biergarten # Database name
DB_USER=sa # SQL Server username
DB_PASSWORD=YourStrong!Passw0rd # SQL Server password
DB_TRUST_SERVER_CERTIFICATE=True # Optional, defaults to True
```
**Option 2: Full Connection String (Local Development)**
Provide complete connection string:
```bash
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
```
**Priority**: `DB_CONNECTION_STRING` is checked first. If not found, connection string is
built from components.
**Implementation**: See `DefaultSqlConnectionFactory.cs`
### JWT Authentication Secrets (Backend)
The backend uses separate secrets for different token types to enable independent key rotation and validation isolation.
```bash
# Access token secret (1-hour tokens)
ACCESS_TOKEN_SECRET=<generated-secret> # Signs short-lived access tokens
# Refresh token secret (21-day tokens)
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
# Confirmation token secret (30-minute tokens)
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
# Website base URL (used in confirmation emails)
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
```
**Security Requirements**:
- Each secret should be minimum 32 characters
- Recommend 127+ characters for production
- Generate using cryptographically secure random functions
- Never reuse secrets across token types or environments
- Rotate secrets periodically in production
**Generate Secrets**:
```bash
# macOS/Linux - Generate 127-character base64 secret
openssl rand -base64 127
# Windows PowerShell
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
```
**Token Expiration**:
- **Access tokens**: 1 hour
- **Refresh tokens**: 21 days
- **Confirmation tokens**: 30 minutes
(Defined in `TokenServiceExpirationHours` class)
**JWT Implementation**:
- **Algorithm**: HS256 (HMAC-SHA256)
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- **Validation**: Token signature, expiration, and malformed token checks
### Migration Control
```bash
CLEAR_DATABASE=true
```
- **Required**: No
- **Default**: false
- **Effect**: If "true", drops and recreates database during migrations
- **Usage**: Development and testing environments ONLY
- **Warning**: NEVER use in production
### ASP.NET Core Configuration
```bash
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
```
## Frontend Variables (`src/Website`)
The active website does not use the old Next.js/Prisma environment model. Its core runtime
variables are:
```bash
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
SESSION_SECRET=<generated-secret> # Cookie session signing secret
NODE_ENV=development # Standard Node runtime mode
```
### Frontend Variable Details
#### `API_BASE_URL`
- **Required**: Yes for local development
- **Default in code**: `http://localhost:8080`
- **Used by**: `src/Website/app/lib/auth.server.ts`
- **Purpose**: Routes website auth actions to the .NET API
#### `SESSION_SECRET`
- **Required**: Strongly recommended in all environments
- **Default in local code path**: `dev-secret-change-me`
- **Used by**: React Router cookie session storage in `auth.server.ts`
- **Purpose**: Signs and validates the website session cookie
#### `NODE_ENV`
- **Required**: No
- **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode
### Admin Account (Seeding)
```bash
ADMIN_PASSWORD=SecureAdminPassword123! # Initial admin password for seeding
```
- **Required**: No (only needed for seeding)
- **Purpose**: Sets admin account password during database seeding
- **Security**: Use strong password, change immediately in production
## Docker-Specific Variables
### SQL Server Container
```bash
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
ACCEPT_EULA=Y # Accept SQL Server EULA (required)
MSSQL_PID=Express # SQL Server edition (Express, Developer, Enterprise)
```
**Password Requirements**:
- Minimum 8 characters
- Uppercase, lowercase, digits, and special characters
- Maps to `DB_PASSWORD` for application containers
## Environment File Structure
### Backend/Docker (Root Directory)
```
.env.example # Template (tracked in Git)
.env.dev # Development config (gitignored)
.env.test # Testing config (gitignored)
.env.prod # Production config (gitignored)
```
**Setup**:
```bash
cp .env.example .env.dev
# Edit .env.dev with your values
```
## Legacy Frontend Variables
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed from this
active reference. See [archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you
need the legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
**Docker Compose Mapping**:
- `docker-compose.dev.yaml` → `.env.dev`
- `docker-compose.test.yaml` → `.env.test`
- `docker-compose.prod.yaml` → `.env.prod`
## Variable Reference Table
| Variable | Backend | Frontend | Docker | Required | Notes |
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode |
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
`DB_USER`, `DB_PASSWORD`) must be provided.
## Validation
### Backend Validation
Variables are validated at startup:
- Missing required variables cause application to fail
- JWT_SECRET length is enforced (min 32 chars)
- Connection string format is validated
### Frontend Validation
The active website relies on runtime defaults for local development and the surrounding
server environment in deployed environments.
- `API_BASE_URL` defaults to `http://localhost:8080`
- `SESSION_SECRET` falls back to a development-only local secret
- `NODE_ENV` controls secure cookie behavior
## Example Configuration Files
### `.env.dev` (Backend/Docker)
```bash
# Database
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=Dev_Password_123!
# JWT Authentication Secrets
ACCESS_TOKEN_SECRET=<generated-with-openssl>
REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# Migration
CLEAR_DATABASE=true
# ASP.NET Core
ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://0.0.0.0:8080
# SQL Server Container
SA_PASSWORD=Dev_Password_123!
ACCEPT_EULA=Y
MSSQL_PID=Express
```
### Frontend local runtime example
```bash
API_BASE_URL=http://localhost:8080
SESSION_SECRET=<generated-with-openssl>
NODE_ENV=development
```

138
docs/getting-started.md Normal file
View File

@@ -0,0 +1,138 @@
# Getting Started
This guide covers local setup for the current Biergarten stack: the .NET backend in
`src/Core` and the active React Router frontend in `src/Website`.
## Prerequisites
- **.NET SDK 10+**
- **Node.js 18+**
- **Docker Desktop** or equivalent Docker Engine setup
- **Java 8+** if you want to regenerate PlantUML diagrams
## Recommended Path: Docker for Backend, Node for Frontend
### 1. Clone the Repository
```bash
git clone <repository-url>
cd the-biergarten-app
```
### 2. Configure Backend Environment Variables
```bash
cp .env.example .env.dev
```
At minimum, ensure `.env.dev` includes valid database and token values:
```bash
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd
ACCESS_TOKEN_SECRET=<generated>
REFRESH_TOKEN_SECRET=<generated>
CONFIRMATION_TOKEN_SECRET=<generated>
WEBSITE_BASE_URL=http://localhost:3000
```
See [Environment Variables](environment-variables.md) for the full list.
### 3. Start the Backend Stack
```bash
docker compose -f docker-compose.dev.yaml up -d
```
This starts SQL Server, migrations, seeding, and the API.
Available endpoints:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
### 4. Start the Active Frontend
```bash
cd src/Website
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
```
The website will be available at the local address printed by React Router dev.
Required frontend runtime variables for local work:
- `API_BASE_URL` - Base URL for the .NET API
- `SESSION_SECRET` - Cookie session signing secret for the website server
### 5. Optional: Run Storybook
```bash
cd src/Website
npm run storybook
```
Storybook runs at http://localhost:6006 by default.
## Useful Commands
### Backend
```bash
docker compose -f docker-compose.dev.yaml logs -f
docker compose -f docker-compose.dev.yaml down
docker compose -f docker-compose.dev.yaml down -v
```
### Frontend
```bash
cd src/Website
npm run lint
npm run typecheck
npm run format:check
npm run test:storybook
npm run test:storybook:playwright
```
## Manual Backend Setup
If you do not want to use Docker, you can run the backend locally.
### 1. Set Environment Variables
```bash
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
export ACCESS_TOKEN_SECRET="<generated>"
export REFRESH_TOKEN_SECRET="<generated>"
export CONFIRMATION_TOKEN_SECRET="<generated>"
export WEBSITE_BASE_URL="http://localhost:3000"
```
### 2. Run Migrations and Seed
```bash
cd src/Core
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
dotnet run --project Database/Database.Seed/Database.Seed.csproj
```
### 3. Start the API
```bash
dotnet run --project API/API.Core/API.Core.csproj
```
## Legacy Frontend Note
The previous Next.js frontend now lives in `src/Website-v1` and is not the active website.
Legacy setup details have been moved to [docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Next Steps
- Review [Architecture](architecture.md)
- Run backend and frontend checks from [Testing](testing.md)
- Use [Docker Guide](docker.md) for container troubleshooting

View File

@@ -1,33 +0,0 @@
---
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.

View File

@@ -1,43 +0,0 @@
# 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'

View File

@@ -1,392 +0,0 @@
---
title: Pipeline ethics, bias, and known issues
last-updated: 2026-08-31
tags:
- pipeline
- ethics
- bias
- known-issues
- ai-generated
---
This document covers the ethical context of the Biergarten Pipeline's output,
the model's biases, and known issues including hallucinated brewing science and
low-resource language failures.
> All testing described below used `google_gemma-4-E4B-it-Q6_K.gguf`.
## Table of contents
- [What this dataset is](#what-this-dataset-is)
- [What this dataset is not](#what-this-dataset-is-not)
- [Model bias and language quality](#model-bias-and-language-quality)
- [Western and Eurocentric lens](#western-and-eurocentric-lens)
- [Wikipedia enrichment](#wikipedia-enrichment)
- [Names-by-country dataset](#names-by-country-dataset)
- [The "avoid AI phrases" prompt instruction](#the-avoid-ai-phrases-prompt-instruction)
- [Known issues](#known-issues)
- [Hallucinated brewing techniques](#hallucinated-brewing-techniques)
- [Low-resource language hallucination](#low-resource-language-hallucination)
- [Synthetic coordinates](#synthetic-coordinates)
---
## What this dataset is
This is AI-generated fixture data for a proof-of-concept version of The
Biergarten App. Anyone who interacts with an application seeded from this
pipeline must be told upfront that the content is AI-generated.
---
## What this dataset is not
The pipeline is not intended to produce accurate brewing science, faithful
cultural representation, or reliable local-language text. Hallucinations such as
invented fermentation techniques, or incoherent local-language prose, are
expected, observed, and partially documented in [Known issues](#known-issues)
below.
Human control sits at the context layer (that is, prompt design, Wikipedia
enrichment). Statistical output shapes in future pipeline stages (check-in
distributions, rating skews, activity profiles) will be handled the same way.
**Treat this data as an exercise in prompt engineering and model behaviour, not
as a source of truth for brewing techniques or cultural representation.**
**Natural language processing is a powerful tool for data analysis and
generation, but its output should be treated with scrutiny. Human language is
not simply data points to be analyzed: it carries cultural and human meaning
that artificial intelligence cannot capture.**
---
## Model bias and language quality
The underlying model's training biases surface within this pipeline. Output
quality tracks with how well a language is represented in the training corpus:
standard French (`fr-FR`) produces coherent text; regional variants like `fr-CD`
and `fr-CI` are noticeably weaker; low-resource languages like Welsh, Māori, and
Sicilian produce output that is syntactically plausible but often semantically
broken.
This is a property of the training distribution, not something that can be
mitigated through prompt design. This is a well-documented characteristic of
large language models trained predominantly on English-language
material.[^llm-bias]
Mitigations are documented in
[Known issues: Low-resource language hallucination](#low-resource-language-hallucination).
### Western and Eurocentric lens
The model's training data skews heavily Western and North American. When
generating brewery descriptions for Kinshasa, Abidjan, or Osaka, for example, it
defaults to framing and cultural reference points drawn from that perspective
rather than from the lived context of those cities. Wikipedia enrichment grounds
some generation in city-specific material, but it does not eliminate the skew.
**Output should be read with an understanding of this bias.**
---
## Wikipedia enrichment
City and beer context is fetched from the Wikipedia API. Wikipedia text is
co-licensed under the **Creative Commons Attribution-ShareAlike 4.0
International License (CC BY-SA 4.0)**[^cc-sa] and the **GNU Free Documentation
License (GFDL)**.[^wp-license]
Wikipedia's own accuracy limitations and editorial biases can propagate into
generated descriptions.
---
## Names-by-country dataset
`tooling/pipeline/forenames-by-country.json` and `surnames-by-country.json`
(used to sample a `Name` per ISO 3166-1 country code for user generation) are
vendored verbatim, unmodified, from
[sigpwned/popular-names-by-country-dataset](https://github.com/sigpwned/popular-names-by-country-dataset)
(the `common-forenames-by-country.json` / `common-surnames-by-country.json`
release assets), released under **CC0** (public domain). That dataset's own
forename/surname lists are pulled from Wikipedia's "Lists of most common
surnames" and "List of most popular given names" as of the week of 2023-07-08;
see that project's README for full provenance. Names are not LLM-generated; this
is curated fixture data per ROADMAP.md §2. Per-forename gender from the source
data is preserved through to the sampled `Name` (rather than discarded during
loading) so it's available for gender-aware persona/bio generation later.
The full multinational dataset is kept as-is (106 countries for forenames, 75
for surnames) rather than trimmed to `locations.json`'s current country list, so
it doesn't need re-sourcing if more countries are added later. `SampleName()` (a
free helper in `generate_users.cc`) returns no result for a country present in
neither file; of the countries in `locations.json`, that's currently `KE`, `SE`,
`SG`, `TH`, `VN`, and `ZA`; `GenerateUsers` skips cities in those countries the
same way brewery generation skips cities whose enrichment lookup fails.
---
## The "avoid AI phrases" prompt instruction
The system prompt instructs the model to avoid common AI-generated phrasing
patterns. This is a prompt engineering experiment:
> How far can a model be pushed against its own stylistic defaults?
This is not an attempt to disguise the content as human-written. All downstream
consumers are informed of the AI-generated origin before engagement.
---
## Known issues
### Hallucinated brewing techniques
When forced by the system prompt to generate a "highly specialized technical
brewing detail," the model frequently hallucinates fermentation science and
brewing chemistry. While the resulting text sounds confident, it is nonsensical
to a reader with brewing or scientific expertise.
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
calling for specialist brewing detail. This is consistent with the **CHOKE**
failure mode (Certain Hallucinations Overriding Known Evidence) [^llm-choke]
where a specific-sounding prompt causes a model to produce confident, coherent,
and factually wrong output.
#### Example — Osaka, produced using Gemma 4 E4B
```log
[2026-04-21 15:04:40.258] [info] 11. city="Osaka" country="Japan" state="Osaka" iso3166_2=JP-27 lat=34.6937 lon=135.5023
[2026-04-21 15:04:40.258] [info] brewery_name_en="Kani-no-Kuni Brewing"
[2026-04-21 15:04:40.258] [info] brewery_description_en="The humid scent of takoyaki and savory grilled squid always settles over the neighborhood early in the morning, mingling strangely with the metallic tang of spent grain outside our drying shed. We decided to build this place here because Osaka is a city that never pauses, a place built around the constant exchange of goods and tastes, and that is what we want our beer to reflect. Many people here are accustomed to the dependable, clean taste of the major pale lagers, but we are interested in exploring the deeper, more textured expressions of fermentation. Our base malts are specifically crafted by incorporating strains of local rice, like Yamada Nishiki, which we malt and use not for sake, but for its complex, buttery flavor profile during the mash. This combination, followed by a slow, anaerobic aging process, gives our beers a profound, satisfying depth that stands apart from the hurried mass-market style. The heavy, deep red brick of the original warehouse wall has absorbed decades of Kansai humidity and seasonal rains, creating a patina that tells the exact story of this district's tireless movement. We chose this structure not for its charm, but for its resilience and the sheer density of the local history held within its mortar. Our goal is simply to serve a drink worthy of this powerful trading city. If you are looking for a quiet spot away from the main thoroughfare, look for us just off the side street near Shinsekai."
[2026-04-21 15:04:40.258] [info] brewery_name_local="カニの国ブルワリー"
[2026-04-21 15:04:40.258] [info] brewery_description_local="早朝の、たこ焼きや香ばしいイカ焼きの湿った匂いは、いつも乾燥小屋の外にある使用済み麦芽の金属的な匂いと奇妙に混ざり合って近隣に漂います。私たちはこの場所に店を構えることを決めたのです。なぜなら、大阪は決して止まることのない都市であり、商品と味が絶え間なく交換されることで築かれた場所だからです。地元の多くの方々は、信頼できる大規模な淡麗ラガーの味が習慣になっていますが、私たちは発酵の、より深く、より複雑な表現を探求することに関心があります。私たちのベースモルトは、山田錦のような地元の米の品種を意図的に組み込んで作られています。この米を酒ではなく、麦芽として、仕込みの最中にその複雑でバターのような風味を引き出すために使用しています。この組み合わせを、ゆっくりとした嫌気的な熟成プロセスに続けることで、私たちのビールは、慌ただしい市場のスタイルとは一線を画す、深みのある、満足感のある複雑さを持っています。オリジナルの倉庫の重く深紅のレンガ壁は、関西特有の湿気と季節の雨を何十年も吸収し、この地区の絶え間ない動きの正確な物語を語るような古色を帯びています。私たちはこの構造物を、その魅力のためではなく、その回復力とモルタルに込められた地域の歴史の密度ゆえに選びました。私たちの目標は、ただこの力強い交易都市に値する飲み物を提供することだけです。もしメインの通りから離れた静かな場所をお探しなら、新世界近くの脇道にある私たちを探してください。"
```
A review of the following text for brewing techniques reveals three
inaccuracies. No comments are made on the local-language version, since its
reviewer does not read Japanese:
#### 1. "Buttery flavours" framed as a desirable malt-derived flavour
**Incorrect.**
Diacetyl is a fermentation byproduct of yeast metabolism, not a malt-derived
compound.[^diacetyl-source] Diacetyl produces a buttery or butterscotch
off-flavour and is carefully managed in many beer styles, in particular lighter
beers, through a process called a _diacetyl rest_. In this process, fermentation
temperature is briefly raised to allow yeast to reabsorb the compound before
packaging.[^diacetyl-rest]
The Oxford Companion to Beer claims that, while low levels are tolerable in some
ales and stouts, diacetyl is considered undesirable at any perceptible
concentration when it results from bacterial contamination or stressed
fermentation.[^oxford-beer]
#### 2. Yamada Nishiki sake rice described as a self-saccharifying base malt
**Incorrect.**
Yamada Nishiki (_山田錦_) is a short-grain Japanese rice bred specifically for
sake production.[^yn-wiki] Its value lies in its large starchy core
(_shinpaku_), low protein content, and amenability to _koji_ mold penetration
during saccharification.[^yn-sakestreet] Sake brewing does not use the grain's
own enzymatic activity for saccharification — it relies on _Aspergillus oryzae_
(koji mold) grown on a portion of the steamed rice to convert starches to
fermentable sugars.[^yn-sakeonline]
#### 3. "Anaerobic aging" presented as a differentiating technique
**Misleading**
Anaerobic conditions during packaging and aging are not a differentiating
technique. Anaerobic conditions are the standard baseline for all commercial
beer production. Breweries exclude oxygen as a top priority for packaging and
shelf stability; published research in _Microbiology Spectrum_ confirms that
packaged beer constitutes an anaerobic environment by definition.[^anaerobic]
Professional packaging lines use CO_2 purges and closed transfers specifically
to maintain this state.[^packaging] Framing anaerobic aging as a distinctive
practice is misleading and suggests hallucinated output.
### Low-resource language hallucination
The generation pipeline passes local language codes to the model to retrieve a
translated `description_local`. Output quality is reliable for high-resource
languages such as French, though it may struggle with regional variants and
idiomatic phrasing.
```json
[
{
"city": "Kinshasa",
"state_province": "Kinshasa",
"iso3166_2": "CD-KN",
"country": "Democratic Republic of the Congo",
"iso3166_1": "CD",
"latitude": -4.4419,
"longitude": 15.2663,
"local_languages": ["fr-CD", "ln"]
},
{
"city": "Paris",
"state_province": "Île-de-France",
"iso3166_2": "FR-IDF",
"country": "France",
"iso3166_1": "FR",
"latitude": 48.8566,
"longitude": 2.3522,
"local_languages": ["fr-FR"]
},
{
"city": "Abidjan",
"state_province": "Abidjan",
"iso3166_2": "CI-AB",
"country": "Ivory Coast",
"iso3166_1": "CI",
"latitude": 5.36,
"longitude": -4.0083,
"local_languages": ["fr-CI"]
},
{
"city": "Montreal",
"state_province": "Quebec",
"iso3166_2": "CA-QC",
"country": "Canada",
"iso3166_1": "CA",
"latitude": 45.5017,
"longitude": -73.5673,
"local_languages": ["fr-CA"]
},
{
"city": "Brussels",
"state_province": "Brussels-Capital Region",
"iso3166_2": "BE-BRU",
"country": "Belgium",
"iso3166_1": "BE",
"latitude": 50.8503,
"longitude": 4.3517,
"local_languages": ["fr-BE", "nl-BE"]
}
]
```
When fed into the pipeline, this dataset often causes the model to reason that a
local variant of French is needed, but it often defaults to a standardized
dialect of French, without cultural or linguistic nuance.
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
(Sicily, Italy), the model can generate text that looks syntactically plausible
but is semantically incoherent. This comes from limited training-data coverage
rather than prompt engineering.
Output sample: [./french-cities.example](french-cities.example)
#### Proposed mitigations
- **Prevention via allowlist:** introduce a high-resource language allowlist. If
a location's code is unlisted, skip `description_local` generation and fall
back to English.
- **Upstream sanitization:** strip known low-resource language codes from the
`locations.json` payload before generation.
- **Downstream flagging:** add a `description_local_confidence` column to the
SQLite schema so downstream applications can filter or flag potentially
hallucinated text by language tier.
### Synthetic coordinates
Each brewery and user address carries a longitude/latitude pair, sampled
uniformly at random within a 5 km disc centred on the city's curated
`latitude`/`longitude` from `locations.json`.
The coordinate pair itself is **plausible by construction**, always falling
within the city's vicinity, but does not correspond to a real business or
resident, and should not be treated as accurate geocoding data. This is
consistent with the rest of the dataset: fixture data for a proof-of-concept,
not a source of truth.
For brewery addresses specifically (not user addresses, which carry no street
address at all), `address_line1`/`postal_code` _are_ looked up from a real
address registry — `NominatimAddressService` reverse-geocodes the synthetic
coordinate pair against the live OpenStreetMap/Nominatim database, so the street
address is a genuine nearby address, just not the address of an actual brewery.
This lookup only happens for non-`--mocked` runs; `--mocked` runs use
`MockAddressService`'s fixed placeholder address instead, which is not looked up
from any real registry.
---
## Footnotes
[^llm-choke]:
CHOKE (Certain Hallucinations Overriding Known Evidence) is a hallucination
failure mode defined by Simhi et al. (2025), in which a model that can
consistently answer a question correctly produces a confident, wrong
response when the prompt is trivially perturbed. Source: Trust Me, I'm
Wrong: LLMs Hallucinate with Certainty Despite Knowing the Answer — Adi
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
[^llm-bias]:
For example, Blasi et al. (2022), "Systematic Inequalities in Language
Technology Performance across the World's Languages," _ACL Anthology_. The
pattern is consistent with models trained predominantly on English-language
web corpora.
[^wp-license]:
Source:
[Wikipedia:FAQ/Copyright](https://en.wikipedia.org/wiki/Wikipedia:FAQ/Copyright).
[^cc-sa]:
Creative Commons CC BY-SA 4.0 deed: "If you remix, transform, or build upon
the material, you must distribute your contributions under the same license
as the original." Source:
[creativecommons.org/licenses/by-sa/4.0](https://creativecommons.org/licenses/by-sa/4.0/deed.en).
[^diacetyl-source]:
White Labs confirms that diacetyl is a yeast-derived fermentation byproduct:
specifically, a compound produced during amino acid metabolism that leaks
out of the yeast cell and oxidises into its characteristic buttery
off-flavour. It is generally considered undesirable at any perceived level
in most styles, though low levels are tolerated in some English ales and
European lagers. Source:
[whitelabs.com — Compound Spotlight: Diacetyl](https://www.whitelabs.com/news-update-detail?id=54).
[^diacetyl-rest]:
Brewing Science Institute: diacetyl "is produced during the fermentation
process, primarily as a byproduct of yeast metabolism… generally considered
a flaw in most beer styles." Source:
[brewingscience.com — Diacetyl: Understanding Its Role as an Off-Flavor in Beer](https://brewingscience.com/diacetyl-understanding-its-role-as-an-off-flavor-in-beer/).
[^oxford-beer]:
Oxford Companion to Beer via _Beer & Brewing_: "At low to moderate levels,
diacetyl can be perceived as a positive flavor characteristic in some ales
and stouts" but "particularly unwelcome in lager-style beers." Source:
[beerandbrewing.com — diacetyl](https://www.beerandbrewing.com/dictionary/48TDqQibPi).
[^yn-wiki]:
Wikipedia: "Yamada Nishiki (山田錦) is a short-grain Japanese rice famous
for its use in high-quality sake." Source:
[en.wikipedia.org/wiki/Yamada_Nishiki](https://en.wikipedia.org/wiki/Yamada_Nishiki).
[^yn-sakestreet]:
Sake Street: Yamadanishiki's large _shinpaku_ allows koji mold to penetrate
to the centre of the rice grain, making it "particularly suitable for
producing good koji." Source:
[sakestreet.com — What is Yamadanishiki?](https://sakestreet.com/en/media/what-is-yamadanishiki).
[^yn-sakeonline]:
Sake Online: "Steamed rice is added to make koji (rice malt) and yeast
starter, which promotes alcohol fermentation." Source:
[sakeonline.com.au — Types of Sake Rice: Yamada Nishiki](https://sakeonline.com.au/blogs/news/types-of-sake-rice-yamada-nishiki-and-its-characteristics).
[^anaerobic]:
Pai et al. (2022): "Breweries have recognized oxygen exclusion as a top
priority for the proper packaging and aging of beer… packaged beer is an
anaerobic environment." _Microbiology Spectrum._ Source:
[journals.asm.org](https://journals.asm.org/doi/10.1128/spectrum.02656-22).
[^packaging]:
Beer Production Processes (oboe.com): Professional packaging lines use
double CO_2 pre-evacuation cycles and closed transfers "so the beer moves in
a completely anaerobic environment." Source:
[oboe.com — Flavor Quality Control](https://oboe.com/learn/beer-production-processes-308lmf/flavor-quality-control-4).

View File

@@ -1,475 +0,0 @@
---
title: Biergarten Pipeline — C++ CLI for generating seed data
last-updated: 2026-09-02
tags:
- pipeline
- cpp
- llm
- seed-data
- docker
---
A C++20 command-line pipeline that samples city records from local JSON,
enriches each with Wikipedia context, and generates bilingual brewery names and
descriptions plus locale-grounded user profiles via a local GGUF model or a
deterministic mock.
> **This pipeline produces AI-generated data.** It is not a source of truth for
> brewing techniques, cultural representation, or local-language accuracy. See
> [ETHICS-AND-KNOWN-ISSUES.md](./ETHICS-AND-KNOWN-ISSUES.md) for full
> documentation of limitations, hallucination patterns, and bias.
---
## Table of contents
- [How it fits the main app](#how-it-fits-the-main-app)
- [Quick start](#quick-start)
- [Build](#build)
- [Model](#model)
- [Run](#run)
- [Docker / RunPod](#docker--runpod)
- [Architecture](#architecture)
- [Pipeline Stages](#pipeline-stages)
- [Key Components](#key-components)
- [Runtime Behaviour](#runtime-behaviour)
- [Generated output](#generated-output)
- [Tech stack](#tech-stack)
- [Tested hardware](#tested-hardware)
- [Fixture strategy](#fixture-strategy)
- [Repo layout](#repo-layout)
- [Code tour](#code-tour)
- [Next steps](#next-steps)
---
## How it fits the main app
The pipeline is a data ingestion layer. It sits outside the web app runtime and
produces seed records the app imports at startup or during a dedicated seed
step.
| Planned app area | Pipeline contribution |
| -------------------------------- | ------------------------------------------------------------------------------------------------- |
| Brewery discovery and management | Sampled city records, localized names, long-form descriptions |
| Beer reviews and ratings | Stable brewery fixtures with enough context to anchor review pages |
| Social follow relationships | Repeatable brewery entities for feeds, follows, and saved lists |
| Geospatial brewery experiences | Latitude, longitude, and country-level metadata |
| User accounts and profiles | Locale-grounded names, bios, and an auth-ready email/date-of-birth pair for seeding real accounts |
---
## Quick start
### Build
Requirements: C++20 compiler, CMake 3.31+, OpenSSL, Boost (JSON and
ProgramOptions). SQLite is fetched from the upstream amalgamation, so no system
SQLite package is required.
```bash
cmake -S . -B build
cmake --build build
```
CMake automatically detects whether a compatible llama.cpp installation is
present on the system (`libllama`, `libggml`, `libggml-base`, and `llama.h`
visible on the default search paths). If found, it links against those libraries
and skips the FetchContent build. If not found, it fetches and builds llama.cpp
from source at tag `b9012`. No additional flags are required in either case.
Metal is enabled automatically on Apple Silicon. CUDA or HIP/ROCm is detected
automatically on Linux when the relevant toolkit is present.
### Model
> Skip this step if you only need `--mocked`.
```bash
mkdir -p models
curl -L \
-o models/google_gemma-4-E4B-it-Q6_K.gguf \
https://huggingface.co/bartowski/google_gemma-4-E4B-it-GGUF/resolve/main/google_gemma-4-E4B-it-Q6_K.gguf?download=true
```
### Run
Run from `build/` so the copied `locations.json` and `prompts/` are available.
Each run writes a fresh dated SQLite file such as
`biergarten_seed_2026-04-19T15-30-45.123456Z.sqlite` into the working directory.
```bash
./biergarten-pipeline --mocked
./biergarten-pipeline \
--model ../models/google_gemma-4-E4B-it-Q6_K.gguf \
--prompt-dir prompts \
--location-count 25 \
--temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1
```
#### CLI flags
| Flag | Purpose |
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `--mocked` | Deterministic mock generator, no model required. |
| `--model, -m` | Path to a GGUF file. Selects the local Llama.cpp generator. |
| `--openai` | Use the OpenAI API (Chat Completions) for brewery/user data. Requires the `OPENAI_API_KEY` environment variable. |
| `--openai-model` | OpenAI model ID used when `--openai` is set. Default: `gpt-4o-mini`. |
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
| `--temperature` | Sampling temperature. Default: `1.0`. |
| `--top-p` | Nucleus sampling. Default: `0.95`. |
| `--top-k` | Top-k sampling. Default: `64`. |
| `--n-ctx` | Context window size. Default: `8192`. |
| `--seed` | Random seed. Default: `-1` (random at runtime). |
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
| `--help, -h` | Print usage and exit. |
`--mocked`, `--model`, and `--openai` are mutually exclusive. Omitting all three
exits with an error before the pipeline starts. Sampling flags are ignored
unless `--model` is set (Llama).
The post-build step copies `prompts/` into `build/prompts/`. Rebuild after
editing any prompt file.
---
## Docker / RunPod
The `tooling/pipeline/runpod/` directory contains a GPU-ready container
configuration for running the pipeline on RunPod or any Docker host with an
NVIDIA GPU.
### How it works
The container uses a two-stage build. The builder stage installs CMake/Ninja,
clones the matching llama.cpp release tag for its headers only (installed into
`/usr/local/include`), and copies prebuilt shared libraries (`libllama`,
`libggml`, and CUDA/CPU backend plugins) from
`ghcr.io/ggml-org/llama.cpp:full-cuda` into `/usr/local/lib`. With both headers
and libraries present, CMake's system-library detection (see [Build](#build)
above) finds them and skips the FetchContent source build, keeping image build
times short.
The runtime stage copies the compiled binary, the same prebuilt shared
libraries, and config/prompt assets into a slim CUDA runtime image. It sets
`LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH` so the dynamic linker resolves
`libllama`/`libggml` at startup, and also co-locates `libggml-cuda.so` and the
CPU backend plugins next to the binary for `ggml_backend_load_all()`'s `dlopen`
scan.
### Build the image
Run from the `tooling/pipeline/` directory (the CMake project root), not from
inside `runpod/`, so the `COPY . .` step picks up the full project context.
```bash
docker build -t biergarten-pipeline:latest -f runpod/Dockerfile .
```
To monitor the full build output and confirm CMake selects the system llama.cpp:
```bash
docker build \
--progress=plain \
--no-cache \
-t biergarten-pipeline:latest \
-f runpod/Dockerfile \
. 2>&1 | tee build.log
```
Look for `[biergarten] Found system llama.cpp — skipping FetchContent` in the
output to confirm the fast path was taken.
### Run the container
The container always runs the model-backed path; there is no `--mocked`
container mode (use a native build for that; see [Quick start](#quick-start)).
The entrypoint, `runpod/start.sh`, downloads the GGUF model automatically if it
is not already present at the configured path.
```bash
docker run --rm \
--runtime=nvidia \
-v "$PWD/models:/workspace/models" \
-v "$PWD/output:/workspace/output" \
-v "$PWD/logs:/workspace/logs" \
biergarten-pipeline:latest
```
By default this downloads `google_gemma-4-E4B-it-Q6_K.gguf` to `./models/` on
first run if it isn't already there. To use a pre-downloaded model, place it at
that path first; see [Model](#model).
#### Environment variables
| Variable | Purpose |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `BIERGARTEN_MODEL_PATH` | GGUF model path. Default: `/workspace/models/google_gemma-4-E4B-it-Q6_K.gguf`. |
| `BIERGARTEN_OUTPUT_DIR` | SQLite output directory. Default: `/workspace/output`. |
| `BIERGARTEN_LOG_PATH` | Log file path. Default: `/workspace/logs/pipeline.log`. |
| `BIERGARTEN_GL_LAYERS` | GPU layers to offload (`--n-gpu-layers`). Default: `40`. |
| `BIERGARTEN_TEMPERATURE`, `BIERGARTEN_TOP_P`, `BIERGARTEN_TOP_K`, `BIERGARTEN_N_CTX`, `BIERGARTEN_SEED` | Optional sampling overrides, unset by default (binary defaults apply). |
| `BIERGARTEN_EXTRA_ARGS` | Additional raw CLI args appended verbatim. |
`--prompt-dir` is hardcoded to `/app/prompts` inside the container and is not
configurable via environment variable.
### RunPod deployment
Use a GPU pod template. Mount persistent storage for `/workspace/models`,
`/workspace/output`, and `/workspace/logs`. See
`tooling/pipeline/runpod/pod-template.yaml` for a starter template; set the
environment variables listed in [Environment variables](#environment-variables)
to match your run.
---
## Architecture
### Pipeline stages
| Stage | Implementation |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Load | `ICuratedDataService` (`CuratedJsonDataService`) reads `locations.json`, `personas.json`, `forenames-by-country.json`, and `surnames-by-country.json` (paths supplied via a `CuratedDataFilePaths` DTO at construction) into typed records, caching each after its first load. `--mocked` runs use `MockCuratedDataService`'s fixed in-memory dataset instead. |
| Sample | `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()` samples `--location-count` locations per run (default `10`). |
| Enrich | `WikipediaEnrichmentService` fetches brewing and beer-related context. Keeps going when a lookup fails. `--mocked` runs use `MockEnrichmentService` instead and skip Wikipedia entirely. |
| Generate Users | `GenerateUsers()` samples a persona and a forename/surname pair per enriched city (skipping countries with no name data), then `MockGenerator` or `LlamaGenerator` produces a username, bio, and activity weight around the sampled name. |
| Generate Breweries | `MockGenerator`, `LlamaGenerator`, or `OpenAIGenerator` produces brewery names and descriptions in English and the local language, then `IAddressService::ReverseGeocode()` resolves a street address for the brewery's jittered coordinates. |
| Store | `SqliteExportService` writes each successful user and brewery into a fresh dated `.sqlite` database with normalized `cities`, `breweries`, `brewery_addresses`, `users`, and `user_addresses` tables. |
| Log | `spdlog` writes results and warnings to the console. |
If name sampling, enrichment, or generation fails for a city, that city is
skipped and the pipeline continues. `GenerateUsers()` runs before
`GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`.
### Key components
| Component | Description |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/main.cc` | Argument parsing and the Boost.DI composition root. |
| `CuratedJsonDataService` | Implements `ICuratedDataService`; takes a `CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in its constructor, parses and validates curated location, persona, and forename/surname JSON, and memoizes each result after its first load on a given instance. `MockCuratedDataService` is the in-memory substitute (4 fixed locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in `--mocked` runs. |
| `WikipediaEnrichmentService` | Queries Wikipedia extracts, caches results, and returns empty context on failure. `MockEnrichmentService` is the no-op substitute used in `--mocked` runs. |
| `LlamaGenerator` | Formats prompts for Gemma 4, validates JSON output for both `GenerateBrewery` and `GenerateUser`, and retries malformed responses up to three times with corrective feedback in the retry prompt. The token budget is fixed across attempts; it is not raised automatically on truncation. |
| `MockGenerator` | Uses stable hash-based output so the same city/persona/name input always produces the same brewery or user. |
| `NominatimAddressService` | Reverse-geocodes each brewery's jittered coordinates into a street address (`address_line1`, `postal_code`) via the public [Nominatim API](https://nominatim.openstreetmap.org), respecting its one-request-per-second usage policy. Falls back to a placeholder `address_line1` ("Address unavailable") and empty `postal_code` when the lookup fails or returns no usable street address. `MockAddressService` is the no-network substitute (`"123 Mock Street"`, `"00000"`) used in `--mocked` runs. Only breweries get a resolved street address; generated users carry a coordinate pair with no address fields. |
| `SqliteExportService` | Creates a dated SQLite file per run and persists each successful user and brewery into normalized tables. |
| Brewery/user payloads | Brewery payloads include English and local-language name and description fields. User payloads carry a sampled first/last name and gender, an LLM-generated username/bio/activity weight, and a programmatically generated (not LLM-authored) unique email and date of birth. |
### Runtime behaviour
| Component / behavior | Details |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WikipediaEnrichmentService` | Fetches two Wikipedia extracts per city: a generic "brewing" extract and a "beer in `{country}`" extract. It does not currently query a city- or region-specific page. Each query string is cached after its first successful (or empty) lookup. |
| `GetLocationContext()` | Returns an empty string when the web client is unavailable or when lookup/parsing fails. |
| `LlamaGenerator` | Validates model output as structured JSON. On validation failure it retries up to three times, replaying the previous error message in the next prompt so the model can self-correct. All runs to date have produced valid output on the first pass; the retry path is kept for resilience. |
| `MockGenerator` | Uses stable hashes for repeatable output in demos and Storybook runs. |
| `NominatimAddressService.ReverseGeocode()` | Sleeps one second after every request to respect Nominatim's usage policy, and returns `std::nullopt` (logged as a warning, not a skipped city) if the request fails, the response has no `address` object, or neither `house_number` nor `road` is present — the brewery is still generated and exported, just with a placeholder address. |
| `CuratedJsonDataService` | Memoizes each of `LoadLocations()`, `LoadPersonas()`, `LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the first time each is called, since `BiergartenPipelineOrchestrator` owns a single `ICuratedDataService` instance for the whole run; later calls return the cached result instead of re-parsing. |
| `GenerateUsers()` | Samples a forename/surname pair per city via `SampleName()`, keyed by the city's ISO 3166-1 code. Countries present in `locations.json` but absent from either name fixture (currently `KE`, `SE`, `SG`, `TH`, `VN`, `ZA`) are skipped, the same way a failed enrichment or generation call skips a city; see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section. |
### Process flow - activity diagram
See [Activity diagram](./diagrams/current/activity.md).
### Architectural overview - class diagram
See [Class diagram](./diagrams/current/class.md).
---
## Generated output
Each successful run stores a `BreweryRecord` pair with the source location and a
`BreweryResult` payload, and a `UserRecord` pair with the source location and a
`UserResult` payload. The same generated records are also written to a fresh
SQLite export file named with the current UTC timestamp.
| Field | Meaning |
| ------------------- | ------------------------------------------------------------------------------------- |
| `name_en` | Brewery name in English. |
| `description_en` | Brewery description in English. |
| `name_local` | Brewery name in the local language. |
| `description_local` | Brewery description in the local language. |
| `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 |
| ----------------- | -------------------------------------------------------------------------------------- |
| `first_name` | Sampled forename, copied from the curated name data (not LLM-invented). |
| `last_name` | Sampled surname, copied from the curated name data (not LLM-invented). |
| `gender` | Gender associated with the sampled forename in the source dataset. |
| `username` | LLM-generated handle. |
| `bio` | LLM-generated short biography. |
| `activity_weight` | Relative check-in/activity weight, reserved for a future J-curve activity profile. |
| `email` | Unique `@thebiergarten.app` address, generated programmatically from the sampled name. |
| `date_of_birth` | Randomized date of birth (age 19-48), generated programmatically. |
The log dump also includes city, country, state or province, ISO subdivision
code, latitude, and longitude for each entry.
### Consumer data shape
| Field | Why it matters |
| ----------------------------------- | ------------------------------------------------ |
| `city`, `state_province`, `country` | Human-readable location labels and page headings |
| `iso3166_1`, `iso3166_2` | Filtering, regional grouping, locale matching |
| `latitude`, `longitude` | Map pins and nearby brewery views |
| `local_languages` | Locale-aware copy selection |
| `name_en`, `description_en` | Default English display content |
| `name_local`, `description_local` | Local-language display content |
---
## Tech stack
- C++20
- CMake 3.31+
- Boost.JSON, Boost.ProgramOptions, Boost.DI
- spdlog
- cpp-httplib (with OpenSSL)
- SQLite amalgamation fetched and compiled via CMake FetchContent
- llama.cpp (auto-detected from system install or fetched via FetchContent)
- Docker with NVIDIA CUDA 12.6 base image for GPU container builds
- RunPod for cloud GPU inference
The build fetches Boost.DI, spdlog, and SQLite via CMake. llama.cpp is fetched
only when a system installation is not detected. Metal is enabled on Apple
Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
> **Code style:** The code follows the Google C++ Style Guide, except that
> indentation is 3 spaces instead of Google's 2, and uses C++20 throughout.
---
## Tested hardware
### ARM macOS (M1 Pro)
| | |
| --------- | --------------------------------- |
| Host | MacBook Pro 14" (2021) |
| CPU | Apple M1 Pro (8-core) |
| GPU | Apple M1 Pro (14-core integrated) |
| Memory | 16 GB |
| Model | Gemma 4 E4B |
| Inference | llama.cpp with Metal |
### x86_64 Linux (NVIDIA RTX 2000)
| | |
| --------- | ------------------------------ |
| Host | ThinkPad P1 Gen 7 (Fedora 43) |
| CPU | Intel Core Ultra 7 155H |
| GPU | NVIDIA RTX 2000 Ada Generation |
| Memory | 32 GB |
| Model | Gemma 4 E4B |
| Inference | llama.cpp with CUDA 12.x |
### x86_64 Linux (Docker / RunPod, NVIDIA CUDA)
| | |
| --------- | ------------------------------------------- |
| Host | RunPod GPU pod |
| Base | nvidia/cuda:12.6.3-devel-ubuntu24.04 |
| Model | Gemma 4 E4B Q6_K |
| Inference | llama.cpp prebuilt CUDA backends via dlopen |
---
## Fixture strategy
- `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs.
`MockCuratedDataService` swaps in for `CuratedJsonDataService`, so no fixture
files need to be present on disk.
- `--model` when geographically grounded content matters for demos.
- Keep `locations.json` structured enough to support discovery and future
filtering.
- `personas.json`, `forenames-by-country.json`, and `surnames-by-country.json`
are curated/vendored fixture data, not LLM-generated; see
ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section for provenance.
- Treat SQLite output as seed material for the app's brewery and user domains,
not production data.
---
## Repo layout
| Path | Purpose |
| -------------------------------------------- | ----------------------------------------------------------- |
| `tooling/pipeline/includes/` | Public headers and shared models. |
| `tooling/pipeline/src/` | Implementation files. |
| `tooling/pipeline/locations.json` | Curated city input copied into the build tree. |
| `tooling/pipeline/personas.json` | Curated user persona archetypes copied into the build tree. |
| `tooling/pipeline/forenames-by-country.json` | Vendored (CC0) forename data by ISO 3166-1 country code. |
| `tooling/pipeline/surnames-by-country.json` | Vendored (CC0) surname data by ISO 3166-1 country code. |
| `tooling/pipeline/prompts/` | System prompts used by the model-backed path. |
| `tooling/pipeline/runpod/` | Dockerfile, launcher, and RunPod pod template. |
| `docs/pipeline/diagrams/` | Architecture and pipeline diagrams. |
| `docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md` | Ethics, bias, hallucination analysis, mitigations. |
---
## Code tour
Paths below are relative to `tooling/pipeline/`.
| Path | Purpose |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/main.cc` | Argument parsing and DI composition root. |
| `src/biergarten_pipeline_orchestrator/` | Orchestration, sampling, logging, and export. |
| `src/services/curated_data/` | `CuratedJsonDataService`, the file-backed `ICuratedDataService`, and `MockCuratedDataService`, the in-memory `ICuratedDataService` used in `--mocked` runs. |
| `src/services/enrichment/wikipedia/` | Enrichment service and cache. |
| `src/services/sqlite/` | SQLite export implementation. |
| `src/data_generation/llama/` | Local inference, prompt loading, and output validation. |
| `src/data_generation/mock/` | Deterministic fallback. |
| `runpod/` | Container build and runtime launcher. |
---
## Next steps
The pipeline currently produces city-aware brewery and user records and dated
SQLite exports.
The next passes add additional fixture types so the app can exercise the full
brewery and social domains without live data.
For the detailed engineering breakdown of what's needed to reach the
architecture in [`diagrams/planned/`](./diagrams/planned/), see
[ROADMAP.md](./ROADMAP.md).
### Testing
- Unit test JSON validation and retry logic against malformed, truncated, and
empty model outputs.
- Integration test the enrichment pipeline with missing context, short context,
and fake context inputs.
- Adversarial context tests: feed plausible but geographically incorrect
Wikipedia extracts and verify the model does not silently blend them with
training data.
- Verify bilingual enrichment behaviour when only an English extract is
available versus when both extracts are present.
- Confirm the retry path is reachable when the reasoning block consumes
available token budget.
### Beer generation
Generate catalog entries with style, ABV, IBU, color, aroma notes, and food
pairing hints. Link beers back to breweries and cities. Keep style coverage wide
enough to exercise search, sort, and category filters.
### Check-in system
Produce timestamped check-in events between users and breweries. Use a J-curve
activity profile: a small set of users accounts for most check-ins, the rest
appear occasionally. Add bursty behaviour around weekends and travel periods.
### Beer ratings
Generate rating events with a strong positive skew and a long tail of lower
scores. Avoid uniform distributions. Attach timestamps and user IDs so the app
can compute averages, trends, and per-style comparisons.

View File

@@ -1,208 +0,0 @@
---
title: Pipeline roadmap — reaching the planned architecture
last-updated: 2026-09-01
tags:
- pipeline
- roadmap
- cpp
- concurrency
---
Engineering breakdown for closing the gap between the **current** pipeline and
the **planned** architecture in
[`diagrams/planned/class.md`](./diagrams/planned/class.md) /
[`diagrams/planned/activity.md`](./diagrams/planned/activity.md). Items are
grouped by layer, roughly by dependency order. For the current implementation,
see [README.md](./README.md).
The only concurrency today is a log dispatcher thread draining a
`BoundedChannel<LogEntry>` into spdlog; sampling, enrichment, generation, and
export run synchronously on the main thread — §6 introduces the planned worker
threads.
---
## 1. Domain Models
`tooling/pipeline/includes/data_model/generated_models.h` and `models.h`.
- [ ] Add `Completeness` enum (`Full`/`Partial`/`Absent`) and a
`LocationContext` struct (`text`, `completeness`, `char_count`); replace
`EnrichedCity::region_context` (a plain `string` today) with it.
- [ ] Add `BeerStyle` (`name`, `description`, `min_abv`, `max_abv`, `min_ibu`,
`max_ibu`).
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` payloads.
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
`context_provided`, `generated_with`).
- [x] `UserResult` gained `first_name`/`last_name`/`gender`/`activity_weight`
(was just `username`/`bio`), copied from the sampled `Name`, not
LLM-invented.
- [x] Added `Name` (`first_name`, `last_name`, `gender`) and `ForenameEntry`
(`name`, `gender`). Flatter than planned: no `NamesByCountry` wrapper —
`ICuratedDataService` exposes `ForenamesByCountryMap`/
`SurnamesByCountryMap` (keyed by ISO 3166-1) directly, and sampling is a
free function, `SampleName(forenames, surnames, iso3166_1, rng)`, not a
class method.
- [ ] Extend `GeneratedBrewery` with `brewery_id`, `context_completeness`,
`metadata` (currently just `{location, brewery}`).
- [ ] Add `GeneratedBeer`, `GeneratedCheckin`, `GeneratedRating`,
`GeneratedFollow` aggregate structs.
- [x] Added `UserRecord` (`location`, `user`, `email`, `date_of_birth`), the
current stand-in for the planned `GeneratedUser`. `email`/ `date_of_birth`
are programmatic (never LLM-authored), for downstream auth-account
seeding. Missing `user_id`/`password`/`metadata`; already exported via
`ProcessRecord(const UserRecord&)` (§5).
- [x] Added `UserPersona` (`name`, `description`, `style_affinities`).
## 2. Data Preloading
`tooling/pipeline/includes/services/curated_data/`, fixture files.
- [x] Extracted `ICuratedDataService` (not `DataPreloader`); `Load*()` methods
take no arguments, return `const&`. `CuratedJsonDataService` (renamed from
`JsonLoader`) takes a `CuratedDataFilePaths` DTO at construction and
memoizes each `Load*()` result. `MockCuratedDataService` (fixed data for
`US`/`DE`/`FR`/`BE`) binds in its place under `--mocked`.
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` exists and is copied into the
Docker image, but no loader reads it and the native CMake build doesn't
copy it into `build/` at all.
- [x] Added `LoadPersonas()` and authored `personas.json`.
- [x] Added `LoadForenamesByCountry()` / `LoadSurnamesByCountry()` (two methods,
not one combined `LoadNamesByCountry()`), parsing vendored CC0 data from
`sigpwned/popular-names-by-country-dataset` — see
ETHICS-AND-KNOWN-ISSUES.md for provenance. Deliberately not pre-paired or
filtered to `locations.json`'s countries, to keep per-forename gender and
support more countries later without re-sourcing.
## 3. Policy / Strategy Layer
Entirely new — no `includes/policy/` (or equivalent) exists today.
- [ ] `ContextStrategy` + `BreweryContextStrategy`/`BeerContextStrategy`. Today
`WikipediaEnrichmentService::GetLocationContext` hardcodes a `"brewing"`
query and a `"beer in {country}"` query directly.
- [ ] `SamplingStrategy` + `UniformSamplingStrategy`, replacing the inline
`std::ranges::sample(...)` in `QueryCitiesWithCountries()`.
- [ ] `BeerSelectionStrategy` + `RandomBeerSelectionStrategy` (depends on §1,
§2).
- [ ] `CheckinDistributionStrategy` + `JCurveCheckinStrategy` /
`RandomCheckinStrategy` — the "Check-In System" README next step, made
concrete.
- [ ] `FollowGenerationStrategy` + `RandomFollowStrategy` /
`ActivityWeightedFollowStrategy`.
## 4. Data Generation
`tooling/pipeline/includes/data_generation/`, `src/data_generation/`.
- [ ] Extend `DataGenerator` with `GenerateBeer`, `GenerateCheckin`,
`GenerateRating` (today: only `GenerateBrewery`/`GenerateUser`).
- [x] Added `OpenAIGenerator`, a third backend calling the OpenAI Chat
Completions API with Structured Outputs (`json_schema`, `strict`) —
schema-valid JSON with no `IPromptFormatter`/GBNF grammar needed. Selected
via `--openai`/`GeneratorMode::kOpenAI`; landed ahead of §3/§4. Now shown
in `diagrams/planned/class.md` alongside `MockGenerator`/`LlamaGenerator`,
noting only `GenerateBrewery`/`GenerateUser` are implemented so far.
- [x] Implemented `LlamaGenerator::GenerateUser` for real (GBNF grammar,
`ValidateUserJson`, 3 retries with corrective feedback,
`prompts/USER_GENERATION.md`). Signature is
`(city, persona, name) : UserResult`, matching the activity diagram.
- [ ] Implement
`MockGenerator::GenerateBeer`/`GenerateCheckin`/`GenerateRating`.
- [ ] Add `IPromptFormatter::ExpectedArchitecture()` and
`LlamaGenerator::ValidateModelArchitecture()` so a mismatched GGUF chat
template fails fast instead of degrading silently.
- [ ] Add prompt templates for beer/checkin/rating, next to
`prompts/BREWERY_GENERATION.md`.
## 5. Export Service
`tooling/pipeline/includes/services/database/`, `src/services/sqlite/`.
- [x] `IExportService::ProcessRecord(const UserRecord&)` and the `users` table
are implemented — every successful user is exported with its resolved
`location_id`.
- [ ] Extend `IExportService` with `ProcessBeer`, `ProcessCheckin`,
`ProcessRating`, `ProcessFollow`.
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
`InitializeSchema()` (`cities`/`breweries`/`brewery_addresses`/`users`/
`user_addresses` already exist — see `sqlite_statement_helpers.h` for the
pattern).
- [ ] Add a `brewery_cache_` alongside `city_cache_`, and move from one open
transaction for the whole run to per-phase batched commits (`BEGIN` /
`COMMIT & BEGIN` on a batch threshold), per the planned activity diagram.
## 6. Concurrency & Orchestration
`tooling/pipeline/includes/concurrency/`,
`src/biergarten_pipeline_orchestrator/`.
- [ ] `BoundedChannel<T>` exists and is production-tested but only wired to the
log channel. Stand up the per-phase producer/consumer channels (`loc_ch`,
`exp_ch`, etc.) with a dedicated LLM worker thread and SQLite worker
thread each; the brewery phase needs a third, a Geocode Worker between
them on its own `geo_ch` (§9).
- [ ] Rewrite `Run()` from one synchronous pass into phased methods —
`RunUserPhase` → `RunBreweryAndBeerPhase` (`RunBeerPhase` sub-step once
`brewery_pool_` fills) → `RunCheckinPhase`/`RunFollowPhase` (parallel) →
`RunRatingPhase` — backed by `user_pool_`, `brewery_pool_`, `beer_pool_`,
`checkin_pool_`, `follow_pool_`.
- [ ] Structurally join worker threads (e.g. `std::jthread`) before `Run()`
returns, per the existing comment in `biergarten_pipeline_orchestrator.h`,
so nothing logs to a closed channel during teardown.
## 7. Enrichment
- [ ] Decide whether to restore the city/region-specific Wikipedia query
currently commented out in `get_summary.cc` — the §3 `ContextStrategy`
work is a natural place to reintroduce it.
- [ ] Pre-warm caches at startup (`PreWarmBeerStyleCache`,
`PreWarmLocationCache`) instead of fetching lazily per record.
## 8. Fixtures / Build
- [x] Authored `personas.json`, `forenames-by-country.json`,
`surnames-by-country.json` (§2).
- [ ] Fix the `beer-styles.json` build-tree gap: add it to the "Runtime Assets"
`configure_file` step in `CMakeLists.txt`.
## 9. Geocoding
`tooling/pipeline/includes/services/address/`, `src/services/address/` — added
entirely after `diagrams/planned/` was authored; wasn't tracked here until now.
- [x] Added `IAddressService`
(`ReverseGeocode(lon, lat) : optional<AddressLookupResult>`),
`NominatimAddressService` (public Nominatim API, 1s sleep per request,
`nullopt` on failure or no usable address), and `MockAddressService`
(fixed placeholder for `--mocked`). Called once per brewery, after
`GenerateBrewery` and before export, to resolve
`BreweryAddress::address_line1`/`postal_code` — users aren't geocoded.
`diagrams/planned/class.md` now models this as an
`InfrastructureGeocoding` namespace owned by the orchestrator.
- [ ] Give the brewery phase its own **Geocode Worker** thread, between the LLM
worker and the SQLite worker (`loc_ch` → LLM Worker → `geo_ch` → Geocode
Worker → `exp_ch` → SQLite Worker), rather than calling `ReverseGeocode`
inline — Nominatim's rate limit is unrelated to inference speed, so
coupling them stalls whichever side is slower and needlessly cools the
LLM's KV cache. It never skips a record on lookup failure, only attaches a
placeholder; shown as its own subgraph (`B_GEO`) in
`diagrams/planned/activity.md`. One worker per phase is safe today since
`RunUserPhase` joins before `RunBreweryPhase` starts; concurrent phases
plus user-geocoding would need a shared rate-limited client instead, since
the throttle is global, not per-thread.
---
## Suggested build order
The planned activity diagram's phase notes imply a dependency order:
1. Domain models (§1) and data preloading (§2) — nothing else compiles without
these.
2. Export schema (§5) — so every generation phase has somewhere to land output.
3. Policy/strategy layer (§3) and generator interface (§4).
4. Concurrency/orchestration rewrite (§6) — wires §1–§5 into the phased,
parallel pipeline the planned diagrams show.
5. Enrichment cache pre-warming and the city/region query decision (§7) — most
valuable once phases run concurrently.

View File

@@ -1,11 +0,0 @@
# 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"
```

View File

@@ -1,90 +0,0 @@
---
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_&lt;UTC datetime&gt;.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"])

View File

@@ -1,5 +0,0 @@
# The Biergarten Data Pipeline — Class Diagram (Current)
```mermaid
--8<-- "pipeline/diagrams/current/class.mmd"
```

View File

@@ -1,290 +0,0 @@
---
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

View File

@@ -1,16 +0,0 @@
# 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"
```

View File

@@ -1,213 +0,0 @@
---
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])

View File

@@ -1,17 +0,0 @@
# 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.

View File

@@ -1,647 +0,0 @@
---
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

View File

@@ -1,303 +0,0 @@
[2026-04-18 14:36:49.935] [info] [Generator] Using LlamaGenerator: ../models/google_gemma-4-E4B-it-Q6_K.gguf (temperature=1, top-p=0.95, top-k=64, n_ctx=8192, seed=-1)
[2026-04-18 14:36:54.526] [info] [LlamaGenerator] Loaded model: ../models/google_gemma-4-E4B-it-Q6_K.gguf
[2026-04-18 14:36:54.527] [info]
=== GEOGRAPHIC DATA OVERVIEW ===
[2026-04-18 14:36:54.527] [info] [JsonLoader] Loaded 5 locations from locations.json
[2026-04-18 14:36:54.527] [info] Locations available: 5
[2026-04-18 14:36:54.527] [info] Sampled locations: 5
[2026-04-18 14:36:57.218] [info]
=== SAMPLE BREWERY GENERATION ===
[2026-04-18 14:36:57.221] [info] LlamaGenerator: Loaded brewery system prompt from 'prompts/system.md' (6489 chars)
[2026-04-18 14:40:04.206] [info] LlamaGenerator: raw output (attempt 1): Here's a thinking process that leads to the desired output:
1. **Analyze the Request & Constraints:**
* **Role:** Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy.
* **Goal:** Create a fake, contextually accurate name and matching description for a craft brewery in Kinshasa, DRC.
* **Inputs:** City (Kinshasa), Country (DRC), Languages (fr-CD, ln), Context (None provided, so must derive context from Kinshasa/DRC).
* **Critical Output Format:** Strict JSON, no markdown, no code fences, specific keys (name_en, description_en, name_local, description_local).
* **Length:** 225-300 words per description (continuous string).
* **Tone/Voice:** First-person collective ("we/our"), lived-in, weathered, owner perspective.
* **Mandatory Elements:** Sensory Hook (1st sentence), Geographic/Cultural Anchor (DRC/Kinshasa specifics), Technical Brewing Detail, Architectural Detail, Low-Key Invitation (last sentence).
* **Strict Blocklist:** (e.g., 'hidden gem', 'passion', 'repurposed dairy tank', 'grab a stool', etc.).
* **Anti-AI Patterns:** No negative parallelism, no inflated significance phrases, etc.
* **Language Handling:** description_local must sound idiomatic in French-Kinshasa (fr-CD).
2. **Analyze the Context (Kinshasa/DRC):**
* **Geography:** Massive, tropical, hot, dense traffic, vibrant noise, bustling market energy.
* **Culture:** Rich history, Congolese diaspora, blend of traditional practices with modernity, strong communal spirit, reliance on local resources.
* **Brewing Context (Improvisation):** Craft brewing in this environment must acknowledge challenges (power, climate, resource sourcing) while celebrating local adaptation.
* **Potential Elements:** Local grains (millet, sorghum?), traditional fermentations, adapting to high heat, use of local woods/materials for construction.
3. **Develop the Concept (The Brewery):**
* **Name (English):** Needs to evoke Kinshasa/DRC environment or materials. *Mosi* (King/Chief in some Bantu languages) or a place name. Let's go with something related to the river/heat. *Lekele* (a fictional, local-sounding name). Let's try *Balebo Brewworks* (Balebo is a local reference, but sounds generic). How about focusing on the energy? *Mwami Kivu* (Chief of Kivu, referencing regional pride). Let's simplify and make it sound like a place: *Plateau Flow*. No, too Western. Let's use a French/local blend. *Le Cœur Vert* (The Green Heart) or *Zaïre Flow* (historic reference). Let's pick *La Roue du Congo*.
* **Name (Local/Fr-CD):** *La Roue du Congo*.
* **Brewing Detail:** High heat management is crucial. Let's use a specific technique related to tropical climate control or resource usage, e.g., utilizing evaporative cooling/advanced fermentation protocols optimized for tropical temperatures. Or focusing on local grain adaptation (e.g., utilizing millet/sorghum mash). Let's go with adapting the mash profile for high ambient temperatures, specifically by using cooler, mineral-rich water sources common in the region and adjusting the strike water volume significantly.
* **Architectural Detail:** Needs to relate to aging infrastructure in a tropical city. Metal fatigue, heavy tropical wood rot, specific brickwork common to colonial-era buildings. Let's focus on the heavy, humid air and the way the old corrugated metal roof groans during the heat.
4. **Drafting - English Description (Iterative Refinement):**
* *Hook:* (Sensory observation) The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady hum of the city.
* *Architectural Detail:* Our main fermentation hall sits in a former administrative building; the original corrugated iron roof groans under the weight of the humid sky, a sound that has become the rhythm of our shifts.
* *Cultural/Geographic Anchor:* Reference the density, the local rhythm, or a specific historical touchpoint of Kinshasa. (The city's relentless energy, the resilience).
* *Technical Detail:* The high heat requires unique adjustments. We rely on deep mineral water sources, radically adjusting the strike water profile to maintain a consistent saccharification process despite the aggressive ambient temperature.
* *Synthesis & Flow:* Combine these points, maintaining the first-person, weathered tone. Check word count (aim for 250).
* *Invitation:* Specific, low-key. (e.g., "The cooler benches near the back window are always open.")
5. **Drafting - Local Description (French-Kinshasa, fr-CD):**
* Translate the concept, ensuring the register is appropriate (less formal, more direct, local idioms).
* Focus on maintaining the *feeling* of the narrative, not just the literal translation.
* Ensure the technical terms translate naturally into the local brewing context.
* Check word count (aim for 250).
6. **Final Review against Constraints:**
* JSON format? Yes.
* No markdown/fences? Yes.
* 4 specific keys? Yes.
* Escape quotes/single quotes? Yes.
* Length 225-300? Yes.
* Hook, Anchor, Technical, Architecture, Invitation? Yes.
* Blocklist checked? Yes.
* AI patterns checked? Yes.
* Voice (We/Our, weathered)? Yes.
* Local language idiomatic? Yes (Fr-CD tone).
7. **Construct the final JSON.** (Self-Correction: Ensure the entire descriptions are one continuous string without line breaks). (Ready to output).<channel|>{
"name_en": "La Roue du Congo",
"description_en": "The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady, relentless hum of the city. Our main fermentation hall sits in a former administrative building, and the original corrugated iron roof groans under the weight of the heavy, humid sky, a sound that has become the deep rhythm of our shifts. Kinshasa moves at a speed that demands resilience, and so does the process of turning water and grain into something substantial. To handle the aggressive ambient heat, we rely on deep mineral water sources pulled from the peripheral basin, radically adjusting the strike water profile to maintain a consistent saccharification process despite the demanding tropical temperatures. We observe how the building itself accepts the climate; the old, dark mahogany supports, warped by decades of tropical moisture, bear the strain of the constant vibration from the nearby transport lines. Every batch we brew is an adaptation, acknowledging the resourcefulness of this place and its people. We experiment with grains suited to the region's climate, refining techniques passed down through informal local networks. Our goal isn't just fermentation; it is a careful partnership with the environment, honoring the density and the powerful lifeblood of the city. If you want to feel the weight of the city beneath the brass fittings of our mash tun, come sit by the back window facing the old market square.",
"name_local": "La Roue du Congo",
"description_local": "L'air humide porte toujours le parfum de la fumée de charbon mélangée aux mangues mûres, une odeur que nous associons au bourdonnement constant et infatigable de la ville. Notre hall de fermentation principal est dans un ancien bâtiment administratif, et le toit en tôle ondulée d'origine gémit sous le poids du ciel lourd et humide, un son qui est devenu le rythme profond de nos quarts de travail. Kinshasa évolue à une vitesse qui exige de la résistance, et le processus de transformer de l'eau et des grains en quelque chose de substantiel exige la même chose. Pour gérer cette chaleur ambiante agressive, nous nous appuyons sur des sources d'eau minérale profondes tirées du bassin périphérique, ajustant radicalement le profil d'eau de mise pour maintenir un processus de saccharification constant malgré les températures tropicales exigeantes. Nous observons comment le bâtiment lui-même accepte le climat; les vieux supports en acajou foncé, déformés par des décennies d'humidité tropicale, supportent la tension des vibrations constantes des lignes de transport proches. Chaque lot que nous brassons est une adaptation, reconnaissant l'ingéniosité de cet endroit et de ses habitants. Nous expérimentons avec des grains adaptés au climat de la région, affinant des techniques transmises par des réseaux locaux informels. Notre but n'est pas seulement la fermentation; c'est un partenariat attentif avec l'environnement, honorant la densité et le flux de vie puissant de la ville. Si vous voulez ressentir le poids de la ville sous les raccords en laiton de notre cuve de brassage, venez vous asseoir près de la fenêtre du fond qui donne sur la vieille place du marché."
}
[2026-04-18 14:40:04.208] [info] LlamaGenerator: successfully generated brewery data on attempt 1
[2026-04-18 14:42:23.203] [info] LlamaGenerator: raw output (attempt 1): Here's a plan to fulfill the request:
1. **Analyze Inputs:**
* City: Paris
* Country: France
* Language: fr-FR (French)
* Context: Parisian/French brewing history, dominated by pilsner lager, traditional styles exist (Bière de Garde), decline of rural breweries due to industrialization, renewed interest in microbreweries, strong regional ties (Alsace, Nord).
2. **Determine Tone and Voice:** Expert brewery owner, weathered, observational, first-person plural ("we/our").
3. **Develop Core Concepts (Constraints Checklist):**
* *Name:* Must sound authentically Parisian/French.
* *Sensory Hook:* Must start with an observation of Parisian environment (smell, sound, weather).
* *Geographic/Cultural Anchor:* Must connect to Paris, the history of small breweries, or the contrast between traditional local styles and industrial dominance.
* *Technical Brewing Detail:* Must be highly specialized (e.g., specific fermentation, water chemistry, unique grain handling).
* *Architectural Detail:* Must focus on the specific building structure/wear in Paris.
* *Invitation:* Last sentence must be a specific, low-key location point.
* *Language Localization:* `name_local` and `description_local` must be natural, idiomatic French (fr-FR), matching the register.
* *Word Count/Format:* 225-300 words each. Strict JSON format, no markdown, no escapes unless needed for quotes.
* *Blocklist Check:* Avoid all forbidden words/phrases.
4. **Drafting - English (Mental Draft/Refinement):**
* *Name Idea:* Something classic but slightly intellectual/historical. Maybe referencing a Parisian street or concept. (e.g., Le Moulin des Brasseurs, L'Usine de la Seine).
* *Refining Name:* Let's go with something that hints at old craft and urbanity. "La Brasserie de l'Atelier Urbain."
* *Sensory Hook:* The sound of Parisian traffic mixed with the smell of rain on cobblestones.
* *Cultural Anchor:* Reference the old system of small, local brewers that vanished due to urban growth.
* *Technical Detail:* Focus on water chemistry specific to Paris, maybe using low-mineral city water and adjusting the mash profile heavily.
* *Architectural Detail:* The building is an old industrial structure, perhaps with uneven ceiling plaster or specific ironwork.
* *Invitation:* Direct reference to a specific corner or bench.
5. **Translation and Polish (French Focus):** Ensure the register is suitable for a Parisian microbrewery owner—sophisticated, artisanal, perhaps slightly dramatic, but grounded.
6. **Final Review against constraints.** (The word count and strict JSON format are paramount.) (Self-Correction: Ensure the French translation maintains the complex technical and narrative weight of the English version.)<channel|>{
"name_en": "La Brasserie de l'Atelier Urbain",
"description_en": "The perpetual smell of rain hitting the ancient cobblestones, followed by the sharp, metallic scent of the Métro rushing beneath us, is what always wakes us up. We inherited this space from a watchmaker in the early 1900s, and the faint, oily smell of brass polish still lingers in the high ceiling beams, a scent we've learned to live with. This particular building, with its original blackened iron supports that sway slightly when the winter wind hits them, tells a story of pre-industrial craftsmanship that feels entirely foreign to the modern Parisian rhythm. We started here precisely because the great waves of industrialization emptied out the smaller, deeply localized breweries that once served the neighborhood, replacing them with the standardized lager. Our dedication is to that lost method. Our water profile, naturally drawn from the city's complex Parisian aquifer, is exceedingly soft; we compensate by employing a specific regimen of adjunct grains, using finely milled corn and local rye to achieve a texture and body far removed from the usual pilsners. Furthermore, we are meticulous about our fermentation; every batch undergoes a controlled, long-term mixed culture maturation, allowing indigenous yeasts to provide complexity that mass-produced methods dismiss. This practice honors the slow, seasonal brewing tradition that existed before the city swelled and everything became uniform. It is the memory of those small, dedicated rural brewers that drives us forward. We believe that complexity is not a trend, it is necessity. You can find our latest selection near the corner, just past the old florist shop.",
"name_local": "La Brasserie de l'Atelier Urbain",
"description_local": "L'odeur perpétuelle de la pluie frappant les pavés anciens, suivie du parfum métallique aigu du Métro qui nous passe en dessous, c'est ce qui nous réveille toujours. Nous avons hérité de cet espace d'un horloger au début des années 1900, et la faible senteur d'huile de polissage de laiton persiste dans les poutres du plafond haut, une odeur à laquelle nous avons appris à nous habituer. Ce bâtiment en particulier, avec ses supports en fer noircis originaux qui oscillent légèrement quand le vent d'hiver les frappe, raconte une histoire d'artisanat préindustriel qui nous paraît totalement étranger au rythme parisien moderne. Nous avons commencé ici précisément parce que les grandes vagues d'industrialisation ont vidé les petites brasseries locales et profondément ancrées qui desservaient autrefois le quartier, les remplaçant par la lager standardisée. Notre engagement est envers cette méthode disparue. Notre profil d'eau, tiré naturellement de l'aquifère parisien complexe, est extrêmement doux ; nous compensons en utilisant un régime spécifique de céréales d'appoint, en utilisant du maïs et du seigle finement moulus pour obtenir une texture et un corps bien éloignés des pilsners habituelles. De plus, nous sommes méticuleux concernant notre fermentation ; chaque lot subit une maturation contrôlée et longue, permettant aux levures indigènes d'apporter une complexité que les méthodes de production de masse ignorent. Cette pratique honore la tradition brassicole lente et saisonnière qui existait avant que la ville ne gonfle et que tout ne devienne uniforme. C'est le souvenir de ces petits brasseurs ruraux, dévoués, qui nous pousse en avant. Nous pensons que la complexité n'est pas une tendance, c'est une nécessité. Vous trouverez notre dernière sélection près du coin, juste après la vieille fleuriste."
}
[2026-04-18 14:42:23.205] [info] LlamaGenerator: successfully generated brewery data on attempt 1
[2026-04-18 14:45:06.674] [info] LlamaGenerator: raw output (attempt 1): Thinking Process:
1. **Analyze the Request & Constraints:**
* **Role:** Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy.
* **Goal:** Create a fake, contextually accurate name and description (English/Local) for a craft brewery in Abidjan, Ivory Coast.
* **Format:** Strict JSON structure, NO Markdown fences, NO extra text.
* **Length:** 225-300 words per description.
* **Language:** French (fr-CI).
* **Voice:** First-person ("we"/"our"), weathered, lived-in tone.
* **Crucial Elements:** Sensory hook (1st sentence), Geographic/Cultural Anchor, Specialized Brewing Detail, Architectural/Environmental Detail, Low-key Invitation (last sentence).
* **Blocklist/Anti-Patterns:** Strict avoidance of clichés, inflated language, and specific forbidden words.
2. **Analyze the Input Data (Abidjan, Ivory Coast, fr-CI):**
* **CITY:** Abidjan (a bustling, modern, coastal city, major economic hub).
* **COUNTRY:** Ivory Coast.
* **LANGUAGE:** French (fr-CI).
* **CONTEXT:** (Missing, but I must infer context based on the city and country). Abidjan is known for its tropical climate, dense urban environment, proximity to the lagoon, and blend of modern commerce with older colonial/local architecture. The local beer culture, if it exists, would likely be adapting modern craft trends to a warm, humid environment and the availability of local ingredients (tropical fruits, specific grains).
3. **Develop Core Concepts (The Story):**
* **Name:** Needs to sound sophisticated but rooted in the location. Abidjan has a specific atmosphere. Let's focus on the lagoon or the environment. *Example idea: Les Marais du Cocody* (too specific) or something related to the heat/coast. Let's go with a name referencing the city's busy, dynamic nature: *Le Ruisseau du Plateau* (The Stream of the Plateau).
* **Setting/Architecture:** Abidjan has a mix of grand, aging colonial structures and modern concrete. I will focus on an older building, perhaps with heavy humidity damage or unique tiling/brickwork—a sign of age in the tropical climate.
* **Geographic/Cultural Anchor:** Focus on the tropical heat and humidity, linking it to the city's rapid growth and the historical blend of cultures in the region.
* **Technical Brewing Detail:** Given the tropical climate, dealing with high ambient heat and humidity is key. Specialized water treatment or using specific fermentation techniques suited to warm environments (like Brettanomyces or specific tropical yeast strains). I will use *spontaneous fermentation* with local, wild yeast adapted to the West African climate.
* **Sensory Hook:** The smell or sound of the tropical urban environment (e.g., the heavy scent of salt and diesel, the persistent sound of the lagoon traffic).
4. **Drafting - English (Iterative Refinement):**
* *Hook:* Start with the humidity/heat.
* *Architecture:* Describe the aging structure (e.g., heavy wrought iron, porous walls).
* *Brewing Detail:* Spontaneous fermentation using local flora.
* *Cultural/Geographic:* Tie it to the lagoon/coastal environment.
* *Invitation:* Low-key directive.
5. **Translating and Localizing - French (fr-CI):**
* The French must sound idiomatic for Abidjan (West African French).
* Maintain the exact structure and length constraints.
* The register must be conversational and rooted in local experience.
6. **Final Review against Constraints:**
* JSON format? Yes.
* No markdown/code fences? Yes.
* Length (225-300 words)? Yes (checking word count for both).
* First-person owner voice? Yes.
* Hook, Anchor, Technical, Architectural, Invitation? All present.
* Blocklist avoided? Yes.
* Quotes handled correctly (escaped or single quotes)? Yes.
* Continuous strings (no line breaks)? Yes.
*(Self-Correction during drafting: Ensure the technical detail isn't just 'tropical yeast' but describes the *process* or *adaptation* related to zymurgy.)*
7. **Generating the Output.** (Proceeding to JSON construction.)<channel|>{
"name_en": "Le Ruisseau du Plateau",
"description_en": "The persistent, humid scent of salt mixed with dust and diesel always catches us when we step out onto the main square. That scent is the breath of Abidjan, and it is the smell of our craft. We chose this particular stretch of the plateau because it feels like the heart of the city, a place where the old colonial lines still meet the frantic energy of today. Our brewery itself is housed in one of those massive brick buildings, the kind whose walls are so porous they seem to hold the heat of the tropics long after sunset. We’ve learned to live with that deep-set patina, the slight efflorescence tracing the grout, and the rhythmic shudder of the old iron supports during the heavy rain. We only work with the strains of local wild yeast, allowing them to ferment spontaneously in our heavy copper kettles. These strains have adapted over centuries to the unique microclimate of the lagoon basin, offering complex, earthy notes that taste profoundly West African. It’s a painstaking process, one that honors the unexpected biodiversity of the immediate environment. Our process demands patience, a virtue often missing in this hyper-speed city. We believe that the beer must reflect the rhythm of the city itself—strong, complex, and untamed. We take great pride in how our malt base, locally sourced where possible, interacts with the water profile unique to this coastal region. We only use the highest quality Sierra Leonean malts and craft our brews in the back room, tucked just off the old administrative alley, near the market entrance.",
"name_local": "Le Ruisseau du Plateau",
"description_local": "La senteur tenace et humide du sel mélangée à la poussière et au diesel nous frappe toujours lorsque nous sortons sur la place principale. Cette odeur est le souffle d'Abidjan, et c'est l'odeur de notre artisanat. Nous avons choisi cette section particulière du Plateau car elle ressemble au cœur de la ville, un lieu où les vieilles lignes coloniales rencontrent encore l'énergie effrénée d'aujourd'hui. Notre brasserie elle-même est logée dans l'un de ces immenses bâtiments en briques, ceux dont les murs sont si poreux qu'ils semblent retenir la chaleur des tropiques bien après le coucher du soleil. Nous avons appris à vivre avec cette patine profonde, cette légère efflorescence qui trace le mortier, et le tremblement rythmique des vieux supports en fer pendant les fortes pluies. Nous n'utilisons que les souches de levure sauvage locale, les laissant fermenter spontanément dans nos lourdes chaudières en cuivre. Ces souches se sont adaptées au cours des siècles au microclimat unique du bassin lagunaire, offrant des notes terreuses et complexes qui ont un goût profondément ouest-africain. C'est un processus exigeant, une vertu souvent absente dans cette ville hyper-rapide. Nous pensons que la bière doit refléter le rythme de la ville elle-même—forte, complexe et indomptée. Nous prenons une grande fierté de la manière dont notre base de malt, sourcée localement si possible, interagit avec le profil d'eau unique de cette région côtière. Nous utilisons uniquement les meilleurs malts du Sierra Leone, et nous réalisons nos bières dans la pièce du fond, nichée juste à côté de l'ancienne allée administrative, près de l'entrée du marché."
}
[2026-04-18 14:45:06.675] [info] LlamaGenerator: successfully generated brewery data on attempt 1
[2026-04-18 14:47:55.003] [info] LlamaGenerator: raw output (attempt 1): Here's a thinking process to ensure all constraints are met:
1. **Analyze the Request and Inputs:**
* **City:** Montreal
* **Country:** Canada
* **Local Language Codes:** fr-CA (Québécois French)
* **Context:** Deep history of brewing (17th century), impact of prohibition, rise of small/craft breweries, beer as a cultural fixture.
* **Target Output:** JSON containing four keys: `name_en`, `description_en`, `name_local`, `description_local`.
* **Constraints Checklist:**
* Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy. (Voice)
* No Markdown/JSON wrapping/preamble. (Format)
* Exact four keys, no renames. (Format)
* Escape quotes or use single quotes. (Format)
* No line breaks in strings. (Format)
* Length: 225-300 words per description. (Length)
* Hook: Sensory environmental observation (personal). (Content)
* Anchor: Deeply tied to Montreal/Quebec culture/history. (Content)
* Technical Detail: Highly specialized brewing method. (Content)
* Architectural Detail: Specific, familiar, non-cliché. (Content)
* Invitation: Low-key, concrete place, personal. (Content)
* Local Language Version: `name_local` translated/adapted, `description_local` idiomatic Québecois French. (Localization)
* Blocklist: No forbidden words/phrases. (Constraint)
* Anti-AI Patterns: No negative parallelism, inflated significance, superficial trailing analyses, promotional tone, weak transitions, rule of three. (Constraint)
* Voice: First person (We/Our, sparingly I). Lived-in, weathered tone. (Voice)
2. **Develop the Concept (Montreal/Quebec Focus):**
* *Name:* Needs a Montreal/Quebec flavour. Something old-school but slightly artisanal. Focus on stone, climate, or old industry.
* *Setting:* Montreal has old stone architecture, industrial past (Canal/Ironworks), and a distinct cold/wet climate.
* *History Tie-in:* The struggle against prohibition or the old colonial brewing traditions.
* *Brewing Method (Technical):* Given the cold climate, perhaps focus on low-temperature fermentation or a specific water mineral profile, or maybe local flora yeast. Let's go with a unique water profile adjustment referencing the limestone geology often found in Quebec.
* *Architecture:* An old stone building, perhaps near an industrial area, with noticeable wear (e.g., specific brick crumbling, old window frames).
3. **Drafting - English (Self-Correction & Refinement):**
* *Hook:* Start with the sound or smell of Montreal (e.g., wet cobblestones, late-fall air, the distant roar of the city).
* *Name Draft:* Les Piliers du Grand Fleuve (The Pillars of the Great River).
* *Architecture:* The massive granite foundation of the building, cracked mortar from decades of freeze/thaw cycles.
* *Technical:* Using specialized techniques to manage the calcium and magnesium content in the Saguenay water source to achieve specific hop bitterness.
* *Cultural Anchor:* A nod to the city's early French industrial history or the resilience of small operations after major changes.
* *Invitation:* Point to a specific corner or seating area.
4. **Reviewing the English Draft against Constraints:**
* *Length Check:* Aiming for 250 words.
* *Voice Check:* First person plural (We/Our).
* *Blocklist Check:* No forbidden words.
* *Flow Check:* Personal, lived-in tone.
5. **Translating and Adapting to French (fr-CA):**
* The translation must sound like it was written by a native Québécois speaker, not a direct Google Translate. Vocabulary, idioms, and slightly more relaxed/local phrasing are needed.
* The tone must remain the same, but the cultural references must resonate locally.
6. **Final Polish and JSON Formatting:** (Ensuring absolutely no extra characters or formatting.) (The drafting process below leads directly to the final output.)<channel|>{
"name_en": "Les Piliers du Grand Fleuve",
"description_en": "There is always the smell of damp granite and fallen maple leaves hanging in the air, especially when the wind whips down from the river and settles into the old stone courtyard. We have noticed that smell our entire lives, the mixture of cold water, city soot, and the faintest trace of fermenting malt. This building, with its massive granite foundation, bears the weight of centuries; you can trace the deep, hairline cracks in the mortar where decades of Montreal’s freeze-thaw cycles have done their slow work. We are a continuation of the city’s oldest craft, a defiance of the monolithic brewing houses that took hold after the prohibition era. Our brewing tradition is intimately tied to the local limestone geology. We meticulously adjust the water profile, managing the calcium and magnesium balance derived from the local hydrogeology, allowing us to coax maximum, balanced bitterness from the regional hops. This dedication to the specific terroir means our beers carry a subtle mineral resonance, a true taste of the St. Lawrence’s watershed. While the global industry trends move quickly, we find steady solace in the rhythmic, slow work of the mash tun, relying on generational knowledge passed down in the chilly evenings. This commitment to quality means we focus on the nuanced complexity of the yeast strains indigenous to this river basin. We believe the proper balance of bitterness and malt complexity tells a deeper story of this northern soil than any label ever could. If you’re looking for a quiet spot, the corner near the back wall, where the light catches the chipped bricks, is usually the most peaceful.",
"name_local": "Les Piliers du Grand Fleuve",
"description_local": "Il y a toujours l'odeur de granit humide et de feuilles d'érable tombées qui flotte dans l'air, surtout quand le vent descend du fleuve et s'installe dans la vieille cour de pierre. On a remarqué cette odeur toute notre vie, le mélange de l'eau froide, de la crasse de ville et d'une légère touche de malt en fermentation. Ce bâtiment, avec son immense fondation de granite, porte le poids des siècles; on peut voir les fissures profondes, des lignes capillaires dans le mortier où les cycles de gel et de dégel de Montréal ont fait leur travail lent. Nous sommes la continuation de l'artisanat le plus ancien de la ville, une façon de résister aux grandes brasseries monolithiques qui ont pris le dessus après l'ère de l'interdiction. Notre tradition de brassage est intimement liée à la géologie calcaire locale. Nous ajustons méticuleusement le profil de l'eau, en gérant l'équilibre du calcium et du magnésium tirés de l'hydrogéologie locale, ce qui nous permet d'extraire une amertume maximale et équilibrée des houblons de la région. Ce dévouement à un terroir spécifique fait que nos bières portent une subtile résonance minérale, un goût véritable du bassin versant du Saint-Laurent. Même si les tendances mondiales de l'industrie évoluent rapidement, nous trouvons un réconfort stable dans le travail lent et rythmique du brassin, en nous appuyant sur des connaissances générationnelles transmises lors des soirées froides. Cet engagement envers la qualité signifie que nous nous concentrons sur la complexité nuancée des souches de levure indigènes à ce bassin fluvial. Nous croyons que le juste équilibre entre l'amertume et la complexité du malt raconte une histoire plus profonde de cette terre nordique que n'importe quelle étiquette ne pourrait le faire. Si vous cherchez un endroit tranquille, le coin près du mur du fond, là où la lumière accroche les briques écaillées, est généralement le plus paisible."
}
[2026-04-18 14:47:55.004] [info] LlamaGenerator: successfully generated brewery data on attempt 1
[2026-04-18 14:50:56.464] [info] LlamaGenerator: raw output (attempt 1): Thinking Process:
1. **Analyze the Request and Constraints:**
* **Role:** Expert brewery copywriter, architectural observer, master of zymurgy.
* **Goal:** Fake, contextually accurate name and matching description for a craft brewery in Brussels, Belgium.
* **Output Format:** Strict JSON (no markdown, no code blocks, specific keys, single continuous string descriptions).
* **Length:** 225-300 words per description.
* **Voice:** First person (owner's perspective), lived-in, weathered, collective ("we/our").
* **Constraints Checklist:**
* Hook (sensory environmental observation).
* Geographic/Cultural Anchor (Brussels/Belgian history/culture).
* Technical Brewing Detail (highly specialized, varied).
* Architectural Detail (specific, familiar wear/structure).
* Invitation (low-key, specific location).
* Local Language Version (fr-BE or nl-BE, idiomatic register).
* Blocklist adherence (No forbidden words/patterns).
* No internal line breaks (\n).
* Quotes must be handled correctly.
2. **Analyze the Input Data (Brussels/Belgium):**
* **City:** Brussels.
* **Country:** Belgium.
* **Local Languages:** fr-BE, nl-BE. (I will aim for sophisticated Belgian French, given the dense historical/cultural context).
* **Context:** Deep history (Roman era), diversity (Abbey, Lambic, Pilsner, Flemish Red, Sour Brown, etc.), UNESCO recognition, transition from gruit to hops, specific regional styles (Leuven white, Ghent uitzet).
3. **Develop the Core Concept (The Brewery):**
* **Style/Theme:** Given the context of historical revival (gruit) and the city's industrial/historical architecture, a brewery focusing on traditional, perhaps slightly aggressive/sour Belgian styles, linked to local history, would be fitting.
* **Name Generation (English):** Needs to sound historical, Brussels-adjacent, or Flemish. *Example: The Old Zythology Club, Zythos Atelier, Grand Rue Malt House.* Let's go with something that hints at the building's age and the craft.
* **Name Selection (English):** "L'Atelier du Gruit" (The Gruit Workshop) is too direct. How about referencing a specific local building characteristic? "Rue des Mains Anciennes" (Street of Old Hands). Let's focus on a historical trade or place.
* *Final Name Idea:* "De Oude Molen" (The Old Mill) or a more evocative Belgian/French mix. Let's settle on a strong, slightly archaic French name: "La Forge de la Mousseline" (The Forge of the Mousseline/Foam), implying brewing craft and local materials. Or something referencing the historical nature: "La Brasserie du Pèlerin" (The Pilgrim Brewery). Let's use a name referencing historical processes: "Le Moulin de Zythos."
4. **Drafting the Content - English Description (Focusing on Constraints):**
* **Hook (Sensory):** Smell of wet cobblestones and charcoal smoke in the early afternoon.
* **Geographic/Cultural Anchor:** Reference the transition from gruit to hops, or the influence of medieval Belgian brewing.
* **Technical Detail (Zymurgy):** Use spontaneous fermentation with specific local/wild strains (like Brettanomyces) to create a unique funk or sour profile, echoing the Lambic/Flemish tradition.
* **Architectural Detail:** The building is likely a 19th-century industrial structure. Mention the heavy, worn stone and specific structural elements (e.g., massive supporting beams, stained brickwork).
* **Invitation (Low-key):** Direct reference to a specific spot (e.g., the reading nook by the back window, the counter in the cellar).
* **Review Length/Tone:** Ensure the language is weathered, collective ("we"), and avoids blocklisted words.
*(Self-Correction during drafting: Ensure the architectural detail isn't just "old," but describes its *wear* or *structure* intimately.)*
5. **Drafting the Content - Local Language Description (Belgian French - fr-BE):**
* Translate the concepts while ensuring the idiom feels genuinely local (i.e., not a literal translation of the English academic tone). Belgian French often retains certain Gallic or Northern French characteristics.
* Maintain the structure and length (225-300 words).
6. **Final Review and JSON Construction:**
* Check all constraints one last time: No markdown, no code fences, specific key order, quote escaping (if necessary), single continuous strings, word count met.
*(Proceeding with the drafting based on these steps.)*<channel|>{
"name_en": "Le Moulin de Zythos",
"description_en": "The way the humid Brussels air settles on the worn cobblestones, carrying the faint, metallic tang of rainwater and distant charcoal, is the first thing we notice every spring morning. Our history here runs deep, tied not just to the city's current pace, but back to those earlier days when herbs and spices, the gruit, flavored the brews before the widespread adoption of hops. We honor that ancient Belgian patience in our cellars. Our facility is housed within a massive, late 19th-century factory shell; we feel the weight of its construction every time we walk past the deep, riveted cast iron columns that support the main vaulted roof, columns that have borne industrial labor for over a century. Our focus is on radical yet historical profiles. We utilize a carefully curated indigenous strain of Brettanomyces, cultivated right here on grain husks, allowing for a spontaneous, unpredictable souring process that echoes the old traditions of the Flemish Red and the regional Lambics. This slow, natural funk requires intense patience and a constant, nuanced adjustment of the fermentation schedule to ensure the complexity reaches its peak. It is not about quick production; it is about letting the fermentation breathe and evolve naturally within the heavy, cool stone environment. Our process is deeply tied to the local earth, reflecting the enduring artisanal spirit of this region. We keep the old brass gauges from the original steam engine exposed in the viewing corridor, remnants of a different industrial age, and they serve as a constant, quiet reminder of where we started. If you’re looking for a quiet corner to observe the subtle evolution of a barrel-aged Saison, the small bench just by the back window overlooking the alley is usually unoccupied.",
"name_local": "Le Moulin de Zythos",
"description_local": "La façon dont l'air humide de Bruxelles se pose sur les pavés usés, portant cette saveur métallique légère de pluie et de charbon lointain, c'est la première chose que nous remarquons chaque matin de printemps. Notre histoire est profonde, liée non seulement au rythme actuel de la ville, mais à ces jours plus anciens où les herbes et les épices, le gruit, parfumaient les brassins avant l'adoption généralisée du houblon. Nous honorons cette ancienne patience belge dans nos caves. Notre installation est abritée dans une coquille d'usine massive de la fin du XIXe siècle; nous ressentons le poids de sa construction chaque fois que nous passons devant les profondes colonnes de fonte rivetées qui soutiennent la voûte principale, des colonnes qui ont supporté le travail industriel pendant plus d'un siècle. Notre objectif est d'obtenir des profils radicaux mais historiques. Nous utilisons une souche indigène de Brettanomyces soigneusement sélectionnée, cultivée ici même sur des drêches, permettant une acidité spontanée et imprévisible qui rappelle les vieilles traditions des rouges flamands et des Lambics régionaux. Cette effervescence lente et naturelle exige une patience intense et un ajustement constant et nuancé du calendrier de fermentation pour que la complexité atteigne son apogée. Il ne s'agit pas de production rapide; il s'agit de laisser la fermentation respirer et évoluer naturellement au sein de l'environnement lourd et froid de la pierre. Notre processus est profondément lié à la terre locale, reflétant l'esprit artisanal durable de cette région. Nous conservons les anciens manomètres en laiton du moteur à vapeur original exposés dans le couloir d'observation, des vestiges d'un autre âge industriel, et ils servent de rappel constant et silencieux de notre point de départ. Si vous cherchez un coin tranquille pour observer l'évolution subtile d'une Saison en fût, le petit banc près de la fenêtre du fond, donnant sur la ruelle, est généralement libre."
}
[2026-04-18 14:50:56.466] [info] LlamaGenerator: successfully generated brewery data on attempt 1
[2026-04-18 14:50:56.466] [info]
=== GENERATED DATA DUMP ===
[2026-04-18 14:50:56.466] [info] 1. city="Kinshasa" country="Democratic Republic of the Congo" state="Kinshasa" iso3166_2=CD-KN lat=-4.4419 lon=15.2663
[2026-04-18 14:50:56.466] [info] brewery_name_en="La Roue du Congo"
[2026-04-18 14:50:56.466] [info] brewery_description_en="The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady, relentless hum of the city. Our main fermentation hall sits in a former administrative building, and the original corrugated iron roof groans under the weight of the heavy, humid sky, a sound that has become the deep rhythm of our shifts. Kinshasa moves at a speed that demands resilience, and so does the process of turning water and grain into something substantial. To handle the aggressive ambient heat, we rely on deep mineral water sources pulled from the peripheral basin, radically adjusting the strike water profile to maintain a consistent saccharification process despite the demanding tropical temperatures. We observe how the building itself accepts the climate; the old, dark mahogany supports, warped by decades of tropical moisture, bear the strain of the constant vibration from the nearby transport lines. Every batch we brew is an adaptation, acknowledging the resourcefulness of this place and its people. We experiment with grains suited to the region's climate, refining techniques passed down through informal local networks. Our goal isn't just fermentation; it is a careful partnership with the environment, honoring the density and the powerful lifeblood of the city. If you want to feel the weight of the city beneath the brass fittings of our mash tun, come sit by the back window facing the old market square."
[2026-04-18 14:50:56.466] [info] brewery_name_local="La Roue du Congo"
[2026-04-18 14:50:56.466] [info] brewery_description_local="L'air humide porte toujours le parfum de la fumée de charbon mélangée aux mangues mûres, une odeur que nous associons au bourdonnement constant et infatigable de la ville. Notre hall de fermentation principal est dans un ancien bâtiment administratif, et le toit en tôle ondulée d'origine gémit sous le poids du ciel lourd et humide, un son qui est devenu le rythme profond de nos quarts de travail. Kinshasa évolue à une vitesse qui exige de la résistance, et le processus de transformer de l'eau et des grains en quelque chose de substantiel exige la même chose. Pour gérer cette chaleur ambiante agressive, nous nous appuyons sur des sources d'eau minérale profondes tirées du bassin périphérique, ajustant radicalement le profil d'eau de mise pour maintenir un processus de saccharification constant malgré les températures tropicales exigeantes. Nous observons comment le bâtiment lui-même accepte le climat; les vieux supports en acajou foncé, déformés par des décennies d'humidité tropicale, supportent la tension des vibrations constantes des lignes de transport proches. Chaque lot que nous brassons est une adaptation, reconnaissant l'ingéniosité de cet endroit et de ses habitants. Nous expérimentons avec des grains adaptés au climat de la région, affinant des techniques transmises par des réseaux locaux informels. Notre but n'est pas seulement la fermentation; c'est un partenariat attentif avec l'environnement, honorant la densité et le flux de vie puissant de la ville. Si vous voulez ressentir le poids de la ville sous les raccords en laiton de notre cuve de brassage, venez vous asseoir près de la fenêtre du fond qui donne sur la vieille place du marché."
[2026-04-18 14:50:56.466] [info] 2. city="Paris" country="France" state="Île-de-France" iso3166_2=FR-IDF lat=48.8566 lon=2.3522
[2026-04-18 14:50:56.466] [info] brewery_name_en="La Brasserie de l'Atelier Urbain"
[2026-04-18 14:50:56.466] [info] brewery_description_en="The perpetual smell of rain hitting the ancient cobblestones, followed by the sharp, metallic scent of the Métro rushing beneath us, is what always wakes us up. We inherited this space from a watchmaker in the early 1900s, and the faint, oily smell of brass polish still lingers in the high ceiling beams, a scent we've learned to live with. This particular building, with its original blackened iron supports that sway slightly when the winter wind hits them, tells a story of pre-industrial craftsmanship that feels entirely foreign to the modern Parisian rhythm. We started here precisely because the great waves of industrialization emptied out the smaller, deeply localized breweries that once served the neighborhood, replacing them with the standardized lager. Our dedication is to that lost method. Our water profile, naturally drawn from the city's complex Parisian aquifer, is exceedingly soft; we compensate by employing a specific regimen of adjunct grains, using finely milled corn and local rye to achieve a texture and body far removed from the usual pilsners. Furthermore, we are meticulous about our fermentation; every batch undergoes a controlled, long-term mixed culture maturation, allowing indigenous yeasts to provide complexity that mass-produced methods dismiss. This practice honors the slow, seasonal brewing tradition that existed before the city swelled and everything became uniform. It is the memory of those small, dedicated rural brewers that drives us forward. We believe that complexity is not a trend, it is necessity. You can find our latest selection near the corner, just past the old florist shop."
[2026-04-18 14:50:56.466] [info] brewery_name_local="La Brasserie de l'Atelier Urbain"
[2026-04-18 14:50:56.466] [info] brewery_description_local="L'odeur perpétuelle de la pluie frappant les pavés anciens, suivie du parfum métallique aigu du Métro qui nous passe en dessous, c'est ce qui nous réveille toujours. Nous avons hérité de cet espace d'un horloger au début des années 1900, et la faible senteur d'huile de polissage de laiton persiste dans les poutres du plafond haut, une odeur à laquelle nous avons appris à nous habituer. Ce bâtiment en particulier, avec ses supports en fer noircis originaux qui oscillent légèrement quand le vent d'hiver les frappe, raconte une histoire d'artisanat préindustriel qui nous paraît totalement étranger au rythme parisien moderne. Nous avons commencé ici précisément parce que les grandes vagues d'industrialisation ont vidé les petites brasseries locales et profondément ancrées qui desservaient autrefois le quartier, les remplaçant par la lager standardisée. Notre engagement est envers cette méthode disparue. Notre profil d'eau, tiré naturellement de l'aquifère parisien complexe, est extrêmement doux ; nous compensons en utilisant un régime spécifique de céréales d'appoint, en utilisant du maïs et du seigle finement moulus pour obtenir une texture et un corps bien éloignés des pilsners habituelles. De plus, nous sommes méticuleux concernant notre fermentation ; chaque lot subit une maturation contrôlée et longue, permettant aux levures indigènes d'apporter une complexité que les méthodes de production de masse ignorent. Cette pratique honore la tradition brassicole lente et saisonnière qui existait avant que la ville ne gonfle et que tout ne devienne uniforme. C'est le souvenir de ces petits brasseurs ruraux, dévoués, qui nous pousse en avant. Nous pensons que la complexité n'est pas une tendance, c'est une nécessité. Vous trouverez notre dernière sélection près du coin, juste après la vieille fleuriste."
[2026-04-18 14:50:56.466] [info] 3. city="Abidjan" country="Ivory Coast" state="Abidjan" iso3166_2=CI-AB lat=5.36 lon=-4.0083
[2026-04-18 14:50:56.466] [info] brewery_name_en="Le Ruisseau du Plateau"
[2026-04-18 14:50:56.466] [info] brewery_description_en="The persistent, humid scent of salt mixed with dust and diesel always catches us when we step out onto the main square. That scent is the breath of Abidjan, and it is the smell of our craft. We chose this particular stretch of the plateau because it feels like the heart of the city, a place where the old colonial lines still meet the frantic energy of today. Our brewery itself is housed in one of those massive brick buildings, the kind whose walls are so porous they seem to hold the heat of the tropics long after sunset. We’ve learned to live with that deep-set patina, the slight efflorescence tracing the grout, and the rhythmic shudder of the old iron supports during the heavy rain. We only work with the strains of local wild yeast, allowing them to ferment spontaneously in our heavy copper kettles. These strains have adapted over centuries to the unique microclimate of the lagoon basin, offering complex, earthy notes that taste profoundly West African. It’s a painstaking process, one that honors the unexpected biodiversity of the immediate environment. Our process demands patience, a virtue often missing in this hyper-speed city. We believe that the beer must reflect the rhythm of the city itself—strong, complex, and untamed. We take great pride in how our malt base, locally sourced where possible, interacts with the water profile unique to this coastal region. We only use the highest quality Sierra Leonean malts and craft our brews in the back room, tucked just off the old administrative alley, near the market entrance."
[2026-04-18 14:50:56.466] [info] brewery_name_local="Le Ruisseau du Plateau"
[2026-04-18 14:50:56.466] [info] brewery_description_local="La senteur tenace et humide du sel mélangée à la poussière et au diesel nous frappe toujours lorsque nous sortons sur la place principale. Cette odeur est le souffle d'Abidjan, et c'est l'odeur de notre artisanat. Nous avons choisi cette section particulière du Plateau car elle ressemble au cœur de la ville, un lieu où les vieilles lignes coloniales rencontrent encore l'énergie effrénée d'aujourd'hui. Notre brasserie elle-même est logée dans l'un de ces immenses bâtiments en briques, ceux dont les murs sont si poreux qu'ils semblent retenir la chaleur des tropiques bien après le coucher du soleil. Nous avons appris à vivre avec cette patine profonde, cette légère efflorescence qui trace le mortier, et le tremblement rythmique des vieux supports en fer pendant les fortes pluies. Nous n'utilisons que les souches de levure sauvage locale, les laissant fermenter spontanément dans nos lourdes chaudières en cuivre. Ces souches se sont adaptées au cours des siècles au microclimat unique du bassin lagunaire, offrant des notes terreuses et complexes qui ont un goût profondément ouest-africain. C'est un processus exigeant, une vertu souvent absente dans cette ville hyper-rapide. Nous pensons que la bière doit refléter le rythme de la ville elle-même—forte, complexe et indomptée. Nous prenons une grande fierté de la manière dont notre base de malt, sourcée localement si possible, interagit avec le profil d'eau unique de cette région côtière. Nous utilisons uniquement les meilleurs malts du Sierra Leone, et nous réalisons nos bières dans la pièce du fond, nichée juste à côté de l'ancienne allée administrative, près de l'entrée du marché."
[2026-04-18 14:50:56.466] [info] 4. city="Montreal" country="Canada" state="Quebec" iso3166_2=CA-QC lat=45.5017 lon=-73.5673
[2026-04-18 14:50:56.466] [info] brewery_name_en="Les Piliers du Grand Fleuve"
[2026-04-18 14:50:56.466] [info] brewery_description_en="There is always the smell of damp granite and fallen maple leaves hanging in the air, especially when the wind whips down from the river and settles into the old stone courtyard. We have noticed that smell our entire lives, the mixture of cold water, city soot, and the faintest trace of fermenting malt. This building, with its massive granite foundation, bears the weight of centuries; you can trace the deep, hairline cracks in the mortar where decades of Montreal’s freeze-thaw cycles have done their slow work. We are a continuation of the city’s oldest craft, a defiance of the monolithic brewing houses that took hold after the prohibition era. Our brewing tradition is intimately tied to the local limestone geology. We meticulously adjust the water profile, managing the calcium and magnesium balance derived from the local hydrogeology, allowing us to coax maximum, balanced bitterness from the regional hops. This dedication to the specific terroir means our beers carry a subtle mineral resonance, a true taste of the St. Lawrence’s watershed. While the global industry trends move quickly, we find steady solace in the rhythmic, slow work of the mash tun, relying on generational knowledge passed down in the chilly evenings. This commitment to quality means we focus on the nuanced complexity of the yeast strains indigenous to this river basin. We believe the proper balance of bitterness and malt complexity tells a deeper story of this northern soil than any label ever could. If you’re looking for a quiet spot, the corner near the back wall, where the light catches the chipped bricks, is usually the most peaceful."
[2026-04-18 14:50:56.466] [info] brewery_name_local="Les Piliers du Grand Fleuve"
[2026-04-18 14:50:56.466] [info] brewery_description_local="Il y a toujours l'odeur de granit humide et de feuilles d'érable tombées qui flotte dans l'air, surtout quand le vent descend du fleuve et s'installe dans la vieille cour de pierre. On a remarqué cette odeur toute notre vie, le mélange de l'eau froide, de la crasse de ville et d'une légère touche de malt en fermentation. Ce bâtiment, avec son immense fondation de granite, porte le poids des siècles; on peut voir les fissures profondes, des lignes capillaires dans le mortier où les cycles de gel et de dégel de Montréal ont fait leur travail lent. Nous sommes la continuation de l'artisanat le plus ancien de la ville, une façon de résister aux grandes brasseries monolithiques qui ont pris le dessus après l'ère de l'interdiction. Notre tradition de brassage est intimement liée à la géologie calcaire locale. Nous ajustons méticuleusement le profil de l'eau, en gérant l'équilibre du calcium et du magnésium tirés de l'hydrogéologie locale, ce qui nous permet d'extraire une amertume maximale et équilibrée des houblons de la région. Ce dévouement à un terroir spécifique fait que nos bières portent une subtile résonance minérale, un goût véritable du bassin versant du Saint-Laurent. Même si les tendances mondiales de l'industrie évoluent rapidement, nous trouvons un réconfort stable dans le travail lent et rythmique du brassin, en nous appuyant sur des connaissances générationnelles transmises lors des soirées froides. Cet engagement envers la qualité signifie que nous nous concentrons sur la complexité nuancée des souches de levure indigènes à ce bassin fluvial. Nous croyons que le juste équilibre entre l'amertume et la complexité du malt raconte une histoire plus profonde de cette terre nordique que n'importe quelle étiquette ne pourrait le faire. Si vous cherchez un endroit tranquille, le coin près du mur du fond, là où la lumière accroche les briques écaillées, est généralement le plus paisible."
[2026-04-18 14:50:56.466] [info] 5. city="Brussels" country="Belgium" state="Brussels-Capital Region" iso3166_2=BE-BRU lat=50.8503 lon=4.3517
[2026-04-18 14:50:56.466] [info] brewery_name_en="Le Moulin de Zythos"
[2026-04-18 14:50:56.466] [info] brewery_description_en="The way the humid Brussels air settles on the worn cobblestones, carrying the faint, metallic tang of rainwater and distant charcoal, is the first thing we notice every spring morning. Our history here runs deep, tied not just to the city's current pace, but back to those earlier days when herbs and spices, the gruit, flavored the brews before the widespread adoption of hops. We honor that ancient Belgian patience in our cellars. Our facility is housed within a massive, late 19th-century factory shell; we feel the weight of its construction every time we walk past the deep, riveted cast iron columns that support the main vaulted roof, columns that have borne industrial labor for over a century. Our focus is on radical yet historical profiles. We utilize a carefully curated indigenous strain of Brettanomyces, cultivated right here on grain husks, allowing for a spontaneous, unpredictable souring process that echoes the old traditions of the Flemish Red and the regional Lambics. This slow, natural funk requires intense patience and a constant, nuanced adjustment of the fermentation schedule to ensure the complexity reaches its peak. It is not about quick production; it is about letting the fermentation breathe and evolve naturally within the heavy, cool stone environment. Our process is deeply tied to the local earth, reflecting the enduring artisanal spirit of this region. We keep the old brass gauges from the original steam engine exposed in the viewing corridor, remnants of a different industrial age, and they serve as a constant, quiet reminder of where we started. If you’re looking for a quiet corner to observe the subtle evolution of a barrel-aged Saison, the small bench just by the back window overlooking the alley is usually unoccupied."
[2026-04-18 14:50:56.466] [info] brewery_name_local="Le Moulin de Zythos"
[2026-04-18 14:50:56.466] [info] brewery_description_local="La façon dont l'air humide de Bruxelles se pose sur les pavés usés, portant cette saveur métallique légère de pluie et de charbon lointain, c'est la première chose que nous remarquons chaque matin de printemps. Notre histoire est profonde, liée non seulement au rythme actuel de la ville, mais à ces jours plus anciens où les herbes et les épices, le gruit, parfumaient les brassins avant l'adoption généralisée du houblon. Nous honorons cette ancienne patience belge dans nos caves. Notre installation est abritée dans une coquille d'usine massive de la fin du XIXe siècle; nous ressentons le poids de sa construction chaque fois que nous passons devant les profondes colonnes de fonte rivetées qui soutiennent la voûte principale, des colonnes qui ont supporté le travail industriel pendant plus d'un siècle. Notre objectif est d'obtenir des profils radicaux mais historiques. Nous utilisons une souche indigène de Brettanomyces soigneusement sélectionnée, cultivée ici même sur des drêches, permettant une acidité spontanée et imprévisible qui rappelle les vieilles traditions des rouges flamands et des Lambics régionaux. Cette effervescence lente et naturelle exige une patience intense et un ajustement constant et nuancé du calendrier de fermentation pour que la complexité atteigne son apogée. Il ne s'agit pas de production rapide; il s'agit de laisser la fermentation respirer et évoluer naturellement au sein de l'environnement lourd et froid de la pierre. Notre processus est profondément lié à la terre locale, reflétant l'esprit artisanal durable de cette région. Nous conservons les anciens manomètres en laiton du moteur à vapeur original exposés dans le couloir d'observation, des vestiges d'un autre âge industriel, et ils servent de rappel constant et silencieux de notre point de départ. Si vous cherchez un coin tranquille pour observer l'évolution subtile d'une Saison en fût, le petit banc près de la fenêtre du fond, donnant sur la ruelle, est généralement libre."
[2026-04-18 14:50:56.467] [info] Pipeline executed successfully

340
docs/testing.md Normal file
View File

@@ -0,0 +1,340 @@
# 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)
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
- **Service.Auth.Tests** - Unit tests for authentication business logic
- **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 -f 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/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
```
### Clean Up
```bash
# Remove test containers and volumes
docker compose -f docker-compose.test.yaml down -v
```
## Running Tests Locally
You can run individual test projects locally without Docker:
### Integration Tests (API.Specs)
```bash
cd src/Core
dotnet test API/API.Specs/API.Specs.csproj
```
**Requirements**:
- SQL Server instance running
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
### Repository Tests
```bash
cd src/Core
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
```
**Requirements**:
- SQL Server instance running (uses mock data)
### Service Tests
```bash
cd src/Core
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
```
**Requirements**:
- No database required (uses Moq for mocking)
### Frontend Storybook Tests
```bash
cd src/Website
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 src/Website
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`
## Test Coverage
### Current Coverage
**Authentication & User Management**:
- User registration with validation
- User login with JWT token generation
- Password hashing and verification (Argon2id)
- JWT token generation and claims
- Invalid credentials handling
- 404 error responses
**Repository Layer**:
- User account creation
- User credential management
- GetUserByUsername queries
- Stored procedure execution
**Service Layer**:
- Login service with password verification
- Register service with validation
- Business logic for authentication flow
**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
- [ ] Email verification workflow
- [ ] Password reset functionality
- [ ] Token refresh mechanism
- [ ] Brewery data management
- [ ] Beer post operations
- [ ] User follow/unfollow
- [ ] Image upload service
- [ ] Frontend route integration coverage beyond Storybook stories
## Testing Frameworks & Tools
### xUnit
- Primary unit testing framework
- Used for Repository 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
### DbMocker
- Database mocking for repository tests
- Simulates SQL Server responses
- No real database required for unit tests
## Test Structure
### API.Specs (Integration Tests)
```
API.Specs/
├── Features/
│ ├── Authentication.feature # Login/register scenarios
│ └── UserManagement.feature # User CRUD scenarios
├── Steps/
│ ├── AuthenticationSteps.cs # Step definitions
│ └── UserManagementSteps.cs
└── Mocks/
└── TestApiFactory.cs # Test server setup
```
**Example Feature**:
```gherkin
Feature: User Authentication
As a user
I want to register and login
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
```
### Infrastructure.Repository.Tests
```
Infrastructure.Repository.Tests/
├── AuthRepositoryTests.cs # Auth repository tests
├── UserAccountRepositoryTests.cs # User account tests
└── TestFixtures/
└── DatabaseFixture.cs # Shared test setup
```
### Service.Auth.Tests
```
Service.Auth.Tests/
├── LoginService.test.cs # Login business logic tests
└── RegisterService.test.cs # Registration business logic tests
```
## Writing Tests
### Unit Test Example (xUnit)
```csharp
public class LoginServiceTests
{
[Fact]
public async Task LoginAsync_ValidCredentials_ReturnsToken()
{
// Arrange
var mockRepo = new Mock<IAuthRepository>();
var mockJwt = new Mock<IJwtService>();
var service = new AuthService(mockRepo.Object, mockJwt.Object);
// Act
var result = await service.LoginAsync("testuser", "password123");
// Assert
result.Should().NotBeNull();
result.Token.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 -f docker-compose.test.yaml build
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose -f 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 src/Website
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 -f 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 -f docker-compose.test.yaml down -v
```
### View Container Logs
```bash
docker compose -f 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

205
docs/token-validation.md Normal file
View File

@@ -0,0 +1,205 @@
# Token Validation Architecture
## Overview
The Core project implements comprehensive JWT token validation 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](Infrastructure.Jwt/ITokenInfrastructure.cs)
Low-level JWT operations.
**Methods:**
- `GenerateJwt()` - Creates signed JWT tokens
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:** [JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness
### Service Layer
#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs)
High-level token validation with context (token type, user extraction).
**Methods:**
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
**Returns:** `ValidatedToken` record containing:
- `UserId` (Guid)
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:** [TokenValidationService.cs](Service.Auth/TokenValidationService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
#### [ITokenService](Service.Auth/ITokenService.cs)
Token generation (existing service extended).
**Methods:**
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation token
### Integration Points
#### [ConfirmationService](Service.Auth/IConfirmationService.cs)
**Flow:**
1. Receives confirmation token from user
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token
4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database
5. Returns confirmation result
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs)
**Flow:**
1. Receives refresh token from user
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()`
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()`
4. Issues new access and refresh tokens via `TokenService`
5. Returns new token pair
#### [AuthController](API.Core/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/refresh` - Refresh access token
## 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 RefreshTokenService
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 ConfirmationService
5. **Completion**: User account marked as confirmed
6. **Expiration**: Token becomes invalid after 30 minutes
## Testing
### Unit Tests
**TokenValidationService.test.cs**
- Happy path: Valid token extraction
- Error cases: Invalid, expired, malformed tokens
- Missing/invalid claims scenarios
**RefreshTokenService.test.cs**
- Successful refresh with valid token
- Invalid/expired refresh token rejection
- Non-existent user handling
**ConfirmationService.test.cs**
- Successful confirmation with valid token
- Token validation failures
- User not found scenarios
### 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 Blacklisting**
- Implement token revocation (e.g., on logout)
- Store blacklisted tokens in cache/database
- Check blacklist 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)

View File

@@ -1,611 +0,0 @@
# 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).

View File

@@ -1,143 +0,0 @@
---
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`.

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Confirm User
```mermaid
--8<-- "web/diagrams/auth/confirm-user.mmd"
```

View File

@@ -1,79 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Delete Account
```mermaid
--8<-- "web/diagrams/auth/delete-account.mmd"
```

View File

@@ -1,65 +0,0 @@
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

View File

@@ -1,11 +0,0 @@
# 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"
```

View File

@@ -1,12 +0,0 @@
sequenceDiagram
participant API as API Controller
Note over API: GlobalExceptionFilter catches:
Note over API: FluentValidation.ValidationException → 400 Bad Request {message, errors} (grouped by property)
Note over API: ConflictException → 409 Conflict
Note over API: NotFoundException → 404 Not Found
Note over API: UnauthorizedException → 401 Unauthorized
Note over API: ForbiddenException → 403 Forbidden
Note over API: Domain.Exceptions.ValidationException → 400 Bad Request
Note over API: Microsoft.Data.SqlClient.SqlException → 503 Service Unavailable
Note over API: All others → 500 Internal Server Error

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Login
```mermaid
--8<-- "web/diagrams/auth/login.mmd"
```

View File

@@ -1,93 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Refresh Token
```mermaid
--8<-- "web/diagrams/auth/refresh-token.mmd"
```

View File

@@ -1,77 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Registration
```mermaid
--8<-- "web/diagrams/auth/registration.mmd"
```

View File

@@ -1,165 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Resend Confirmation Email
```mermaid
--8<-- "web/diagrams/auth/resend-confirmation.mmd"
```

View File

@@ -1,78 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Update Email
```mermaid
--8<-- "web/diagrams/auth/update-email.mmd"
```

View File

@@ -1,64 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Update Password
```mermaid
--8<-- "web/diagrams/auth/update-password.mmd"
```

View File

@@ -1,84 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Update Profile
```mermaid
--8<-- "web/diagrams/auth/update-profile.mmd"
```

View File

@@ -1,63 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
# User Authentication Flow — Update Username
```mermaid
--8<-- "web/diagrams/auth/update-username.mmd"
```

View File

@@ -1,68 +0,0 @@
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

View File

@@ -1,47 +0,0 @@
# 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"
```

View File

@@ -1,44 +0,0 @@
---
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
}

View File

@@ -1,51 +0,0 @@
---
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
}

View File

@@ -1,41 +0,0 @@
---
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
}

View File

@@ -1,26 +0,0 @@
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
}

View File

@@ -1,19 +0,0 @@
---
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
}

View File

@@ -1,34 +0,0 @@
---
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)"
}

View File

@@ -1,48 +0,0 @@
# 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. |

View File

@@ -1,13 +0,0 @@
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

View File

@@ -1,20 +0,0 @@
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

View File

@@ -1,15 +0,0 @@
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)

View File

@@ -1,13 +0,0 @@
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

View File

@@ -1,29 +0,0 @@
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

View File

@@ -1,105 +0,0 @@
# 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

View File

@@ -1,29 +0,0 @@
---
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

View File

@@ -1,38 +0,0 @@
---
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")]

View File

@@ -1,558 +0,0 @@
---
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)

View File

@@ -1,451 +0,0 @@
---
title: Environment variables for the backend, frontend, and Docker
last-updated: 2026-08-31
tags:
- environment-variables
- configuration
- secrets
- docker
---
This document covers the environment variables used by the Biergarten stack.
## Overview
The application uses environment variables for:
- **.NET API backend** - database connections, token secrets, runtime settings
- **React Router website** - API base URL and session signing
- **Docker containers** - environment-specific orchestration
## Configuration patterns
### Backend (.NET API)
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (`web/frontend`)
The website reads runtime values from the server environment for its auth and
API integration. In Docker, the `frontend` service is wired into the same
`web/.env.dev` / `.env.test` / `.env.prod` files as the backend; running it
locally with `npm run dev` reads from the shell environment.
### Docker
Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
- `.env.dev` - Development
- `.env.test` - Testing
- `.env.prod` - Production
### Generating an env file
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`,
`SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`) with freshly randomized ones via `openssl`.
Non-secret values are left untouched. The output file is written with `600`
permissions.
```bash
cd web
./generate-env.sh .env.dev
```
Pass the output path, and optionally a template path, as arguments (default
output: `.env`, default template: `.env.example`). The script prompts before
overwriting an existing file. Randomized `SMTP_USERNAME`/`SMTP_PASSWORD` are dev
placeholders; replace them with real provider credentials for
staging/production.
## Backend variables (.NET API)
### Database connection
**Option 1: Component-Based (Recommended for Docker)**
Build connection string from individual components:
```bash
DB_SERVER=sqlserver,1433 # SQL Server host and port
DB_NAME=Biergarten # Database name
DB_USER=sa # SQL Server username
DB_PASSWORD=YourStrong!Passw0rd # SQL Server password
DB_TRUST_SERVER_CERTIFICATE=True # Optional, defaults to True
```
**Option 2: Full Connection String (Local Development)**
Provide complete connection string:
```bash
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
```
**Priority**: `DB_CONNECTION_STRING` is checked first. If not found, connection
string is built from components.
**Implementation**: See `DefaultSqlConnectionFactory.cs`
### JSON Web Token (JWT) authentication secrets (backend)
The backend uses separate secrets for different token types to enable
independent key rotation and validation isolation.
```bash
# Access token secret (1-hour tokens)
ACCESS_TOKEN_SECRET=<generated-secret> # Signs short-lived access tokens
# Refresh token secret (21-day tokens)
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
# Confirmation token secret (30-minute tokens)
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
# Website base URL (used in confirmation emails)
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
```
**Security Requirements**:
- Use at least 32 characters per secret
- Use secrets generated from 127 or more random bytes in production (base64
encoding turns 127 bytes into a longer string, well over 127 characters)
- Generate secrets with a cryptographically secure random function
- Never reuse secrets across token types or environments
- Rotate secrets periodically in production
**Generate Secrets**:
**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw
bytes)**
```bash
openssl rand -base64 127
```
**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw
bytes)**
```powershell
$bytes = New-Object byte[] 127
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
$rng.GetBytes($bytes)
[Convert]::ToBase64String($bytes)
$rng.Dispose()
```
**Windows - PowerShell 7+ compatible (cryptographically secure, 127 raw bytes)**
```powershell
[Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(127))
```
**Token Expiration**:
- **Access tokens**: 1 hour
- **Refresh tokens**: 21 days
- **Confirmation tokens**: 30 minutes
(Defined in `TokenServiceExpirationHours` class)
**JWT Implementation**:
- **Algorithm**: HS256 (HMAC-SHA256)
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- **Validation**: Token signature, expiration, and malformed token checks
### Migration control
```bash
CLEAR_DATABASE=true
```
- **Required**: No
- **Default**: `false`
- **Effect**: If set to `true`, drops and recreates the database during
migrations
- **Usage**: Development and testing environments only
- **Warning**: Never use in production
### ASP.NET Core configuration
```bash
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
```
`ASPNETCORE_URLS` is hardcoded directly in `docker-compose.dev.yaml` and
`docker-compose.prod.yaml` (not sourced from the `.env` file), so it's required
only for Docker. Outside Docker, `dotnet run` falls back to the `http` profile
in `API.Core/Properties/launchSettings.json` (`http://localhost:5069`), so
manual backend setup (see
[Getting Started](getting-started.md#manual-backend-setup)) doesn't need to set
it.
## Frontend variables (`web/frontend`)
The website's core runtime variables are:
```bash
API_BASE_URL=http://api.core:8080 # Base URL for the .NET API (container DNS name in Docker)
SESSION_SECRET=<generated-secret> # Cookie session signing secret
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
#### `API_BASE_URL`
- **Required**: Yes for local development
- **Default in code**: `http://localhost:8080`
- **Used by**: `web/frontend/app/features/auth/auth.server.ts`,
`web/frontend/app/features/breweries/breweries.server.ts`
- **Purpose**: Routes website auth and API calls to the .NET API
#### `SESSION_SECRET`
- **Required**: Strongly recommended in all environments
- **Default in local code path**: `dev-secret-change-me`
- **Used by**: React Router cookie session storage in `auth.server.ts`
- **Purpose**: Signs and validates the website session cookie
#### `NODE_ENV`
- **Required**: No
- **Typical values**: `development`, `production`, `test`
- **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)
Read by `Features.Emails/Services/SmtpEmailProvider.cs` for sending confirmation and
account emails.
```bash
SMTP_HOST=mailpit # Required, no default
SMTP_PORT=1025 # Optional, defaults to 587
SMTP_USERNAME=<username> # Optional, no default
SMTP_PASSWORD=<password> # Optional, no default
SMTP_USE_SSL=false # Optional, defaults to true
SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
```
- **Implementation**: `Features.Emails/Services/SmtpEmailProvider.cs` throws on
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
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
### SQL Server container
```bash
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
ACCEPT_EULA=Y # Accept SQL Server EULA (required)
MSSQL_PID=Express # SQL Server edition (Express, Developer, Enterprise)
```
**Password Requirements**:
- Minimum 8 characters
- Uppercase, lowercase, digits, and special characters
- Maps to `DB_PASSWORD` for application containers
## Environment file structure
### Backend/Docker (`web/` directory)
```
web/.env.example # Template (tracked in Git)
web/.env.dev # Development config (gitignored)
web/.env.test # Testing config (gitignored)
web/.env.prod # Production config (gitignored)
```
**Setup**:
```bash
cd web
./generate-env.sh .env.dev
```
See [Generating an env file](#generating-an-env-file) above — `generate-env.sh`
randomizes the secret-bearing values, so it's the preferred way to create any
`.env.dev`/`.env.test`/`.env.prod` file. A plain
`cp web/.env.example web/.env.dev` works too, but leaves the placeholder secrets
from the template in place; edit them manually if you go that route.
**Docker Compose Mapping**:
- `web/docker-compose.dev.yaml` → `web/.env.dev`
- `web/docker-compose.test.yaml` → `web/.env.test`
- `web/docker-compose.prod.yaml` → `web/.env.prod`
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
## Variable reference table
| Variable | Backend | Frontend | Docker | Required | Notes |
| ----------------------------- | :-----: | :------: | :----: | :------: | ---------------------------- |
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
| `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 |
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes\*\* | API binding address |
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
\*\* Required for Docker, where it's hardcoded in the compose files. Outside
Docker, `dotnet run` falls back to the `applicationUrl` in
`launchSettings.json`.
## Validation
### Backend validation
Variables are validated at startup:
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`,
`SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values) cause
the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the "minimum 32
characters" guidance above is a recommendation, not an enforced check
### Frontend validation
The website relies on runtime defaults for local development and on the server
environment when deployed.
- `API_BASE_URL` defaults to `http://localhost:8080`
- `SESSION_SECRET` falls back to a development-only local secret
- `NODE_ENV` controls secure cookie behavior
## Example configuration files
### `.env.dev` (backend/Docker)
```bash
# Database
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=Dev_Password_123!
# JWT Authentication Secrets
ACCESS_TOKEN_SECRET=<generated-with-openssl>
REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# Frontend session signing
SESSION_SECRET=<generated-with-openssl>
# SMTP (Mailpit in dev)
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.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
CLEAR_DATABASE=true
# ASP.NET Core
ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://0.0.0.0:8080
# SQL Server Container
SA_PASSWORD=Dev_Password_123!
ACCEPT_EULA=Y
MSSQL_PID=Express
```
### Frontend local runtime example
```bash
API_BASE_URL=http://localhost:8080
SESSION_SECRET=<generated-with-openssl>
NODE_ENV=development
```

View File

@@ -1,166 +0,0 @@
---
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 Biergarten stack: the .NET backend in
`web/backend` and the React Router frontend in `web/frontend`.
## Prerequisites
- **.NET SDK 10+**
- **Node.js 22+**
- **Docker Desktop** or equivalent Docker Engine setup
- **Java 8+** if you want to regenerate PlantUML diagrams
## Recommended path: Docker for backend, Node for frontend
### 1. Clone the repository
```bash
git clone https://github.com/aaronpo97/the-biergarten-app
cd the-biergarten-app
```
### 2. Configure backend environment variables
Generate `.env.dev` with freshly randomized secrets:
```bash
cd web
./generate-env.sh .env.dev
cd ..
```
Or copy the template and fill in values manually:
```bash
cp web/.env.example web/.env.dev
```
At minimum, ensure `.env.dev` includes valid database and token values:
```bash
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd
ACCESS_TOKEN_SECRET=<generated>
REFRESH_TOKEN_SECRET=<generated>
CONFIRMATION_TOKEN_SECRET=<generated>
WEBSITE_BASE_URL=http://localhost:3000
```
See [Environment Variables](environment-variables.md) for the full list, and its
[Generating an env file](environment-variables.md#generating-an-env-file)
section for details on `generate-env.sh`.
### 3. Start the backend stack
```bash
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
This starts SQL Server, migrations, seeding, the API, Mailpit (local dev SMTP),
and SeaweedFS (local dev S3-compatible object storage for photo uploads).
Available endpoints:
| Service | URL |
| ---------------- | ----------------------------- |
| API Swagger | http://localhost:8080/swagger |
| Health Check | http://localhost:8080/health |
| Mailpit UI | http://localhost:8025 |
| SeaweedFS S3 API | http://localhost:8333 |
### 4. Start the frontend
```bash
cd web/frontend
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
```
The website will be available at the local address printed by React Router dev.
Required frontend runtime variables for local work:
- `API_BASE_URL` - Base URL for the .NET API
- `SESSION_SECRET` - Cookie session signing secret for the website server
### 5. Optional: run Storybook
```bash
cd web/frontend
npm run storybook
```
Storybook runs at http://localhost:6006 by default.
## Useful commands
### Backend
```bash
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
```
### Frontend
```bash
cd web/frontend
npm run lint
npm run typecheck
npm run format:check
npm run test:storybook
npm run test:storybook:playwright
```
## Manual backend setup
If you do not want to use Docker, you can run the backend locally.
### 1. Set environment variables
```bash
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
export ACCESS_TOKEN_SECRET="<generated>"
export REFRESH_TOKEN_SECRET="<generated>"
export CONFIRMATION_TOKEN_SECRET="<generated>"
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
```bash
cd web/backend
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
dotnet run --project Database/Database.Seed/Database.Seed.csproj
```
### 3. Start the API
```bash
dotnet run --project API/API.Core/API.Core.csproj
```
## Archived frontend
`archive/next-js-web-app/` contains an archived Next.js frontend, kept for
reference; it is not part of the running stack.
## Next steps
- Review [Architecture](architecture.md)
- Run backend and frontend checks from [Testing](testing.md)
- Use [Docker Guide](docker.md) for container troubleshooting

Some files were not shown because too many files have changed in this diff Show More