Update docs formatting

This commit is contained in:
Aaron Po
2026-09-01 00:07:05 -04:00
parent 52910a17d5
commit dee38fd1a3
18 changed files with 292 additions and 282 deletions

View File

@@ -12,4 +12,4 @@ Welcome to the documentation for The Biergarten App.
- **[Architecture](web/architecture.md)** — System design and components
- **[Website](web/getting-started.md)** — Backend and frontend documentation
- **[Pipeline](pipeline/README.md)** — Data pipeline and ETL processes
- **[Pipeline](pipeline/README.md)** — Data generation pipeline

View File

@@ -306,13 +306,13 @@ 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 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.
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.
---

View File

@@ -110,27 +110,27 @@ Each run writes a fresh dated SQLite file such as
#### CLI flags
| Flag | Purpose |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `--mocked` | Deterministic mock generator, no model required. |
| `--model, -m` | Path to a GGUF file. Selects the local Llama.cpp generator. |
| 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. |
| `--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).
`--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.
@@ -238,8 +238,8 @@ to match your run.
| Sample | `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()` samples `--location-count` locations per run (default `10`). |
| Enrich | `WikipediaEnrichmentService` fetches brewing and beer-related context. Keeps going when a lookup fails. `--mocked` runs use `MockEnrichmentService` instead and skip Wikipedia entirely. |
| Generate Users | `GenerateUsers()` samples a persona and a forename/surname pair per enriched city (skipping countries with no name data), then `MockGenerator` or `LlamaGenerator` produces a username, bio, and activity weight around the sampled name. |
| Generate Breweries | `MockGenerator`, `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. |
| 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
@@ -270,10 +270,10 @@ skipped and the pipeline continues. `GenerateUsers()` runs before
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.
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 payloads include English and local-language name and description
@@ -299,11 +299,11 @@ 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.
`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
@@ -334,14 +334,14 @@ Each successful run stores a `BreweryRecord` pair with the source location and a
`UserResult` payload. The same generated records are also written to a fresh
SQLite export file named with the current UTC timestamp.
| Field | Meaning |
| ------------------- | ------------------------------------------ |
| `name_en` | Brewery name in English. |
| `description_en` | Brewery description in English. |
| `name_local` | Brewery name in the local language. |
| `description_local` | Brewery description in the local language. |
| Field | Meaning |
| ------------------- | ------------------------------------------------------------------------------------- |
| `name_en` | Brewery name in English. |
| `description_en` | Brewery description in English. |
| `name_local` | Brewery name in the local language. |
| `description_local` | Brewery description in the local language. |
| `address_line1` | Street address resolved via reverse geocoding, or a placeholder if resolution failed. |
| `postal_code` | Postal code resolved via reverse geocoding, or empty if resolution failed. |
| `postal_code` | Postal code resolved via reverse geocoding, or empty if resolution failed. |
| Field | Meaning |
| ----------------- | -------------------------------------------------------------------------------------- |

View File

