This commit is contained in:
Aaron Po
2026-08-31 18:33:20 -04:00
parent 30995f6f40
commit 772a65d896
11 changed files with 353 additions and 218 deletions

View File

@@ -1,4 +1,13 @@
# Architecture ---
title: System architecture — backend, frontend, database, and deployment
last-updated: 2026-08-31
tags:
- architecture
- backend
- frontend
- vertical-slice
- deployment
---
This document describes the architecture of The Biergarten App. This document describes the architecture of The Biergarten App.
@@ -54,38 +63,60 @@ repository end to end. `Features.Emails`, `Features.Locations`, and
`Features.PhotoUpload` have no `Controllers/` folder; all three are invoked `Features.PhotoUpload` have no `Controllers/` folder; all three are invoked
internally by other slices via MediatR/DI, never over HTTP: internally by other slices via MediatR/DI, never over HTTP:
``` ```mermaid
┌───────────────────────────────────────────────────────────────────────┐ flowchart TB
│ API.Core (thin host) │ subgraph HOST["API.Core (thin host)"]
│ - Program.cs wiring: MediatR + AddApplicationPart per slice │ direction LR
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │ H1["Program.cs wiring:<br/>MediatR + AddApplicationPart per slice"]
└───────────────────────────────────────────────────────────────────────┘ H2["Swagger / OpenAPI"]
↓ discovers controllers via AddApplicationPart H3["JWT auth middleware"]
┌────────────────┬───────────────┬──────────────────┬──────────────────┬──────────────────┐ H4["GlobalExceptionFilter"]
│ Features.Users │Features. │ Features.Emails │ Features. │ Features. │ end
│ │Breweries │ (no controller, │ Locations │ PhotoUpload │
│ Controllers │ Controller │ internal only) │ (no controller, │ (no controller, │ subgraph SLICES["Feature Slices"]
│ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │ direction LR
│ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │ S1["Features.Users<br/>Controllers, Commands/Queries,<br/>Handlers, Repository"]
│ Handlers │ Handlers │ │ │ Handlers │ S2["Features.Breweries<br/>Controller, Commands/Queries,<br/>Handlers, Repository"]
│ Repository │ Repository │ EmailDispatcher │ │ Repository │ S3["Features.Emails<br/>(internal only)<br/>Commands, Handlers,<br/>EmailDispatcher"]
└────────────────┴───────────────┴──────────────────┴──────────────────┴──────────────────┘ S4["Features.Locations<br/>(internal only)<br/>Repository"]
↓ each slice depends only on shared/domain/infra, never on another slice S5["Features.PhotoUpload<br/>(internal only)<br/>Commands, Handlers,<br/>Repository"]
┌─────────────────────────┬─────────────────────────┬────────────────────┐ end
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
│ (ResponseBody envelope) │ (ValidationBehavior, │ Domain.Exceptions │ subgraph SHARED["Shared Projects"]
│ │ cross-slice email cmds) │ │ direction LR
└─────────────────────────┴─────────────────────────┴────────────────────┘ SH1["Shared.Contracts<br/>ResponseBody envelope"]
↓ SH2["Shared.Application<br/>ValidationBehavior,<br/>cross-slice email commands"]
┌─────────────────────────────────────────────────────────────────────────┐ end
│ Database.Connection, Infrastructure.Jwt, Infrastructure.PasswordHashing,│
│ Infrastructure.Email, Infrastructure.Email.Templates │ subgraph DOMAIN["Domain Layer"]
└─────────────────────────────────────────────────────────────────────────┘ direction LR
↓ D1["Domain.Entities"]
┌─────────────────────────────────────┐ D2["Domain.Exceptions"]
│ Database (SQL Server) │ end
│ - Tables & constraints │
└─────────────────────────────────────┘ subgraph INFRA["Infrastructure Layer"]
direction LR
I1["Database.Connection"]
I2["Infrastructure.Jwt"]
I3["Infrastructure.PasswordHashing"]
I4["Infrastructure.Email /<br/>Email.Templates"]
I5["Infrastructure.FileUpload"]
end
DB[("Database (SQL Server)")]
HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3
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
``` ```
Slices never reference each other's project. The one cross-slice interaction Slices never reference each other's project. The one cross-slice interaction
@@ -147,10 +178,10 @@ no `Controllers/` folder and no MediatR handlers; it exposes
(for example, `Features.Breweries`) do not yet depend on it; they run their own (for example, `Features.Breweries`) do not yet depend on it; they run their own
`CityID` existence checks inline. `Features.PhotoUpload` also has no `CityID` existence checks inline. `Features.PhotoUpload` also has no
`Controllers/` folder; its `UploadPhotoCommand` is sent by other features' `Controllers/` folder; its `UploadPhotoCommand` is sent by other features'
handlers (for example, a future brewery photo upload command) rather than handlers (for example, a future brewery photo upload command) rather than bound
bound to an HTTP route directly. The one exception today is the `/upload-test` to an HTTP route directly. The one exception today is the `/upload-test` minimal
minimal API endpoint wired directly in `API.Core/Program.cs`, a temporary route API endpoint wired directly in `API.Core/Program.cs`, a temporary route for
for exercising the upload path end to end. exercising the upload path end to end.
**Dependencies**: **Dependencies**:
@@ -196,17 +227,17 @@ whichever slices need them
**Components**: **Components**:
- **Database.Connection**: generic ADO.NET connection/command plumbing - **Database.Connection**: generic ADO.NET connection/command plumbing
(`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class),
domain-specific; each slice's own `Repository/` folder builds on this not domain-specific; each slice's own `Repository/` folder builds on this
- **Infrastructure.Jwt**: JWT token generation and validation - **Infrastructure.Jwt**: JWT token generation and validation
- **Infrastructure.PasswordHashing**: Argon2id password hashing - **Infrastructure.PasswordHashing**: Argon2id password hashing
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit) - **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
- **Infrastructure.Email.Templates**: Email template rendering (Razor - **Infrastructure.Email.Templates**: Email template rendering (Razor
components) components)
- **Infrastructure.FileUpload**: `IFileStorageProvider` abstraction over - **Infrastructure.FileUpload**: `IFileStorageProvider` abstraction over
S3-compatible object storage, implemented by `S3FileStorageProvider` S3-compatible object storage, implemented by `S3FileStorageProvider` (targets
(targets the SeaweedFS container in dev; any S3-compatible endpoint in the SeaweedFS container in dev; any S3-compatible endpoint in other
other environments) using the AWS SDK's `AmazonS3Client` with environments) using the AWS SDK's `AmazonS3Client` with
`ForcePathStyle = true` `ForcePathStyle = true`
**Dependencies**: **Dependencies**:
@@ -234,8 +265,8 @@ whichever slices need them
- `StateProvince` - A state/province referenced by a city - `StateProvince` - A state/province referenced by a city
- `Country` - A country referenced by a state/province - `Country` - A country referenced by a state/province
- `BeerStyle` - A beer style/category (schema table exists; no repository yet) - `BeerStyle` - A beer style/category (schema table exists; no repository yet)
- `BeerPost` - A beer listing tied to a brewery and style (schema table - `BeerPost` - A beer listing tied to a brewery and style (schema table exists;
exists; no repository yet) no repository yet)
- `Photo` - A photo uploaded by a user account, written via - `Photo` - A photo uploaded by a user account, written via
`Features.PhotoUpload`'s `IPhotoUploadRepository` `Features.PhotoUpload`'s `IPhotoUploadRepository`
@@ -297,9 +328,9 @@ only:
factory every slice's repository builds on factory every slice's repository builds on
- `Database.Connection/DapperRepository.cs`: shared abstract base class every - `Database.Connection/DapperRepository.cs`: shared abstract base class every
repository extends, providing `CreateConnection()` and a repository extends, providing `CreateConnection()` and a
`RollbackQuietlyAsync()` helper that rolls back a transaction while `RollbackQuietlyAsync()` helper that rolls back a transaction while swallowing
swallowing any exception the rollback itself raises, so the exception that any exception the rollback itself raises, so the exception that triggered the
triggered the rollback is what propagates rollback is what propagates
**Benefits**: **Benefits**:
@@ -332,24 +363,23 @@ method that registers its repository and slice-internal services
#### Direct SQL via Dapper/ADO.NET #### Direct SQL via Dapper/ADO.NET
**Purpose**: Keep data access explicit and colocated with the slice that owns **Purpose**: Keep data access explicit and colocated with the slice that owns it
it
**Strategy**: **Strategy**:
- No ORM; each repository issues inline SQL through Dapper, which handles - No ORM; each repository issues inline SQL through Dapper, which handles
parameter binding. The one parameter binding. The one wrinkle is the `GEOGRAPHY` column on
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper `BreweryPostLocation`, which Dapper can't deserialize as a UDT: reads select
can't deserialize as a UDT: reads select `CONVERT(varbinary(max), `CONVERT(varbinary(max), Coordinates)` so the value comes back as a plain
Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind. `byte[]` Dapper can bind. Proximity search
Proximity search (`GET /api/brewery/locations/nearby`) filters and orders (`GET /api/brewery/locations/nearby`) filters and orders rows server-side with
rows server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`, `Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding a client-side
avoiding a client-side distance calculation over every row. No spatial distance calculation over every row. No spatial index backs the query yet;
index backs the query yet; `schema.sql` sketches one, commented out, for `schema.sql` sketches one, commented out, for when the table grows large
when the table grows large enough to need it enough to need it
- Referential checks (for example, "does this `CityId` exist?") are explicit - Referential checks (for example, "does this `CityId` exist?") are explicit
`SELECT 1 ...` existence checks in the repository method, run inside the `SELECT 1 ...` existence checks in the repository method, run inside the same
same transaction as the write transaction as the write
- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column, - Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
- Repositories throw `Domain.Exceptions` types (`NotFoundException`, - Repositories throw `Domain.Exceptions` types (`NotFoundException`,
@@ -365,8 +395,7 @@ error-handling approach in more detail.
### Website (`web/frontend`) ### Website (`web/frontend`)
The website is a React Router 7 application with server-side rendering The website is a React Router 7 application with server-side rendering enabled.
enabled.
```text ```text
web/frontend/ web/frontend/
@@ -409,8 +438,7 @@ web/frontend/
### Theme system ### Theme system
The website uses semantic DaisyUI theme tokens backed by four Biergarten The website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
themes:
- Biergarten Lager - Biergarten Lager
- Biergarten Stout - Biergarten Stout
@@ -550,8 +578,8 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
**Seed Data**: **Seed Data**:
- Countries, state/provinces, and cities (via `Features.Locations`' - Countries, state/provinces, and cities (via `Features.Locations`'
`ILocationRepository`, which creates `Country`/`StateProvince` rows on `ILocationRepository`, which creates `Country`/`StateProvince` rows on demand
demand while resolving a city) while resolving a city)
- User accounts (via `Features.Users`' repository) - User accounts (via `Features.Users`' repository)
- Brewery posts with locations (via `Features.Breweries`' repository) - Brewery posts with locations (via `Features.Breweries`' repository)
@@ -601,16 +629,12 @@ healthcheck:
### Test pyramid ### Test pyramid
``` ```mermaid
┌──────────────┐ flowchart TB
│ Integration │ ← API.Specs (Reqnroll) IT["Integration Tests<br/>API.Specs (Reqnroll)"]
│ Tests │ 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)"]
├──────────────┤
│ Unit Tests │ ← Features.Users.Tests, Features.Breweries.Tests, IT --- UT
│ (per slice, │ Features.Emails.Tests, Features.PhotoUpload.Tests
│ handler │ (commands/queries/handlers, with repository/service
│ tests) │ dependencies mocked via Moq)
└──────────────┘
``` ```
**Strategy**: **Strategy**:

15
docs/index.md Normal file
View File

@@ -0,0 +1,15 @@
---
title: The Biergarten App
last-updated: 2026-08-31
tags:
- overview
- index
---
Welcome to the documentation for The Biergarten App.
## Documentation Sections
- **[Architecture](architecture.md)** — System design and components
- **[Website](website/getting-started.md)** — Backend and frontend documentation
- **[Pipeline](pipeline/README.md)** — Data pipeline and ETL processes

View File

@@ -1,4 +1,13 @@
# Ethics, bias, and known issues ---
title: Pipeline ethics, bias, and known issues
last-updated: 2026-08-31
tags:
- pipeline
- ethics
- bias
- known-issues
- ai-generated
---
This document covers the ethical context of the Biergarten Pipeline's output, This document covers the ethical context of the Biergarten Pipeline's output,
the model's biases, and known issues including hallucinated brewing science and the model's biases, and known issues including hallucinated brewing science and
@@ -290,11 +299,11 @@ Each brewery and user address carries a longitude/latitude pair, sampled
uniformly at random within a 5 km disc centred on the city's curated uniformly at random within a 5 km disc centred on the city's curated
`latitude`/`longitude` from `locations.json`. `latitude`/`longitude` from `locations.json`.
The resulting point is **plausible by construction**, always falling within The resulting point is **plausible by construction**, always falling within the
the city's vicinity, but it is not looked up from any real address registry. city's vicinity, but it is not looked up from any real address registry. It does
It does not correspond to a real address, business, or resident, and should not correspond to a real address, business, or resident, and should not be
not be treated as accurate geocoding data. This is consistent with the rest of treated as accurate geocoding data. This is consistent with the rest of the
the dataset: fixture data for a proof-of-concept, not a source of truth. dataset: fixture data for a proof-of-concept, not a source of truth.
--- ---

View File

@@ -1,4 +1,13 @@
# Biergarten Pipeline ---
title: Biergarten Pipeline — C++ CLI for generating seed data
last-updated: 2026-08-31
tags:
- pipeline
- cpp
- llm
- seed-data
- docker
---
A C++20 command-line pipeline that samples city records from local JSON, A C++20 command-line pipeline that samples city records from local JSON,
enriches each with Wikipedia context, and generates bilingual brewery names and enriches each with Wikipedia context, and generates bilingual brewery names and
@@ -436,8 +445,8 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
Paths below are relative to `tooling/pipeline/`. Paths below are relative to `tooling/pipeline/`.
- `src/main.cc`: argument parsing and DI composition root. - `src/main.cc`: argument parsing and DI composition root.
- `src/biergarten_pipeline_orchestrator/`: orchestration, sampling, logging, - `src/biergarten_pipeline_orchestrator/`: orchestration, sampling, logging, and
and export. export.
- `src/services/curated_data/`: `CuratedJsonDataService`, the file-backed - `src/services/curated_data/`: `CuratedJsonDataService`, the file-backed
`ICuratedDataService`, and `MockCuratedDataService`, the in-memory `ICuratedDataService`, and `MockCuratedDataService`, the in-memory
`ICuratedDataService` used in `--mocked` runs. `ICuratedDataService` used in `--mocked` runs.

View File

@@ -1,4 +1,12 @@
# Pipeline Roadmap — Reaching the Planned Architecture ---
title: Pipeline roadmap — reaching the planned architecture
last-updated: 2026-08-31
tags:
- pipeline
- roadmap
- cpp
- concurrency
---
This is the engineering breakdown for closing the gap between the **current** This is the engineering breakdown for closing the gap between the **current**
pipeline and the **planned** architecture documented in pipeline and the **planned** architecture documented in

View File

@@ -1,4 +1,13 @@
# Database ---
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 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 convention. For the vertical-slice code that talks to this schema, see
@@ -7,15 +16,36 @@ convention. For the vertical-slice code that talks to this schema, see
## Schema ## Schema
The full schema lives under The full schema lives under
`web/backend/Database/Database.Migrations/scripts/01-schema/`, one numbered `web/backend/Database/Database.Migrations/scripts/01-schema/` with one numbered
script per table (`01-UserAccount.sql`, `02-Photo.sql`, ...), run in order by script per table.
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
owning feature slice's repository (see ```
└── 01-schema
├── 01-UserAccount.sql
├── 02-Photo.sql
├── 03-UserVerification.sql
├── 04-UserCredential.sql
├── 05-UserFollow.sql
├── 06-Country.sql
├── 07-StateProvince.sql
├── 08-City.sql
├── 09-BreweryPost.sql
├── 10-BreweryPostLocation.sql
├── 11-BreweryPostPhoto.sql
├── 12-BeerStyle.sql
├── 13-BeerPost.sql
├── 14-BeerPostPhoto.sql
├── 15-BeerPostComment.sql
├── 16-UserProfile.sql
└── 17-UserAvatar.sql
```
Each script is run in order by `Database.Migrations` (DbUp) on startup. Each
table is queried directly by the owning feature slice's repository (see
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)). [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 A generated DBML copy of the schema (for import into a diagramming tool such as
as dbdiagram.io) lives at dbdiagram.io) lives at `web/backend/Database/Database.Migrations/schema.dbml`.
`web/backend/Database/Database.Migrations/schema.dbml`.
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
@@ -24,7 +54,7 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
### Tables backed by the API today ### Tables backed by the API today
| Table | Backed by | Notes | | Table | Backed by | Notes |
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique | | `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 | | `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 | | `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
@@ -43,7 +73,7 @@ These exist so the schema can support planned functionality, but have no
repository or seed data yet: repository or seed data yet:
| Table | Purpose (from schema) | | Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) | | `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet | | `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 | | `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 |
@@ -51,16 +81,24 @@ repository or seed data yet:
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` | | `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` | | `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
## Migrations ## Managing Migrations (DbUp)
**Tool**: DbUp, run by the `Database.Migrations` project/container. 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.
Scripts live under `scripts/`, applied in order and tracked so a script never **Note:** The schema is currently considered volatile. At this stage of
re-runs once it has succeeded. Today that's `01-schema/`, with one script per development, fresh database instances are provisioned for both development and
table, numbered so a table's foreign keys always point at an earlier-numbered testing, so no production data exists yet.
script. When the schema needs to change, add a new numbered script rather than
editing an existing one in place: DbUp's tracking is per-script, so an edited ### How to Make Schema Changes
already-applied script never re-runs against existing databases.
- **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 ## Data seeding
@@ -81,7 +119,7 @@ repository (`Features.PhotoUpload`) but no seed data.
409, 400, ...); an unmapped `SqlException` (for example, a genuine 409, 400, ...); an unmapped `SqlException` (for example, a genuine
connectivity or constraint-violation failure) maps to 503. connectivity or constraint-violation failure) maps to 503.
- One exception: `Features.Users`' `DapperUserStore` does catch - One exception: `Features.Users`' `DapperUserStore` does catch `SqlException`
`SqlException` directly, to detect a duplicate-key race (SQL Server error directly, to detect a duplicate-key race (SQL Server error 2601 or 2627)
2601 or 2627) when two requests try to verify the same account when two requests try to verify the same account concurrently; see
concurrently; see `DapperUserStore.IsDuplicateKeyViolation`. `DapperUserStore.IsDuplicateKeyViolation`.

View File

@@ -1,4 +1,11 @@
# Docker guide ---
title: Docker deployment, configuration, and troubleshooting
last-updated: 2026-08-31
tags:
- docker
- docker-compose
- deployment
---
This document covers Docker deployment, configuration, and troubleshooting for This document covers Docker deployment, configuration, and troubleshooting for
The Biergarten App. The Biergarten App.
@@ -114,9 +121,9 @@ 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 `wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
stopped, regardless of which one finishes first; `up --abort-on-container-exit` stopped, regardless of which one finishes first; `up --abort-on-container-exit`
isn't used here because the one-shot `database.migrations`/`database.seed` isn't used here because the one-shot `database.migrations`/`database.seed` jobs
jobs and `frontend.tests` (no database dependency) can exit before the other and `frontend.tests` (no database dependency) can exit before the other test
test containers, which would end the run prematurely. See containers, which would end the run prematurely. See
[Testing](testing.md#running-tests-with-docker-recommended) for details. [Testing](testing.md#running-tests-with-docker-recommended) for details.
### 3. Production (`docker-compose.prod.yaml`) ### 3. Production (`docker-compose.prod.yaml`)
@@ -131,8 +138,8 @@ test containers, which would end the run prematurely. See
- Health checks enabled - Health checks enabled
- Restart policies (unless-stopped) - Restart policies (unless-stopped)
- Security hardening - Security hardening
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` - Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
are reachable only from other containers on `prodnet` reachable only from other containers on `prodnet`
**Services**: **Services**:
@@ -206,8 +213,8 @@ graph TD
E[seaweedfs: container started] --> D E[seaweedfs: container started] --> D
``` ```
In development, `api.core` also depends on `seaweedfs` (`condition: In development, `api.core` also depends on `seaweedfs`
service_started`, not a health check) alongside `database.seed`. (`condition: service_started`, not a health check) alongside `database.seed`.
**Health Check Example** (SQL Server): **Health Check Example** (SQL Server):
@@ -241,8 +248,7 @@ api.core:
- `sqlserverdata-dev` - Database files persist between restarts - `sqlserverdata-dev` - Database files persist between restarts
- `nuget-cache-dev` - NuGet package cache (speeds up builds) - `nuget-cache-dev` - NuGet package cache (speeds up builds)
- `seaweedfsdata-dev` - SeaweedFS object storage data persists between - `seaweedfsdata-dev` - SeaweedFS object storage data persists between restarts
restarts
**Testing**: **Testing**:

View File

@@ -1,4 +1,12 @@
# Environment variables ---
title: Environment variables for the backend, frontend, and Docker
last-updated: 2026-08-31
tags:
- environment-variables
- configuration
- secrets
- docker
---
This document covers the environment variables used by the Biergarten stack. This document covers the environment variables used by the Biergarten stack.
@@ -37,9 +45,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`,
`SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`) `SEAWEEDFS_SECRET_ACCESS_KEY`) with freshly randomized ones via `openssl`.
with freshly randomized ones via `openssl`. Non-secret values are left Non-secret values are left untouched. The output file is written with `600`
untouched. The output file is written with `600` permissions. permissions.
```bash ```bash
cd web cd web
@@ -171,12 +179,12 @@ DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
``` ```
`ASPNETCORE_URLS` is hardcoded directly in `docker-compose.dev.yaml` and `ASPNETCORE_URLS` is hardcoded directly in `docker-compose.dev.yaml` and
`docker-compose.prod.yaml` (not sourced from the `.env` file), so it's `docker-compose.prod.yaml` (not sourced from the `.env` file), so it's required
required only for Docker. Outside Docker, `dotnet run` falls back to the only for Docker. Outside Docker, `dotnet run` falls back to the `http` profile
`http` profile in `API.Core/Properties/launchSettings.json` in `API.Core/Properties/launchSettings.json` (`http://localhost:5069`), so
(`http://localhost:5069`), so manual backend setup (see manual backend setup (see
[Getting Started](getting-started.md#manual-backend-setup)) doesn't need to [Getting Started](getting-started.md#manual-backend-setup)) doesn't need to set
set it. it.
## Frontend variables (`web/frontend`) ## Frontend variables (`web/frontend`)
@@ -191,8 +199,8 @@ PORT=3000 # Port the built server listens on
The `frontend` Docker service hardcodes `API_BASE_URL` to `http://api.core:8080` The `frontend` Docker service hardcodes `API_BASE_URL` to `http://api.core:8080`
(the backend's in-network service name) and `PORT` to `3000`; only (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 `SESSION_SECRET` is sourced from the env file, and `NODE_ENV` is set per compose
compose file (`development` in dev, `production` in prod). file (`development` in dev, `production` in prod).
### Frontend variable details ### Frontend variable details
@@ -357,8 +365,8 @@ Variables are validated at startup:
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, - Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`,
`SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`, `SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values) `SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values) cause
cause the application to fail with an `InvalidOperationException` the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the "minimum 32 - No minimum length is enforced on the token secrets in code; the "minimum 32
characters" guidance above is a recommendation, not an enforced check characters" guidance above is a recommendation, not an enforced check

View File

@@ -1,4 +1,11 @@
# Getting started ---
title: Getting started — local setup for backend and frontend
last-updated: 2026-08-31
tags:
- getting-started
- setup
- onboarding
---
This guide covers local setup for the Biergarten stack: the .NET backend in This guide covers local setup for the Biergarten stack: the .NET backend in
`web/backend` and the React Router frontend in `web/frontend`. `web/backend` and the React Router frontend in `web/frontend`.
@@ -58,9 +65,8 @@ section for details on `generate-env.sh`.
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
``` ```
This starts SQL Server, migrations, seeding, the API, Mailpit (local dev This starts SQL Server, migrations, seeding, the API, Mailpit (local dev SMTP),
SMTP), and SeaweedFS (local dev S3-compatible object storage for photo and SeaweedFS (local dev S3-compatible object storage for photo uploads).
uploads).
Available endpoints: Available endpoints:

View File

@@ -1,4 +1,13 @@
# Testing ---
title: Testing strategy — unit, integration, and Storybook suites
last-updated: 2026-08-31
tags:
- testing
- ci
- xunit
- reqnroll
- storybook
---
This document describes the testing strategy and how to run tests for The This document describes the testing strategy and how to run tests for The
Biergarten App. Biergarten App.
@@ -10,10 +19,9 @@ The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a - **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
live, seeded database live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice - **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Users.Tests`, `Features.Breweries.Tests`, (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that `Features.PhotoUpload.Tests`), covering that slice's command/query handlers,
slice's command/query handlers, with dependencies mocked via Moq (no real with dependencies mocked via Moq (no real database required)
database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared - **Storybook Vitest project** - Browser-based interaction tests for shared
website stories website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered - **Storybook Playwright suite** - Browser checks against Storybook-rendered
@@ -42,8 +50,8 @@ This command:
`wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all `wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
stopped, regardless of which one finishes first. Long-running services stopped, regardless of which one finishes first. Long-running services
(`sqlserver`, `seaweedfs`) and the one-shot `database.migrations` / (`sqlserver`, `seaweedfs`) and the one-shot `database.migrations` /
`database.seed` jobs keep running in the background alongside them and are `database.seed` jobs keep running in the background alongside them and are torn
torn down separately (see below); `wait` doesn't touch them. down separately (see below); `wait` doesn't touch them.
### View test results ### View test results
@@ -175,8 +183,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
**Features.Breweries.Tests**: **Features.Breweries.Tests**:
- Brewery create/update/delete commands and get-by-id/get-all queries - Brewery create/update/delete commands and get-by-id/get-all queries
- Brewery location queries: all locations, and locations within a given range - Brewery location queries: all locations, and locations within a given range of
of a coordinate a coordinate
**Features.Emails.Tests**: **Features.Emails.Tests**:
@@ -186,9 +194,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- Upload command handler: storage key construction, content type passthrough, - Upload command handler: storage key construction, content type passthrough,
and photo persistence via the mocked storage provider and repository and photo persistence via the mocked storage provider and repository
- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature - Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature checks
checks (including rejection of files with a mismatched or missing (including rejection of files with a mismatched or missing signature)
signature)
**Frontend UI Coverage**: **Frontend UI Coverage**:
@@ -295,8 +302,8 @@ Features.Users.Tests/
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`, Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` `Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a
a validator test file alongside its handler test). validator test file alongside its handler test).
## Writing tests ## Writing tests
@@ -337,10 +344,10 @@ Scenario: User login with valid credentials
## Continuous integration ## Continuous integration
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push and
and pull request to `main`. It runs the same test Docker Compose pull request to `main`. It runs the same test Docker Compose configuration used
configuration used locally, so backend and frontend tests execute in the same locally, so backend and frontend tests execute in the same containerized
containerized environment in CI as they do on a developer machine: environment in CI as they do on a developer machine:
```bash ```bash
# CI/CD command # CI/CD command
@@ -351,16 +358,15 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
``` ```
The workflow inspects the exit code of each test container The workflow inspects the exit code of each test container
(`test-env-api-specs`, `test-env-unit-tests`, `test-env-frontend-tests`) (`test-env-api-specs`, `test-env-unit-tests`, `test-env-frontend-tests`) after
after `wait` returns and fails the job if any of them is non-zero. `wait` returns and fails the job if any of them is non-zero. `./test-results/`
`./test-results/` is uploaded as a build artifact regardless of outcome. is uploaded as a build artifact regardless of outcome.
`up -d` + `wait` keeps container teardown independent of any single `up -d` + `wait` keeps container teardown independent of any single container's
container's exit. The one-shot `database.migrations`/`database.seed` jobs exit. The one-shot `database.migrations`/`database.seed` jobs exit 0 partway
exit 0 partway through the run, and `frontend.tests` (which has no database through the run, and `frontend.tests` (which has no database dependency)
dependency) reliably finishes before `api.specs`/`unit.tests` start; because reliably finishes before `api.specs`/`unit.tests` start; because `wait` blocks
`wait` blocks on all three test containers, neither early exit ends the run on all three test containers, neither early exit ends the run prematurely.
prematurely.
Exit codes: Exit codes:
@@ -368,9 +374,9 @@ Exit codes:
- Non-zero - Test failures occurred - Non-zero - Test failures occurred
The frontend's Storybook Vitest and Playwright suites run inside the The frontend's Storybook Vitest and Playwright suites run inside the
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that `frontend.tests` container (see [Docker Guide](docker.md)) as part of that same
same compose run, so no separate frontend CI step is needed. To run them compose run, so no separate frontend CI step is needed. To run them locally
locally without Docker: without Docker:
```bash ```bash
cd web/frontend cd web/frontend
@@ -381,9 +387,9 @@ npm run test:storybook:playwright
### Running the workflow locally with `act` ### Running the workflow locally with `act`
[`act`](https://github.com/nektos/act) replays `.github/workflows/*.yml` on [`act`](https://github.com/nektos/act) replays `.github/workflows/*.yml` on your
your own machine using Docker, so you can reproduce a CI run (or a fix for own machine using Docker, so you can reproduce a CI run (or a fix for one)
one) without pushing a branch. without pushing a branch.
**Install** (macOS): **Install** (macOS):
@@ -401,34 +407,31 @@ act push \
--container-architecture linux/amd64 --container-architecture linux/amd64
``` ```
- `-j containerized-tests` runs only that job (there's currently just the - `-j containerized-tests` runs only that job (there's currently just the one).
one). - `-P ubuntu-latest=catthehacker/ubuntu:act-latest` pins the runner image act
- `-P ubuntu-latest=catthehacker/ubuntu:act-latest` pins the runner image uses to impersonate `ubuntu-latest`. Without it, act's first run prompts
act uses to impersonate `ubuntu-latest`. Without it, act's first run interactively to choose a default image size, which hangs/fails under a
prompts interactively to choose a default image size, which hangs/fails non-interactive shell.
under a non-interactive shell.
- `--container-architecture linux/amd64` is required on Apple Silicon: the - `--container-architecture linux/amd64` is required on Apple Silicon: the
`sqlserver` service's image is amd64-only, and act needs to know to `sqlserver` service's image is amd64-only, and act needs to know to emulate
emulate that platform for the whole job container, not just that one that platform for the whole job container, not just that one service.
service.
- No secrets or `.actrc` are needed: `generate-env.sh` creates its own - No secrets or `.actrc` are needed: `generate-env.sh` creates its own
`.env.test` with freshly randomized values, same as in real CI. `.env.test` with freshly randomized values, same as in real CI.
**Known act-only limitation**: the "Upload test results" step **Known act-only limitation**: the "Upload test results" step
(`actions/upload-artifact@v4`) fails locally with `Unable to get the (`actions/upload-artifact@v4`) fails locally with
ACTIONS_RUNTIME_TOKEN env variable`. act doesn't provide a real Actions `Unable to get the ACTIONS_RUNTIME_TOKEN env variable`. act doesn't provide a
artifact backend, so this step, and only this step, is expected to fail real Actions artifact backend, so this step, and only this step, is expected to
under act even when everything else passes. It works normally on fail under act even when everything else passes. It works normally on
GitHub-hosted runners. GitHub-hosted runners.
**Stale local state**: `docker-compose.test.yaml` uses fixed container **Stale local state**: `docker-compose.test.yaml` uses fixed container names and
names and named volumes (`sqlserverdata-test`, `seaweedfsdata-test`) scoped named volumes (`sqlserverdata-test`, `seaweedfsdata-test`) scoped to the `web`
to the `web` compose project. If a previous local run (via `act` or a compose project. If a previous local run (via `act` or a manual `docker compose`
manual `docker compose` invocation) didn't get torn down, its SQL Server invocation) didn't get torn down, its SQL Server volume can persist with an old
volume can persist with an old `SA_PASSWORD` baked in, while a fresh `SA_PASSWORD` baked in, while a fresh `.env.test` generates a new one each run,
`.env.test` generates a new one each run, causing `Login failed for user causing `Login failed for user 'sa'` errors that look like a test bug but are
'sa'` errors that look like a test bug but are really a leftover volume. really a leftover volume. Clear it before re-running:
Clear it before re-running:
```bash ```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

View File

@@ -1,4 +1,12 @@
# Token validation architecture ---
title: JWT token validation architecture
last-updated: 2026-08-31
tags:
- jwt
- authentication
- security
- tokens
---
## Overview ## Overview
@@ -64,10 +72,9 @@ Token generation and validation live on the same slice-internal service.
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()` `TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()`
extension method, except for the lower-level `ITokenInfrastructure` (JWT extension method, except for the lower-level `ITokenInfrastructure` (JWT
signing/verification) it signing/verification) it depends on, which is registered by the host
depends on, which is registered by the host (`API.Core/Program.cs`) since (`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
`JwtAuthenticationHandler` (host-level auth middleware) also depends on it middleware) also depends on it directly.
directly.
### Integration points ### Integration points
@@ -77,14 +84,16 @@ directly.
1. Receives confirmation token from user via `ConfirmUserCommand` 1. Receives confirmation token from user via `ConfirmUserCommand`
2. Calls `ITokenService.ValidateConfirmationTokenAsync()` 2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token and looks it up via `UserManager<ApplicationUser>` 3. Extracts user ID from validated token and looks it up via
4. Marks the account confirmed via `IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`, `UserManager<ApplicationUser>`
then persists it via `UserManager<ApplicationUser>.UpdateAsync()` 4. Marks the account confirmed via
`IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`, then persists it
via `UserManager<ApplicationUser>.UpdateAsync()`
5. Returns confirmation result 5. Returns confirmation result
`UserManager<ApplicationUser>` and `IUserEmailStore<ApplicationUser>` are `UserManager<ApplicationUser>` and `IUserEmailStore<ApplicationUser>` are
ASP.NET Core Identity's abstractions, not EF Core's default Identity store: ASP.NET Core Identity's abstractions, not EF Core's default Identity store: the
the app backs them with its own `DapperUserStore` (see app backs them with its own `DapperUserStore` (see
[Database](database.md#database-error-handling)), which persists against the [Database](database.md#database-error-handling)), which persists against the
existing `UserAccount`/`UserCredential`/`UserVerification` tables via Dapper, existing `UserAccount`/`UserCredential`/`UserVerification` tables via Dapper,
consistent with the rest of the app's repository pattern. consistent with the rest of the app's repository pattern.