@@ -155,8 +155,8 @@ Entirely new — no `includes/policy/` (or equivalent) directory exists today.
`MockGenerator`/`LlamaGenerator`) that calls the OpenAI Chat Completions
API with Structured Outputs (`json_schema`, `strict`) to guarantee
schema-valid JSON without needing `IPromptFormatter` or a GBNF grammar —
selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of
the planned architecture in §3/§4 and isn't reflected there yet.
selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of the
planned architecture in §3/§4 and isn't reflected there yet.
- [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop
mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3
attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`.
@@ -195,8 +195,8 @@ Entirely new — no `includes/policy/` (or equivalent) directory exists today.
`kCreateUserAddressesTableSql` in
`includes/services/database/sqlite_statement_helpers.h` for the existing
pattern to follow.
- [ ] Add a `brewery_cache_` alongside the existing `city_cache_`, and move
from one open transaction for the whole run to per-phase batched commits
- [ ] Add a `brewery_cache_` alongside the existing `city_cache_`, and move from
one open transaction for the whole run to per-phase batched commits
(`BEGIN` / `COMMIT & BEGIN` on a batch-size threshold), as shown in the
planned activity diagram.

View File

@@ -1,6 +1,10 @@
# 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.
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,6 +1,11 @@
# The Biergarten Data Pipeline — Activity Diagram (Planned)
Each generation phase runs a producer/consumer trio concurrently: an **Orchestrator** producer feeds a bounded channel, an **LLM Worker** consumes it and generates records (closing its output channel when the input is drained and closed), and a **SQLite Worker** consumes that output channel and commits in batches. The Checkin and Follow phases run in parallel with each other once the user and brewery pools are populated.
Each generation phase runs a producer/consumer trio concurrently: an
**Orchestrator** producer feeds a bounded channel, an **LLM Worker** consumes it
and generates records (closing its output channel when the input is drained and
closed), and a **SQLite Worker** consumes that output channel and commits in
batches. The Checkin and Follow phases run in parallel with each other once the
user and brewery pools are populated.
```mermaid
--8<-- "pipeline/diagrams/planned/activity.mmd"

View File

@@ -6,5 +6,12 @@
## 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.
- `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

@@ -45,12 +45,15 @@ Authentication workflow diagrams, one per flow, under
### 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:
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
@@ -109,14 +112,6 @@ flowchart TB
style S5 stroke-dasharray: 4 3
```
Slices generally don't reference each other's project. Most cross-slice
interactions go through a MediatR command instead — for example,
`Features.Users` triggering a confirmation email handled by `Features.Emails`
via a `SendRegistrationEmailCommand` whose contract lives in
`Shared.Application`, so neither slice takes a project reference on the other.
The one exception is `Features.Users`, which takes a direct `ProjectReference`
on `Features.PhotoUpload` to send `UploadPhotoCommand` for avatar uploads.
### Layer responsibilities
#### API layer (`API.Core`)
@@ -163,12 +158,6 @@ own
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
commands sent from other slices, never over HTTP.
`Features.Locations` also has no `Controllers/` folder and no MediatR handlers;
it exposes `ILocationRepository` directly as a plain service, currently consumed
only by `Database.Seed` to look up cities while seeding brewery locations. Other
slices (for example, `Features.Breweries`) do not yet depend on it; they run
their own `CityID` existence checks inline.
`Features.PhotoUpload` also has no `Controllers/` folder; its
`UploadPhotoCommand` is sent by other features' handlers (for example, a future
brewery photo upload command) rather than bound to an HTTP route directly.
@@ -182,8 +171,8 @@ brewery photo upload command) rather than bound to an HTTP route directly.
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with one exception: `Features.Users`
references `Features.PhotoUpload` directly for avatar uploads
- No other `Features.*` project, with one exception: `Features.Users` references
`Features.PhotoUpload` directly for avatar uploads
**Rules**:
@@ -277,13 +266,15 @@ everything needed to understand or change one capability lives in one project
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (for example, `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body; there is
no separate request DTO + mapping step for writes
- Controllers generally bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- The exception to this is for authenticated routes, when the current user id
needs to be extracted from the authentication token to be used in a command.
- A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes
- Query handlers map to a dedicated response `Dto`, so domain entities never
leak over the wire
leak
**Example**:
@@ -351,16 +342,11 @@ method that registers its repository and slice-internal services
**Strategy**:
- No ORM; each repository issues inline SQL through Dapper, which handles
parameter binding. The one wrinkle is the `GEOGRAPHY` column on
`BreweryPostLocation`, which Dapper can't deserialize as a UDT: reads select
`CONVERT(varbinary(max), Coordinates)` so the value comes back as a plain
`byte[]` Dapper can bind. Proximity search
(`GET /api/brewery/locations/nearby`) filters and orders rows server-side with
`Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding a client-side
distance calculation over every row. No spatial index backs the query yet;
`schema.sql` sketches one, commented out, for when the table grows large
enough to need it
- 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
@@ -372,8 +358,8 @@ method that registers its repository and slice-internal services
- 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.
See [Database](database.md) for the schema and the app's SQL error-handling
approach in more detail.
## Frontend architecture
@@ -441,43 +427,25 @@ reference. Product and engineering documentation points to `web/frontend`.
### Authentication flow
1. **Registration**:
- User submits credentials
- Password hashed with Argon2id
- User account created (unverified) and a confirmation email is dispatched
via `Features.Emails`
2. **Email confirmation**:
- User follows the confirmation link/token from the email
- `Features.Users`' `ConfirmUser` command marks the account verified
3. **Login**:
- User submits credentials
- Password verified against hash
- Access and refresh JWTs are issued and returned to the client; neither is
stored server-side
4. **Token refresh**:
- Client exchanges a valid, unexpired refresh token for a new access/refresh
pair via `Features.Users`' `RefreshToken` command
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
refresh token is not invalidated and remains usable until its own
expiration
5. **API Requests**:
- Client sends the access JWT in the `Authorization` header
- `JwtAuthenticationHandler` validates the token
- Request proceeds if valid
| 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
- Memory: 64MB
- Iterations: 4
- Parallelism: 2 (hardcoded, to avoid thread-pool exhaustion)
- Salt: 128-bit (16 bytes)
- Hash: 256-bit (32 bytes)
| 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
@@ -485,11 +453,13 @@ reference. Product and engineering documentation points to `web/frontend`.
**Claims**:
- `sub` - User ID
- `unique_name` - Username
- `jti` - Unique token ID
- `iat` - Issued at timestamp
- `exp` - Expiration timestamp
| Claim | Meaning |
| ------------- | -------------------- |
| `sub` | User ID |
| `unique_name` | Username |
| `jti` | Unique token ID |
| `iat` | Issued at timestamp |
| `exp` | Expiration timestamp |
**Configuration** (appsettings.json):
@@ -503,11 +473,10 @@ reference. Product and engineering documentation points to `web/frontend`.
}
```
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.
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
@@ -563,7 +532,8 @@ tracking stays valid.
**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))
pipeline under `tooling/pipeline/` (see
[Pipeline README](../pipeline/README.md))
**Seed Data**:
@@ -573,11 +543,9 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](../pipeline/README.md)
- User accounts (via `Features.Users`' repository)
- Brewery posts with locations (via `Features.Breweries`' repository)
`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`,
but `Database.Seed` doesn't call it, so photos aren't part of the seed data.
Tables that exist in the schema but have no repository yet (`UserAvatar`,
`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
`BreweryPostPhoto`) are not seeded either.
Tables that exist in the schema but have no repository yet ( `UserFollow`,
`BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, `BreweryPostPhoto`)
are not seeded either.
## Deployment architecture
@@ -585,18 +553,22 @@ Tables that exist in the schema but have no repository yet (`UserAvatar`,
**Container Structure**:
- `sqlserver` - SQL Server 2022
- `database.migrations` - Schema migration runner
- `database.seed` - Data seeder
- `api.core` - ASP.NET Core Web API
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
- `seaweedfs` - S3-compatible object storage for uploaded photos (dev only)
| 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**:
- Development (`docker-compose.dev.yaml`)
- Testing (`docker-compose.test.yaml`)
- Production (`docker-compose.prod.yaml`)
| Environment | Compose file |
| ----------- | -------------------------- |
| Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` |
For details, see [Docker Guide](docker.md).

View File

@@ -20,24 +20,24 @@ The full schema lives under
script per table.
```
└── 01-schema
├── 01-UserAccount.sql
├── 02-Photo.sql
├── 03-UserVerification.sql
├── 04-UserCredential.sql
├── 05-UserFollow.sql
├── 06-Country.sql
├── 07-StateProvince.sql
├── 08-City.sql
├── 09-BreweryPost.sql
├── 10-BreweryPostLocation.sql
├── 11-BreweryPostPhoto.sql
├── 12-BeerStyle.sql
├── 13-BeerPost.sql
├── 14-BeerPostPhoto.sql
├── 15-BeerPostComment.sql
├── 16-UserProfile.sql
└── 17-UserAvatar.sql
01-schema
├── 01-UserAccount.sql
├── 02-Photo.sql
├── 03-UserVerification.sql
├── 04-UserCredential.sql
├── 05-UserFollow.sql
├── 06-Country.sql
├── 07-StateProvince.sql
├── 08-City.sql
├── 09-BreweryPost.sql
├── 10-BreweryPostLocation.sql
├── 11-BreweryPostPhoto.sql
├── 12-BeerStyle.sql
├── 13-BeerPost.sql
├── 14-BeerPostPhoto.sql
├── 15-BeerPostComment.sql
├── 16-UserProfile.sql
└── 17-UserAvatar.sql
```
Each script is run in order by `Database.Migrations` (DbUp) on startup. Each

View File

@@ -1,6 +1,10 @@
# 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.
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,6 +1,8 @@
# Database Schema
Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency), omitted below for brevity. Tables marked `(*)` have no repository/feature slice yet.
Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency),
omitted below for brevity. Tables marked `(*)` have no repository/feature slice
yet.
```mermaid
--8<-- "web/diagrams/database-schema.mmd"

View File

@@ -6,10 +6,12 @@
## Notes
- **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 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, results aggregated.
- **Prod network** (`prodnet`, bridge): isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production.
- **Database-only environment**: same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `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` 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 are readable by Visual Studio, Azure DevOps, and GitHub Actions.
| 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; results are aggregated. |
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
| `.trx` results | Readable by Visual Studio, Azure DevOps, and GitHub Actions. |

View File

@@ -17,12 +17,12 @@ doesn't wire up any slice; it applies schema directly.
## Project reference dependencies
Solid arrows are compile-time project references. Dashed arrows are the two
places slices talk to each other without a direct reference: the email
MediatR contract routed through `Shared.Application`, and the one place that
pattern is broken outright. Every DB-backed slice depends on the same three
foundation projects (`Shared.Contracts`, `Domain.Entities`,
`Database.Connection`) — drawn as one box instead of fifteen separate arrows;
see the notes for what else each of those foundation projects pulls in.
places slices talk to each other without a direct reference: the email MediatR
contract routed through `Shared.Application`, and the one place that pattern is
broken outright. Every DB-backed slice depends on the same three foundation
projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
as one box instead of fifteen separate arrows; see the notes for what else each
of those foundation projects pulls in.
```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd"
@@ -33,32 +33,49 @@ see the notes for what else each of those foundation projects pulls in.
- **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
- Only `Features.Users` and `Features.Breweries` get `AddApplicationPart`
(controllers)
- `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers
only
- `Features.Locations` and `Features.Beers` not referenced by `API.Core` at
all
- **Feature slices**: each owns its own controller (where present), commands/queries + handlers, validators, and Dapper repository
- `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`)
- internally namespaced `Features.Auth`, despite project name `Features.Users`
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked only via MediatR commands sent from other slices
- `Features.Locations` has no controller; one MediatR handler (`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core` never scans it
- `Features.Beers` is scaffolded (project + references only, no source files yet)
- **Feature slices**: each owns its own controller (where present),
commands/queries + handlers, validators, and Dapper repository
- `Features.Users` covers auth (`Commands/Authentication`), account management
(`Commands/Account`), and profile/avatar (`Commands/Profile`)
- internally namespaced `Features.Auth`, despite project name
`Features.Users`
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked
only via MediatR commands sent from other slices
- `Features.Locations` has no controller; one MediatR handler
(`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core`
never scans it
- `Features.Beers` is scaffolded (project + references only, no source files
yet)
- **The one exception to slice isolation**: `Features.Users` takes a direct project reference to `Features.PhotoUpload`
- **The one exception to slice isolation**: `Features.Users` takes a direct
project reference to `Features.PhotoUpload`
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
- unlike the email flow, the command type lives in the target slice itself, not in `Shared.Application`
- unlike the email flow, the command type lives in the target slice itself,
not in `Shared.Application`
- **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline)
- Cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
- Cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
- MediatR command contract (not project reference)
- Every slice (including `Features.Emails`) references it for the pipeline behavior alone
- Every slice (including `Features.Emails`) references it for the pipeline
behavior alone
- **Domain.Entities**:
- `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`
- `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)
- `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
@@ -68,7 +85,8 @@ see the notes for what else each of those foundation projects pulls in.
- **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
- distinct from `Database.Seed`, which loads data through the feature slices'
own DI extensions
- **Database.Seed**:
- calls each slice's `AddFeatures*()` DI extension directly
@@ -76,6 +94,10 @@ see the notes for what else each of those foundation projects pulls in.
- no API host, no shared pipeline with `API.Core`
- **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
- `Infrastructure.Configuration` (the shared options-binding project referenced by `Database.Connection`, `Infrastructure.Email`, `Infrastructure.FileUpload`, and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing them would just add a second copy of the same fan-in
- `Domain.Exceptions` (referenced by nearly every project directly, including
all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
- `Infrastructure.Configuration` (the shared options-binding project
referenced by `Database.Connection`, `Infrastructure.Email`,
`Infrastructure.FileUpload`, and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing
them would just add a second copy of the same fan-in

View File

@@ -21,8 +21,7 @@ The project uses Docker Compose to orchestrate multiple services:
- React Router website (SSR frontend)
- Test runners
See the [deployment diagram](diagrams/deployment.md) for visual
representation.
See the [deployment diagram](diagrams/deployment.md) for visual representation.
## Docker Compose environments

View File

@@ -308,9 +308,9 @@ cd web
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.
`.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**:

View File

@@ -70,10 +70,12 @@ and SeaweedFS (local dev S3-compatible object storage for photo uploads).
Available endpoints:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
- SeaweedFS S3 API: http://localhost:8333
| Service | URL |
| ---------------- | ----------------------------- |
| API Swagger | http://localhost:8080/swagger |
| Health Check | http://localhost:8080/health |
| Mailpit UI | http://localhost:8025 |
| SeaweedFS S3 API | http://localhost:8333 |
### 4. Start the frontend

View File

@@ -16,16 +16,12 @@ Biergarten App.
The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`), covering that slice's command/query handlers,
with dependencies mocked via Moq (no real database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared
website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
components
| Suite | Purpose |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **API.Specs** | BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database |
| **Features.\*.Tests** | One unit test project per backend feature slice (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that slice's command/query handlers, with dependencies mocked via Moq (no real database required) |
| **Storybook Vitest project** | Browser-based interaction tests for shared website stories |
| **Storybook Playwright suite** | Browser checks against Storybook-rendered components |
## Running tests with Docker (recommended)
@@ -80,19 +76,6 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
You can run individual test projects locally without Docker:
### Integration tests (API.Specs)
```bash
cd web/backend
dotnet test API/API.Specs/API.Specs.csproj
```
**Requirements**:
- SQL Server instance running
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
### Feature slice unit tests
Each feature slice has its own test project, covering its command/query
@@ -122,6 +105,19 @@ npm install
npm run test:storybook
```
### Integration tests (API.Specs)
```bash
cd web/backend
dotnet test API/API.Specs/API.Specs.csproj
```
**Requirements**:
- SQL Server instance running
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
**Purpose**:
- Verifies shared stories such as form fields, submit buttons, navbar states,

View File

@@ -12,11 +12,11 @@ tags:
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
| Token Type | Lifetime | Purpose |
| --- | --- | --- |
| **Access Tokens** | 1 hour | API authentication |
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
| **Confirmation Tokens** | 30 minutes | Email confirmation |
| Token Type | Lifetime | Purpose |
| ----------------------- | ---------- | --------------------------- |
| **Access Tokens** | 1 hour | API authentication |
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
| **Confirmation Tokens** | 30 minutes | Email confirmation |
## Components
@@ -48,16 +48,15 @@ Token generation and validation live on the same slice-internal service.
- `GenerateAccessToken(Guid userId, string username)` - Creates 1-hour access
token
- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day
refresh token
- `GenerateConfirmationToken(Guid userId, string username)` - Creates
30-minute confirmation token
- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day refresh
token
- `GenerateConfirmationToken(Guid userId, string username)` - Creates 30-minute
confirmation token
**Validation methods:**
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation
tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
There is no `ValidateAccessTokenAsync` on `ITokenService`. Access-token
validation doesn't go through `ITokenService` at all: `JwtAuthenticationHandler`
@@ -181,43 +180,43 @@ Each token is validated for:
Validation failures return HTTP 401 Unauthorized. Regardless of the specific
cause (invalid signature, expired token, missing header, malformed claims),
`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same
generic message: `"Unauthorized: Invalid or missing authentication token"` —
it doesn't distinguish failure reasons in the response.
`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same generic
message: `"Unauthorized: Invalid or missing authentication token"` — it doesn't
distinguish failure reasons in the response.
## Token lifecycle
### Access token lifecycle
| Step | Description |
| --- | --- |
| Generation | During login (1-hour validity) |
| Usage | Included in Authorization header on API requests |
| Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token |
| Step | Description |
| ---------- | ------------------------------------------------ |
| Generation | During login (1-hour validity) |
| Usage | Included in Authorization header on API requests |
| Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token |
### Refresh token lifecycle
| Step | Description |
| --- | --- |
| Generation | During login (21-day validity) |
| Storage | Client-side (secure storage) |
| Usage | Posted to `/api/auth/refresh` endpoint |
| Step | Description |
| ---------- | ------------------------------------------------ |
| Generation | During login (21-day validity) |
| Storage | Client-side (secure storage) |
| Usage | Posted to `/api/auth/refresh` endpoint |
| Validation | Validated by `ITokenService.RefreshTokenAsync()` |
| Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days |
| Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days |
### Confirmation token lifecycle
| Step | Description |
| --- | --- |
| Generation | During user registration (30-minute validity) |
| Delivery | Emailed to user in confirmation link |
| Usage | User clicks link, token posted to `/api/auth/confirm` |
| Validation | Validated by `ConfirmUserHandler` |
| Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes |
| Step | Description |
| ---------- | ----------------------------------------------------- |
| Generation | During user registration (30-minute validity) |
| Delivery | Emailed to user in confirmation link |
| Usage | User clicks link, token posted to `/api/auth/confirm` |
| Validation | Validated by `ConfirmUserHandler` |
| Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes |
## Testing
@@ -277,22 +276,18 @@ All of the following live in `Features.Users.Tests`.
### Stretch goals
1. **Middleware for Access Token Validation**
- Automatically validate access tokens on protected routes
- Populate HttpContext.User from token claims
- Return 401 for invalid/missing tokens
1. **Middleware for Access Token Validation** - Automatically validate access
tokens on protected routes - Populate HttpContext.User from token claims -
Return 401 for invalid/missing tokens
2. **Token Denylisting**
- Implement token revocation (for example, on logout)
- Store denylisted tokens in cache/database
- Check the denylist during validation
2. **Token Denylisting** - Implement token revocation (for example, on logout) -
Store denylisted tokens in cache/database - Check the denylist during
validation
3. **Refresh Token Rotation Strategy**
- Detect token reuse (replay attacks)
- Automatically invalidate entire token chain on reuse
- Log suspicious activity
3. **Refresh Token Rotation Strategy** - Detect token reuse (replay attacks) -
Automatically invalidate entire token chain on reuse - Log suspicious
activity
4. **Structured Logging**
- Log token validation attempts
- Track failed validation reasons
- Alert on repeated validation failures (brute force detection)
4. **Structured Logging** - Log token validation attempts - Track failed
validation reasons - Alert on repeated validation failures (brute force
detection)