Refactor documentation and diagrams for web/pipeline. (#272)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> for assistance with diagram creation and documentation prose updates.
This commit is contained in:
Aaron Po
2026-09-02 01:58:40 -04:00
committed by GitHub
parent 1fe35a2ea6
commit 30317326ce
83 changed files with 4291 additions and 4497 deletions

609
docs/web/architecture.md Normal file
View File

@@ -0,0 +1,609 @@
# Architecture
This document describes the architecture of The Biergarten App.
## High-level overview
The Biergarten App is a monorepo split between the backend and the website:
- **Backend**: .NET 10 Web API with SQL Server, organized as vertical feature
slices with MediatR
- **Frontend**: React 19 + React Router 8 website in `web/frontend`
- **Architecture Style**: Vertical-slice backend plus server-rendered React
frontend
`archive/next-js-web-app/` contains an archived Next.js frontend, kept for
reference.
## Diagrams
For visual representations, see:
- [Vertical Slice](diagrams/vertical-slice.md) - host wiring and project
reference diagrams
- [Deployment](diagrams/deployment.md) - Docker deployment diagram
- [Database Schema](diagrams/database-schema.md) - database relationships
Authentication workflow diagrams, one per flow, under
[`diagrams/auth/`](diagrams/auth/):
- [Registration](diagrams/auth/registration.md)
- [Login](diagrams/auth/login.md)
- [Refresh Token](diagrams/auth/refresh-token.md)
- [Confirm User](diagrams/auth/confirm-user.md)
- [Resend Confirmation](diagrams/auth/resend-confirmation.md)
- [Update Username](diagrams/auth/update-username.md)
- [Update Email](diagrams/auth/update-email.md)
- [Update Password](diagrams/auth/update-password.md)
- [Update Profile](diagrams/auth/update-profile.md)
- [Delete Account](diagrams/auth/delete-account.md)
- [Error Handling](diagrams/auth/error-handling.md) - shared
`GlobalExceptionFilter` mapping used by all flows above
## Backend architecture
### Vertical-slice architecture pattern
The backend organizes business capabilities as feature slices instead of
technical layers.
Each feature (`Features.Users`, `Features.Breweries`, `Features.Emails`,
`Features.Locations`, `Features.PhotoUpload`) is a single project that owns its
own MediatR commands/queries/handlers, validators, and repository end to end.
`Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no
`Controllers/` folder; all three are invoked internally by other slices via
MediatR/DI, never over HTTP:
```mermaid
flowchart TB
subgraph HOST["API.Core (thin host)"]
direction LR
H1["Program.cs wiring:<br/>MediatR + AddApplicationPart per slice"]
H2["Swagger / OpenAPI"]
H3["JWT auth middleware"]
H4["GlobalExceptionFilter"]
end
subgraph SLICES["Feature Slices"]
direction LR
S1["Features.Users<br/>Controllers, Commands/Queries,<br/>Handlers, Repository"]
S2["Features.Breweries<br/>Controller, Commands/Queries,<br/>Handlers, Repository"]
S3["Features.Emails<br/>(internal only)<br/>Commands, Handlers,<br/>EmailDispatcher"]
S4["Features.Locations<br/>(internal only)<br/>Repository"]
S5["Features.PhotoUpload<br/>(internal only)<br/>Commands, Handlers,<br/>Repository"]
end
subgraph SHARED["Shared Projects"]
direction LR
SH1["Shared.Contracts<br/>ResponseBody envelope"]
SH2["Shared.Application<br/>ValidationBehavior,<br/>cross-slice email commands"]
end
subgraph DOMAIN["Domain Layer"]
direction LR
D1["Domain.Entities"]
D2["Domain.Exceptions"]
end
subgraph INFRA["Infrastructure Layer"]
direction LR
I1["Database.Connection"]
I2["Infrastructure.Jwt"]
I3["Infrastructure.PasswordHashing"]
I4["Infrastructure.Email /<br/>Email.Templates"]
I5["Infrastructure.FileUpload"]
end
DB[("Database (SQL Server)")]
HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3
S1 -- "ProjectReference<br/>(avatar upload)" --> S5
SLICES --> SHARED
SLICES --> DOMAIN
SLICES --> INFRA
INFRA --> DB
style S3 stroke-dasharray: 4 3
style S4 stroke-dasharray: 4 3
style S5 stroke-dasharray: 4 3
```
### Layer responsibilities
#### API layer (`API.Core`)
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
own
**Components**:
| Component | Description |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
| `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |
**Dependencies**:
- Every `Features.*` project (for controller/MediatR discovery)
- `Shared.Contracts`, `Shared.Application`
- `Infrastructure.Jwt` (for the auth middleware)
**Rules**:
- No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices
#### Feature slices (`Features.Users`, `Features.Breweries`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`)
**Purpose**: Each slice is the complete vertical for one business capability
**Components** (per slice):
| Component | Description |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `Controllers/` | HTTP endpoints, binding directly to Command/Query types as the request contract |
| `Commands/<Operation>/` and `Queries/<Operation>/` | One folder per operation, each containing the Command/Query record, its `IRequestHandler`, and (for commands) a FluentValidation validator |
| `Repository/` | The slice's own Dapper repository implementation |
| `Dtos/` | Response shapes returned by query handlers (never the raw domain entity) |
| `DependencyInjection/` | An `AddFeaturesX()` extension method registering the slice's repository/services |
##### Note
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
commands sent from other slices, never over HTTP.
`Features.PhotoUpload` also has no `Controllers/` folder; its
`UploadPhotoCommand` is sent by other features' handlers (for example, a future
brewery photo upload command) rather than bound to an HTTP route directly.
**Dependencies**:
- `Domain.Entities`, `Domain.Exceptions`
- `Database.Connection` (generic ADO.NET connection/command plumbing) plus
whichever infrastructure project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with one exception: `Features.Users` references
`Features.PhotoUpload` directly for avatar uploads
**Rules**:
- All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
**Purpose**: The minimum cross-slice surface area required because every slice
needs it, or because duplicating it four times would be worse than sharing it
**Components**:
| Project | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns |
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) |
**Rules**:
- Kept deliberately small: this is the exception to "no slice depends on another
slice".
#### Infrastructure layer
**Purpose**: Technical capabilities and external integrations, shared by
whichever slices need them
**Components**:
| Project | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. |
| `Infrastructure.Jwt` | JWT token generation and validation. |
| `Infrastructure.PasswordHashing` | Argon2id password hashing. |
| `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). |
| `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
| `Infrastructure.FileUpload` | `IFileStorageProvider` abstraction over S3-compatible object storage, implemented by `S3FileStorageProvider` (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's `AmazonS3Client` with `ForcePathStyle = true`. |
**Dependencies**:
- Domain entities
- External libraries (ADO.NET, JWT, Argon2, MailKit, AWSSDK.S3, and so on)
**Rules**:
- Implements technical concerns only, no business logic
- Reusable across slices
#### Domain layer (`Domain.Entities`)
**Purpose**: Core business entities and models
**Components**:
| Entity | Description |
| --------------------- | -------------------------------------------------------------------------------------------------- |
| `UserAccount` | User profile data. |
| `UserCredential` | Authentication credentials. |
| `UserVerification` | Account verification state. |
| `BreweryPost` | A user-submitted brewery listing. |
| `BreweryPostLocation` | A brewery's address and coordinates. |
| `City` | A city referenced by a brewery location. |
| `StateProvince` | A state/province referenced by a city. |
| `Country` | A country referenced by a state/province. |
| `BeerStyle` | A beer style/category (schema table exists; no repository yet). |
| `BeerPost` | A beer listing tied to a brewery and style (schema table exists; no repository yet). |
| `Photo` | A photo uploaded by a user account, written via `Features.PhotoUpload`'s `IPhotoUploadRepository`. |
**Dependencies**:
- None (pure domain)
**Rules**:
- Plain Old CLR Objects (POCOs)
- No framework dependencies
- No infrastructure references
- Represents business concepts
### Design patterns
#### Vertical slice + MediatR
**Purpose**: Organize code by feature instead of by technical layer, so
everything needed to understand or change one capability lives in one project
**Implementation**:
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (for example, `GetBreweryByIdQuery`)
- Controllers generally bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- The exception to this is for authenticated routes, when the current user id
needs to be extracted from the authentication token to be used in a command.
- A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes
- Query handlers map to a dedicated response `Dto`, so domain entities never
leak
**Example**:
```csharp
public record CreateBreweryCommand(Guid PostedById, string BreweryName, string Description, ...)
: IRequest<BreweryDto>;
public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler<CreateBreweryCommand, BreweryDto>
{
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) { ... }
}
```
#### Repository pattern
**Purpose**: Abstract database access behind interfaces
**Implementation**: each slice owns its own repository, scoped to that feature
only:
- `Features.Users/Repository/IUserListRepository.cs`,
`Features.Users/Repository/IUserProfileRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs`
- `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection
factory every slice's repository builds on
- `Database.Connection/DapperRepository.cs`: shared abstract base class every
repository extends, providing `CreateConnection()` and a
`RollbackQuietlyAsync()` helper that rolls back a transaction while swallowing
any exception the rollback itself raises, so the exception that triggered the
rollback is what propagates
**Benefits**:
- Testable (easy to mock)
- Each slice's data access logic is self-contained
**Example**:
```csharp
public interface IUserListRepository
{
Task<UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
}
```
#### Dependency injection
**Purpose**: Loose coupling and testability
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
method that registers its repository and slice-internal services
**Lifetimes**:
- Scoped: Repositories, slice-internal services (per request)
- Singleton: `DefaultSqlConnectionFactory`
- Transient: Utilities, helpers
#### Direct SQL via Dapper/ADO.NET
**Purpose**: Keep data access explicit and colocated with the slice that owns it
**Strategy**:
- Each repository issues inline SQL through Dapper, which handles parameter
binding.
- Proximity search (`GET /api/brewery/locations/nearby`) filters and orders rows
server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding
a client-side distance calculation over every row.
- Referential checks (for example, "does this `CityId` exist?") are explicit
`SELECT 1 ...` existence checks in the repository method, run inside the same
transaction as the write
- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
`ConflictException`, ...) directly when a check fails; `API.Core`'s
`GlobalExceptionFilter` maps those exception types to HTTP status codes
- Application focuses on orchestration; the database enforces integrity via
keys, `CHECK` constraints, and cascades
See [Database](database.md) for the schema and the app's SQL error-handling
approach in more detail.
## Frontend architecture
### Website (`web/frontend`)
The website is a React Router 8 application with server-side rendering enabled.
```text
web/frontend/
├── app/
│ ├── components/ Shared UI: Navbar, FormField, SubmitButton, ToastProvider,
│ │ RouteErrorState, ClientOnly (defers client-only children,
│ │ for example Leaflet maps, past hydration)
│ ├── features/ One folder per feature slice, each owning its own
│ │ routes/, components/, hooks/, schemas, and server-side
│ │ loader helpers:
│ │ - account profile/dashboard management
│ │ - auth login, register, confirm, logout
│ │ - breweries index/directory/detail routes, Leaflet
│ │ map components, nearby-search resource
│ │ route, distance/address utils
│ │ - catalog beers, beer styles
│ │ - home landing page
│ │ - theme theme switcher/guide
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
│ ├── routes.ts Route table (React Router 8 config-based routing)
│ ├── root.tsx App shell and global providers
│ └── app.css Theme tokens and global styling
├── .storybook/ Storybook config and preview setup
├── stories/ Storybook stories for shared UI and themes
├── tests/playwright/ Storybook Playwright coverage
└── package.json Frontend scripts and dependencies
```
### Frontend responsibilities
- Render brewery discovery routes (`/breweries` index, `/breweries/directory`,
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
search
- Render the auth demo, account dashboard, and theme guide routes
- Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, confirmation, and
brewery/location data
- Provide shared UI building blocks for forms, navigation, themes, and toasts
- Supply Storybook documentation and browser-based component verification
### Theme system
The website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
- Biergarten Lager
- Biergarten Stout
- Biergarten Cassis
- Biergarten Weizen
All component styling should prefer semantic tokens such as `primary`,
`success`, `surface`, and `highlight` instead of hard-coded color values.
### Archived frontend
`archive/next-js-web-app/` contains an archived Next.js frontend, kept for
reference. Product and engineering documentation points to `web/frontend`.
## Security architecture
### Authentication flow
| Flow | Details |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Registration** | User submits credentials; password is hashed with Argon2id; user account is created as unverified and a confirmation email is dispatched via `Features.Emails`. |
| **Email confirmation** | User follows the confirmation link/token from the email; `Features.Users`' `ConfirmUser` command marks the account as verified. |
| **Login** | User submits credentials; password is verified against the hash; access and refresh JWTs are issued and returned to the client; neither is stored server-side. |
| **Token refresh** | Client exchanges a valid, unexpired refresh token for a new access/refresh pair via `Features.Users`' `RefreshToken` command; refresh tokens are stateless JWTs, not tracked server-side, and the previous refresh token remains usable until its own expiration. |
| **API requests** | Client sends the access JWT in the `Authorization` header; `JwtAuthenticationHandler` validates the token; request proceeds if valid. |
### Password security
**Algorithm**: Argon2id
| Setting | Value |
| ----------- | ---------------------------------------------- |
| Memory | 64MB |
| Iterations | 4 |
| Parallelism | 2 (hardcoded, to avoid thread-pool exhaustion) |
| Salt | 128-bit (16 bytes) |
| Hash | 256-bit (32 bytes) |
### JWT tokens
**Algorithm**: HS256 (HMAC-SHA256)
**Claims**:
| Claim | Meaning |
| ------------- | -------------------- |
| `sub` | User ID |
| `unique_name` | Username |
| `jti` | Unique token ID |
| `iat` | Issued at timestamp |
| `exp` | Expiration timestamp |
**Configuration** (appsettings.json):
```json
{
"Jwt": {
"ExpirationMinutes": 60,
"Issuer": "biergarten-api",
"Audience": "biergarten-users"
}
}
```
This block exists in `appsettings.json`, but nothing in the code currently reads
it: actual token lifetimes come from the hardcoded `TokenServiceExpirationHours`
constants, and `JwtInfrastructure` hardcodes `ValidateIssuer`/`ValidateAudience`
to `false`, so `Issuer`/`Audience` are not enforced.
## Database architecture
For the table-by-table schema and the custom SQL error code scheme, see
[Database](database.md).
### SQL-first philosophy
**Principles**:
1. Database is source of truth
2. Queries live in the owning repository, not scattered across the app
3. Database handles referential integrity
4. Application orchestrates, database executes
**Benefits**:
- Performance optimization via execution plans
- Query logic scoped to the repository that owns it, not spread across layers
- Version-controlled schema (migrations)
- Easier query profiling and tuning
### Migration strategy
**Tool**: DbUp
**Process**:
1. Write a SQL script under `Database.Migrations/scripts/`
2. Embed it in the `Database.Migrations` project
3. `Database.Migrations` runs each script against the target database on
startup, tracked so a script never re-runs once applied
**Migration Files**:
```
scripts/
└── 01-schema/
├── 01-UserAccount.sql
├── 02-Photo.sql
├── ...
├── 16-UserProfile.sql
└── 17-UserAvatar.sql # one script per CREATE TABLE
```
Each table has its own script, numbered so a table's foreign keys always point
at an earlier-numbered script. As the schema evolves, new numbered scripts get
added rather than editing an existing one in place, so DbUp's already-applied
tracking stays valid.
### Data seeding
**Purpose**: Populate development/test databases with realistic data
**Implementation**: `Database.Seed` project, using data produced by the C++
pipeline under `tooling/pipeline/` (see
[Pipeline README](../pipeline/README.md))
**Seed Data**:
- Countries, state/provinces, and cities (via `Features.Locations`'
`ILocationRepository`, which creates `Country`/`StateProvince` rows on demand
while resolving a city)
- User accounts (via `Features.Users`' repository)
- Brewery posts with locations (via `Features.Breweries`' repository)
Tables that exist in the schema but have no repository yet ( `UserFollow`,
`BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, `BreweryPostPhoto`)
are not seeded either.
## Deployment architecture
### Docker containerization
**Container Structure**:
| Container | Purpose |
| --------------------- | ------------------------------------------------------------ |
| `sqlserver` | SQL Server 2022 |
| `database.migrations` | Schema migration runner |
| `database.seed` | Data seeder |
| `api.core` | ASP.NET Core Web API |
| `mailpit` | Local dev/test SMTP server + web UI (not used in production) |
| `seaweedfs` | S3-compatible object storage for uploaded photos (dev only) |
**Environments**:
| Environment | Compose file |
| ----------- | -------------------------- |
| Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` |
For details, see [Docker Guide](docker.md).
### Health checks
- **SQL Server**: Validates database connectivity
- **API**: Checks service health and dependencies
**Configuration**:
```yaml
healthcheck:
test: ["CMD-SHELL", "sqlcmd health check"]
interval: 10s
retries: 12
start_period: 30s
```
## Testing architecture
### Test pyramid
```mermaid
flowchart TB
IT["Integration Tests<br/>API.Specs (Reqnroll)"]
UT["Unit Tests (per slice, handler tests)<br/>Features.Users.Tests, Features.Breweries.Tests,<br/>Features.Emails.Tests, Features.PhotoUpload.Tests<br/>(commands/queries/handlers, with repository/service<br/>dependencies mocked via Moq)"]
IT --- UT
```
**Strategy**:
- Many unit tests (fast, isolated)
- Fewer integration tests (slower, e2e)
- Mock external dependencies
- Test database for integration tests
For details, see [Testing Guide](testing.md).

125
docs/web/database.md Normal file
View File

@@ -0,0 +1,125 @@
---
title: SQL Server database schema, migrations, and error handling
last-updated: 2026-08-31
tags:
- sql
- repository
- database
- sql-server
- schema
---
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
convention. For the vertical-slice code that talks to this schema, see
[Architecture](architecture.md).
## Schema
The full schema lives under
`web/backend/Database/Database.Migrations/scripts/01-schema/` with one numbered
script per table.
```
01-schema
├── 01-UserAccount.sql
├── 02-Photo.sql
├── 03-UserVerification.sql
├── 04-UserCredential.sql
├── 05-UserFollow.sql
├── 06-Country.sql
├── 07-StateProvince.sql
├── 08-City.sql
├── 09-BreweryPost.sql
├── 10-BreweryPostLocation.sql
├── 11-BreweryPostPhoto.sql
├── 12-BeerStyle.sql
├── 13-BeerPost.sql
├── 14-BeerPostPhoto.sql
├── 15-BeerPostComment.sql
├── 16-UserProfile.sql
└── 17-UserAvatar.sql
```
Each script is run in order by `Database.Migrations` (DbUp) on startup. Each
table is queried directly by the owning feature slice's repository (see
[Direct SQL via Dapper/ADO.NET](architecture.md#direct-sql-via-dapperadonet)).
A generated DBML copy of the schema (for import into a diagramming tool such as
dbdiagram.io) lives at `web/backend/Database/Database.Migrations/schema.dbml`.
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
### Tables backed by the API today
| Table | Backed by | Notes |
| --------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
### Tables defined in the schema but not yet wired to a feature slice
These exist so the schema can support planned functionality, but have no
repository or seed data yet:
| Table | Purpose (from schema) |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet |
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
## Managing Migrations (DbUp)
Migrations are handled by the `Database.Migrations` container using DbUp.
Scripts are placed in the `scripts/` directory, applied in order, and tracked so
that each one runs only once.
**Note:** The schema is currently considered volatile. At this stage of
development, fresh database instances are provisioned for both development and
testing, so no production data exists yet.
### How to Make Schema Changes
- **Now (no production data):** Update the schema by editing the original DDL
statements directly.
- **Later (with production data):** Add a new, numbered script for any schema
changes. Never edit an existing script in place — DbUp tracks completion by
script name, so modifying an already-applied script means the changes will be
ignored on existing databases.
## Data seeding
`Database.Seed` populates a target database from data produced by the C++
pipeline (see [Pipeline README](../pipeline/README.md)): countries,
state/provinces, and cities (created on demand while resolving each brewery's
location), user accounts, and brewery posts with locations. It doesn't call
every feature slice that has a write path: `Photo`, for example, has a
repository (`Features.PhotoUpload`) but no seed data.
## Database error handling
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
rows against the `RowVersion` column.
- On failure, the repository throws a `Domain.Exceptions` type directly
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
409, 400, ...); an unmapped `SqlException` (for example, a genuine
connectivity or constraint-violation failure) maps to 503.
- One exception: `Features.Users`' `DapperUserStore` does catch `SqlException`
directly, to detect a duplicate-key race (SQL Server error 2601 or 2627)
when two requests try to verify the same account concurrently; see
`DapperUserStore.IsDuplicateKeyViolation`.

View File

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

View File

@@ -0,0 +1,79 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant ConfirmHandler as ConfirmUserHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box Infrastructure Layer
participant JWT as JWT Infrastructure
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: POST /api/auth/confirm?token={token} (requires Bearer access token)
activate API
API->>Mediator: Send(ConfirmUserCommand(token))
activate Mediator
Mediator->>ConfirmHandler: Handle(command)
activate ConfirmHandler
ConfirmHandler->>TokenSvc: ValidateConfirmationTokenAsync(token)
activate TokenSvc
TokenSvc->>JWT: Validate against confirmation token secret, extract userId
activate JWT
JWT-->>TokenSvc: ValidatedToken or throws
deactivate JWT
TokenSvc-->>ConfirmHandler: ValidatedToken
deactivate TokenSvc
alt Token invalid or expired
ConfirmHandler->>Mediator: throw UnauthorizedException
Mediator->>API: propagate
API->>User: 401 Unauthorized
else Token valid
ConfirmHandler->>UserMgr: FindByIdAsync(validatedToken.UserId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>ConfirmHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ConfirmHandler->>Mediator: throw UnauthorizedException "User account not found"
Mediator->>API: propagate
API->>User: 401 Unauthorized
else User found
ConfirmHandler->>UserStore: SetEmailConfirmedAsync(user, true)
Note right of UserStore: IUserEmailStore(ApplicationUser) is injected directly into the handler (not via UserManager), setting the confirmed flag in-memory here
ConfirmHandler->>UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr->>UserStore: UpdateAsync(user)
activate UserStore
UserStore->>DB: UPDATE UserAccount SET ...
UserStore->>DB: INSERT INTO UserVerification (UserAccountId, VerificationDateTime)
Note right of UserStore: Idempotent — skipped (duplicate-key exception swallowed) if a verification row already exists
UserStore-->>UserMgr: IdentityResult.Success
deactivate UserStore
UserMgr-->>ConfirmHandler: IdentityResult
deactivate UserMgr
ConfirmHandler-->>Mediator: ConfirmationPayload(user.Id, DateTime.UtcNow)
deactivate ConfirmHandler
Mediator-->>API: ConfirmationPayload
deactivate Mediator
API->>User: 200 OK {message: "User with ID {id} is confirmed.", payload: {...}}
end
end
deactivate API

View File

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

View File

@@ -0,0 +1,65 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant DeleteAccountHandler
participant UserMgr as UserManager(ApplicationUser)
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: DELETE /api/auth/account (requires Bearer access token)
activate API
API->>Mediator: Send(DeleteAccountCommand(GetAuthenticatedUserId()))
activate Mediator
Mediator->>DeleteAccountHandler: Handle(command)
activate DeleteAccountHandler
DeleteAccountHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>DeleteAccountHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
DeleteAccountHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
DeleteAccountHandler->>UserMgr: DeleteAsync(user)
activate UserMgr
UserMgr->>UserStore: DeleteAsync(user)
activate UserStore
UserStore->>DB: DELETE FROM UserAccount WHERE UserAccountID = @Id
Note right of UserStore: UserCredential, UserVerification, and UserProfile cascade on delete<br/>(UserAvatar cascades transitively through UserProfile).<br/>Posts, comments, photos, and follows use ON DELETE NO ACTION<br/>if any reference this account, the DELETE throws a foreign key violation (SQL error 547)
alt Foreign key violation (dependent records exist)
UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords)
else Delete succeeds
UserStore-->>UserMgr: IdentityResult.Success
end
deactivate UserStore
UserMgr-->>DeleteAccountHandler: IdentityResult
deactivate UserMgr
alt Account has dependent records
DeleteAccountHandler->>Mediator: throw ConflictException<br>"Account cannot be deleted while it still has associated posts, comments, photos, or follows."
Mediator->>API: propagate
API->>User: 409 Conflict
else !result.Succeeded (other failure)
DeleteAccountHandler->>Mediator: throw ConflictException
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
DeleteAccountHandler-->>Mediator: (void)
deactivate DeleteAccountHandler
Mediator-->>API: (void)
deactivate Mediator
API->>User: 200 OK {message: "Account deleted successfully."}
end
end
deactivate API

View File

@@ -0,0 +1,11 @@
# User Authentication Flow — Error Handling
Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`.
Applies uniformly across every authentication endpoint (registration, login,
refresh, confirm, resend, update-username/email/password/profile,
delete-account) — see the per-flow diagrams for where each exception type is
thrown.
```mermaid
--8<-- "web/diagrams/auth/error-handling.mmd"
```

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant LoginHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box Infrastructure Layer
participant Argon2 as Argon2PasswordHasher
participant JWT as JWT Infrastructure
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: POST /api/auth/login {username, password}
activate API
API->>Mediator: Send(LoginCommand)
activate Mediator
Note right of Mediator: ValidationBehavior runs LoginValidator — username and password must both be non-empty
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request {message, errors}
else Validation succeeds
Mediator->>LoginHandler: Handle(command)
activate LoginHandler
LoginHandler->>UserMgr: FindByNameAsync(username)
activate UserMgr
UserMgr->>UserStore: FindByNameAsync(username)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
activate DB
DB-->>UserStore: UserAccount row or null
deactivate DB
UserStore->>UserStore: Hydrate (active UserCredential.Hash, UserVerification → EmailConfirmed)
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>LoginHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
LoginHandler->>Mediator: throw UnauthorizedException "Invalid username or password."
Mediator->>API: propagate
API->>User: 401 Unauthorized
else User found
LoginHandler->>UserMgr: CheckPasswordAsync(user, password)
activate UserMgr
Note right of UserMgr: HasPasswordAsync short-circuits to false (no active credential hydrated) without calling the hasher — otherwise delegates to Argon2PasswordHasher
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, password)
activate Argon2
Note right of Argon2: Parse stored PHC string ($argon2id$v=19$m=...,t=...,p=...$salt$hash), rehash password with the embedded salt/parameters, constant-time compare (CryptographicOperations.FixedTimeEquals)
Argon2-->>UserMgr: Success/Failed
deactivate Argon2
UserMgr-->>LoginHandler: true/false
deactivate UserMgr
alt Password invalid
LoginHandler->>Mediator: throw UnauthorizedException
Mediator->>API: propagate
API->>User: 401 Unauthorized
else Password valid
LoginHandler->>TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(...)
activate JWT
JWT-->>TokenSvc: Access Token
deactivate JWT
TokenSvc-->>LoginHandler: Access Token
deactivate TokenSvc
LoginHandler->>TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(...)
activate JWT
JWT-->>TokenSvc: Refresh Token
deactivate JWT
TokenSvc-->>LoginHandler: Refresh Token
deactivate TokenSvc
LoginHandler-->>Mediator: LoginPayload(userAccountId, username, refreshToken, accessToken)
deactivate LoginHandler
Mediator-->>API: LoginPayload
deactivate Mediator
API->>User: 200 OK {message: "Logged in successfully.", payload: {...}}
end
end
end
deactivate API

View File

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

View File

@@ -0,0 +1,77 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant RefreshHandler as RefreshTokenHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box Infrastructure Layer
participant JWT as JWT Infrastructure
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: POST /api/auth/refresh<br/>Header: X-Refresh-Token: {token}
activate API
API->>Mediator: Send(RefreshTokenCommand(refreshToken))
activate Mediator
Note right of Mediator: ValidationBehavior runs RefreshTokenValidator — refreshToken must be non-empty. Anonymous endpoint, no Bearer token required.
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request {message, errors}
else Validation succeeds
Mediator->>RefreshHandler: Handle(command)
activate RefreshHandler
RefreshHandler->>TokenSvc: RefreshTokenAsync(refreshToken)
activate TokenSvc
TokenSvc->>JWT: ValidateRefreshTokenAsync(refreshToken)
activate JWT
Note right of JWT: Validates signature/expiry against the refresh token secret, extracts userId/username claims
JWT-->>TokenSvc: ValidatedToken or throws
deactivate JWT
alt Token invalid or expired
TokenSvc->>RefreshHandler: throw UnauthorizedException
RefreshHandler->>Mediator: propagate
Mediator->>API: propagate
API->>User: 401 Unauthorized
else Token valid
TokenSvc->>UserMgr: FindByIdAsync(validated.UserId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>TokenSvc: ApplicationUser or null
deactivate UserMgr
alt User not found
TokenSvc->>RefreshHandler: throw UnauthorizedException "User account not found"
RefreshHandler->>Mediator: propagate
Mediator->>API: propagate
API->>User: 401 Unauthorized
else User found
TokenSvc->>JWT: GenerateAccessToken(user) + GenerateRefreshToken(user)
JWT-->>TokenSvc: new access + refresh tokens
TokenSvc-->>RefreshHandler: RefreshTokenResult
deactivate TokenSvc
RefreshHandler-->>Mediator: LoginPayload(userId, username, refreshToken, accessToken)
deactivate RefreshHandler
Mediator-->>API: LoginPayload
deactivate Mediator
API->>User: 200 OK {message: "Token refreshed successfully.", payload: {...}}
end
end
end
deactivate API

View File

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

View File

@@ -0,0 +1,165 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant RegHandler as RegisterUserHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box Infrastructure Layer
participant Argon2 as Argon2PasswordHasher
participant JWT as JWT Infrastructure
participant SMTP as Email Provider
participant Template as Template Provider
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
participant ProfileHandler as CreateUserProfileHandler<br/>(Profile slice)
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
end
participant DB as SQL Server
User->>API: POST /api/auth/register {username, firstName, lastName, email, dateOfBirth, password}
activate API
API->>Mediator: Send(RegisterUserCommand)
activate Mediator
Note right of Mediator: ValidationBehavior runs RegisterUserValidator — username 3-64 chars alphanumeric+[._-], valid email ≤128 chars, password min 8 chars with upper/lower/number/special, DateOfBirth 19+ years old
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request {message, errors}
else Validation succeeds
Mediator->>RegHandler: Handle(command)
activate RegHandler
RegHandler->>RegHandler: user = new UserAccount{...}.ToApplicationUser()
RegHandler->>UserMgr: CreateAsync(user, password)
activate UserMgr
UserMgr->>Argon2: HashPassword(user, password)
activate Argon2
Note right of Argon2: Argon2id — salt 16 bytes, memory 64MB, iterations 4, parallelism 2 (fixed, to avoid thread-pool exhaustion), hash output 32 bytes
Argon2-->>UserMgr: PHC string in Argon2id format (algorithm, version, memory/time/parallelism params, salt, hash)
deactivate Argon2
UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
Note right of UserStore: user.Id is still empty here — only sets the in-memory hash — CreateAsync writes it to UserCredential itself
UserMgr->>UserStore: FindByNameAsync(username)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
activate DB
DB-->>UserStore: null (user doesn't exist)
deactivate DB
deactivate UserStore
UserMgr->>UserStore: FindByEmailAsync(email)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE Email = @Email
activate DB
DB-->>UserStore: null (email doesn't exist)
deactivate DB
deactivate UserStore
Note right of UserMgr: Default UserManager validators check uniqueness before CreateAsync — DapperUserStore.CreateAsync also guards a genuine race via a caught duplicate-key SqlException (2601/2627)
UserMgr->>UserStore: CreateAsync(user)
activate UserStore
UserStore->>DB: BEGIN TRANSACTION
activate DB
UserStore->>DB: INSERT INTO UserAccount (...) OUTPUT INSERTED.UserAccountID
DB-->>UserStore: UserAccountId (GUID)
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
UserStore->>DB: COMMIT TRANSACTION
deactivate DB
Note right of UserStore: Both statements run via Dapper against a single DbTransaction
UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
deactivate UserStore
UserMgr-->>RegHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
RegHandler->>Mediator: throw ConflictException (DuplicateUserName/DuplicateEmail → "Username or email already exists", otherwise joined error descriptions)
Mediator->>API: propagate
API->>User: 409 Conflict "Username or email already exists"
else result.Succeeded
RegHandler->>Mediator: Send(CreateUserProfileCommand(user.Id, ""))
activate Mediator
Mediator->>ProfileHandler: Handle(command)
activate ProfileHandler
ProfileHandler->>DB: INSERT INTO UserProfile (UserAccountId, Biography)
activate DB
DB-->>ProfileHandler: UserProfileId
deactivate DB
ProfileHandler-->>Mediator: UserProfileId
deactivate ProfileHandler
Mediator-->>RegHandler: UserProfileId
deactivate Mediator
RegHandler->>TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
activate JWT
Note right of JWT: HS256, expires 1 hour, claims sub/unique_name/iat/jti
JWT->>JWT: Create JWT with claims, sign with secret key
JWT-->>TokenSvc: Access Token
deactivate JWT
TokenSvc-->>RegHandler: Access Token
deactivate TokenSvc
RegHandler->>TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
activate JWT
Note right of JWT: Expires 21 days (504h), same structure as access token, signed with a separate secret
JWT-->>TokenSvc: Refresh Token
deactivate JWT
TokenSvc-->>RegHandler: Refresh Token
deactivate TokenSvc
RegHandler->>TokenSvc: GenerateConfirmationToken(user)
activate TokenSvc
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
activate JWT
Note right of JWT: Expires 30 minutes, signed with a third, separate secret
JWT-->>TokenSvc: Confirmation Token
deactivate JWT
TokenSvc-->>RegHandler: Confirmation Token
deactivate TokenSvc
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
activate Mediator
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the second of two cross-slice dispatches in this flow (profile creation is the first)
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher
EmailDispatcher->>Template: RenderUserRegisteredEmailAsync(firstName, confirmationLink)
activate Template
Note right of Template: Razor component — header/branding, welcome message, confirmation button, footer
Template->>Template: Render Razor component to HTML
Template-->>EmailDispatcher: HTML email content
deactivate Template
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
activate SMTP
Note right of SMTP: MailKit — host/port from SMTP_HOST/SMTP_PORT, StartTLS when SMTP_USE_SSL=true, optional username/password auth
SMTP->>SMTP: Build MIME message, connect, authenticate, send, disconnect
SMTP-->>EmailDispatcher: Success / throws
deactivate SMTP
deactivate EmailDispatcher
deactivate Mediator
alt Email sent successfully
RegHandler->>RegHandler: emailSent = true
else mediator.Send throws
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false
end
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
deactivate RegHandler
Mediator-->>API: RegistrationPayload
deactivate Mediator
API->>User: 201 Created {message: "User registered successfully.", payload: {...}}
end
end
deactivate API

View File

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

View File

@@ -0,0 +1,71 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant ResendHandler as ResendConfirmationEmailHandler
participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService
end
box Infrastructure Layer
participant SMTP as Email Provider
participant Template as Template Provider
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
end
participant DB as SQL Server
User->>API: POST /api/auth/confirm/resend?userId={id} (requires Bearer access token)
activate API
API->>Mediator: Send(ResendConfirmationEmailCommand(userId))
activate Mediator
Mediator->>ResendHandler: Handle(command)
activate ResendHandler
ResendHandler->>UserMgr: FindByIdAsync(userId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>ResendHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ResendHandler->>ResendHandler: return silently (prevents user enumeration)
else User found
ResendHandler->>UserMgr: IsEmailConfirmedAsync(user)
UserMgr-->>ResendHandler: bool
alt Already confirmed
ResendHandler->>ResendHandler: return silently (no-op)
else Not yet confirmed
ResendHandler->>TokenSvc: GenerateConfirmationToken(user)
activate TokenSvc
TokenSvc-->>ResendHandler: confirmation token
deactivate TokenSvc
ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand)
activate Mediator
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher
EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher
deactivate Mediator
end
end
ResendHandler-->>Mediator: (void)
deactivate ResendHandler
Mediator-->>API: (void)
deactivate Mediator
API->>User: 200 OK {message: "confirmation email has been resent"}
Note right of API: Always 200 OK regardless of outcome above — same user-enumeration precaution as a not-found user or an already-confirmed account
deactivate API

View File

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

View File

@@ -0,0 +1,64 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant UpdateEmailHandler
participant UserMgr as UserManager(ApplicationUser)
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: PATCH /api/auth/email {newEmail} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdateEmailCommand(GetAuthenticatedUserId(), newEmail))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdateEmailValidator (same format rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdateEmailHandler: Handle(command)
activate UpdateEmailHandler
UpdateEmailHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore->>DB: SELECT ...
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>UpdateEmailHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateEmailHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdateEmailHandler->>UserMgr: SetEmailAsync(user, newEmail)
activate UserMgr
UserMgr->>UserStore: FindByEmailAsync(newEmail)
Note right of UserMgr: Uniqueness check. Also resets user.EmailConfirmed = false — caller must re-confirm via POST /api/auth/confirm/resend
UserMgr->>UserStore: UpdateAsync(user)
UserStore->>DB: UPDATE UserAccount SET Email = @Email, ...
UserStore->>DB: DELETE FROM UserVerification WHERE UserAccountID = @Id
UserStore-->>UserMgr: IdentityResult
UserMgr-->>UpdateEmailHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateEmailHandler->>Mediator: throw ConflictException (DuplicateEmail → "Email address already exists")
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdateEmailHandler-->>Mediator: UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed = false)
deactivate UpdateEmailHandler
Mediator-->>API: UpdateEmailPayload
deactivate Mediator
API->>User: 200 OK {message: "Email updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

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

View File

@@ -0,0 +1,84 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant UpdatePasswordHandler
participant UserMgr as UserManager(ApplicationUser)
end
box Infrastructure Layer
participant Argon2 as Argon2PasswordHasher
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: PATCH /api/auth/password {currentPassword, newPassword} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdatePasswordCommand(GetAuthenticatedUserId(), currentPassword, newPassword))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdatePasswordValidator (same password strength rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdatePasswordHandler: Handle(command)
activate UpdatePasswordHandler
UpdatePasswordHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>UpdatePasswordHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdatePasswordHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdatePasswordHandler->>UserMgr: ChangePasswordAsync(user, currentPassword, newPassword)
activate UserMgr
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, currentPassword)
activate Argon2
Argon2-->>UserMgr: Success/Failed
deactivate Argon2
alt Current password mismatch
UserMgr-->>UpdatePasswordHandler: IdentityResult.Failed (PasswordMismatch)
else Current password matches
UserMgr->>Argon2: HashPassword(user, newPassword)
activate Argon2
Argon2-->>UserMgr: new hash
deactivate Argon2
UserMgr->>UserStore: SetPasswordHashAsync(user, newHash)
activate UserStore
UserStore->>DB: UPDATE UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @Id AND IsRevoked = 0
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
Note right of UserStore: Revoke-then-insert runs inside one DbTransaction (credential rotation)
UserStore-->>UserMgr: (void)
deactivate UserStore
UserMgr-->>UpdatePasswordHandler: IdentityResult.Success
end
deactivate UserMgr
alt Current password is incorrect
UpdatePasswordHandler->>Mediator: throw UnauthorizedException "Current password is incorrect"
Mediator->>API: propagate
API->>User: 401 Unauthorized
else !result.Succeeded (other failure)
UpdatePasswordHandler->>Mediator: throw ConflictException
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdatePasswordHandler-->>Mediator: UpdatePasswordPayload(user.Id, DateTime.UtcNow)
deactivate UpdatePasswordHandler
Mediator-->>API: UpdatePasswordPayload
deactivate Mediator
API->>User: 200 OK {message: "Password updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

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

View File

@@ -0,0 +1,63 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant UpdateProfileHandler
participant UserMgr as UserManager(ApplicationUser)
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: PATCH /api/auth/profile {firstName, lastName, dateOfBirth} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdateProfileCommand(GetAuthenticatedUserId(), firstName, lastName, dateOfBirth))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdateProfileValidator (same name/DateOfBirth rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdateProfileHandler: Handle(command)
activate UpdateProfileHandler
UpdateProfileHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
UserStore-->>UserMgr: ApplicationUser or null
UserMgr-->>UpdateProfileHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateProfileHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdateProfileHandler->>UpdateProfileHandler: user.FirstName = ...<br/>user.LastName = ...<br/>user.DateOfBirth = ...
Note right of UpdateProfileHandler: First/last name and date of birth aren't tracked by any Identity store interface — mutated directly on the ApplicationUser, then saved via UserManager.UpdateAsync
UpdateProfileHandler->>UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr->>UserStore: UpdateAsync(user)
UserStore->>DB: UPDATE UserAccount SET FirstName = @FirstName, LastName = @LastName, DateOfBirth = @DateOfBirth, ...
UserStore-->>UserMgr: IdentityResult
UserMgr-->>UpdateProfileHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateProfileHandler->>Mediator: throw ConflictException
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdateProfileHandler-->>Mediator: UpdateProfilePayload(user.Id, firstName, lastName, dateOfBirth)
deactivate UpdateProfileHandler
Mediator-->>API: UpdateProfilePayload
deactivate Mediator
API->>User: 200 OK {message: "Profile updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

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

View File

@@ -0,0 +1,68 @@
sequenceDiagram
actor User
participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer
participant UpdateUsernameHandler
participant UserMgr as UserManager(ApplicationUser)
end
box Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end
participant DB as SQL Server
User->>API: PATCH /api/auth/username {newUsername} (requires Bearer access token)
activate API
API->>Mediator: Send(UpdateUsernameCommand(GetAuthenticatedUserId(), newUsername))
activate Mediator
Note right of Mediator: ValidationBehavior runs UpdateUsernameValidator (same format rules as registration)
alt Validation fails
Mediator->>API: throws FluentValidation.ValidationException
API->>User: 400 Bad Request
else Validation succeeds
Mediator->>UpdateUsernameHandler: Handle(command)
activate UpdateUsernameHandler
UpdateUsernameHandler->>UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr->>UserStore: FindByIdAsync(userAccountId)
activate UserStore
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
activate DB
DB-->>UserStore: row or null
deactivate DB
UserStore-->>UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr-->>UpdateUsernameHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateUsernameHandler->>Mediator: throw NotFoundException
Mediator->>API: propagate
API->>User: 404 Not Found
else Account found
UpdateUsernameHandler->>UserMgr: SetUserNameAsync(user, newUsername)
activate UserMgr
UserMgr->>UserStore: FindByNameAsync(newUsername)
Note right of UserMgr: Uniqueness check before the UPDATE
UserMgr->>UserStore: UpdateAsync(user)
UserStore->>DB: UPDATE UserAccount SET Username = @UserName, ...
UserStore-->>UserMgr: IdentityResult
UserMgr-->>UpdateUsernameHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateUsernameHandler->>Mediator: throw ConflictException (DuplicateUserName → "Username already exists")
Mediator->>API: propagate
API->>User: 409 Conflict
else result.Succeeded
UpdateUsernameHandler-->>Mediator: UpdateUsernamePayload(user.Id, user.UserName)
deactivate UpdateUsernameHandler
Mediator-->>API: UpdateUsernamePayload
deactivate Mediator
API->>User: 200 OK {message: "Username updated successfully.", payload: {...}}
end
end
end
deactivate API

View File

@@ -0,0 +1,40 @@
# Database Schema
Five diagrams, one per schema category — mirrors the `identity` / `media` /
`location` / `brewery` / `beer` schema split. Every table also has a
`RowVersion ROWVERSION` column (optimistic concurrency), omitted below for
brevity. Tables marked `(*)` have no repository/feature slice yet.
Boxes with no attribute list (e.g. `Photo` in the identity diagram) are owned by
a different schema and shown only to keep the relationship visible; see the
diagram named after that schema for its full definition.
## Identity (`identity`)
```mermaid
--8<-- "web/diagrams/database-schema/identity.mmd"
```
## Media (`media`)
```mermaid
--8<-- "web/diagrams/database-schema/media.mmd"
```
## Location (`location`)
```mermaid
--8<-- "web/diagrams/database-schema/location.mmd"
```
## Brewery (`brewery`)
```mermaid
--8<-- "web/diagrams/database-schema/brewery.mmd"
```
## Beer (`beer`)
```mermaid
--8<-- "web/diagrams/database-schema/beer.mmd"
```

View File

@@ -0,0 +1,38 @@
erDiagram
BeerStyle ||--o{ Beer : "categorizes"
Beer ||--o{ BeerPhoto : "has photos"
Beer ||--o{ BeerComment : "has comments"
%% Cross-schema: defined in full in identity.mmd / brewery.mmd / media.mmd
User ||--o{ Beer : "posts"
Brewery ||--o{ Beer : "brews"
User ||--o{ BeerComment : "comments"
BeerStyle {
uniqueidentifier BeerStyleID PK
nvarchar StyleName UK
nvarchar Description "nullable"
}
Beer {
uniqueidentifier BeerPostID PK
nvarchar Name
nvarchar Description
decimal ABV
int IBU
uniqueidentifier PostedByID FK
uniqueidentifier BeerStyleID FK
uniqueidentifier BrewedByID FK
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BeerComment {
uniqueidentifier BeerPostCommentID PK
nvarchar Comment
uniqueidentifier BeerPostID FK
uniqueidentifier CommentedByID FK
int Rating
datetime CreatedAt
datetime UpdatedAt "nullable"
}

View File

@@ -0,0 +1,16 @@
erDiagram
Brewery ||--o| BreweryLoc : "has location"
Brewery ||--o{ BreweryPhoto : "has photos"
Brewery ||--o{ Beer : "brews"
%% Cross-schema: defined in full in identity.mmd / location.mmd / media.mmd / beer.mmd
User ||--o{ Brewery : "posts"
Brewery {
uniqueidentifier BreweryPostID PK
nvarchar BreweryName
uniqueidentifier PostedByID FK
nvarchar Description
datetime CreatedAt
datetime UpdatedAt "nullable"
}

View File

@@ -0,0 +1,53 @@
erDiagram
User ||--o{ Cred : "has credentials"
User ||--o| Verify : "has verification"
User ||--o| Profile : "has profile"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
%% Cross-schema: defined in full in media.mmd / brewery.mmd / beer.mmd
Profile ||--o{ Avatar : "has avatar history"
User ||--o{ Photo : "uploads"
User ||--o{ Brewery : "posts"
User ||--o{ Beer : "posts"
User ||--o{ BeerComment : "comments"
User {
uniqueidentifier UserAccountID PK
varchar Username UK
nvarchar FirstName
nvarchar LastName
varchar Email UK
datetime CreatedAt
datetime UpdatedAt "nullable"
date DateOfBirth
}
Cred {
uniqueidentifier UserCredentialID PK
uniqueidentifier UserAccountID FK
datetime CreatedAt
datetime Expiry
nvarchar Hash
bit IsRevoked
datetime RevokedAt "nullable"
}
Verify {
uniqueidentifier UserVerificationID PK
uniqueidentifier UserAccountID "FK, UK"
datetime VerificationDateTime
}
Profile {
uniqueidentifier UserProfileID PK
uniqueidentifier UserAccountID "FK, UK"
nvarchar Biography
}
Follow {
uniqueidentifier UserFollowID PK
uniqueidentifier UserAccountID FK
uniqueidentifier FollowingID FK
datetime CreatedAt
}

View File

@@ -0,0 +1,36 @@
erDiagram
Country ||--o{ StateProvince : "has"
StateProvince ||--o{ City : "has"
City ||--o{ BreweryLoc : "located in"
%% Cross-schema: defined in full in brewery.mmd
Brewery ||--o| BreweryLoc : "has location"
Country {
uniqueidentifier CountryID PK
nvarchar CountryName
char ISO3166_1 UK
}
StateProvince {
uniqueidentifier StateProvinceID PK
nvarchar StateProvinceName
char ISO3166_2 UK
uniqueidentifier CountryID FK
}
City {
uniqueidentifier CityID PK
nvarchar CityName
uniqueidentifier StateProvinceID FK
}
BreweryLoc {
uniqueidentifier BreweryPostLocationID PK
uniqueidentifier BreweryPostID "FK, UK"
nvarchar AddressLine1
nvarchar AddressLine2 "nullable"
nvarchar PostalCode
uniqueidentifier CityID FK
geography Coordinates "nullable"
}

View File

@@ -0,0 +1,39 @@
erDiagram
Avatar }o--|| Photo : "uses photo"
Photo ||--o{ BreweryPhoto : "used in"
Photo ||--o{ BeerPhoto : "used in"
%% Cross-schema: defined in full in identity.mmd / brewery.mmd / beer.mmd
Profile ||--o{ Avatar : "has avatar history"
User ||--o{ Photo : "uploads"
Brewery ||--o{ BreweryPhoto : "has photos"
Beer ||--o{ BeerPhoto : "has photos"
Photo {
uniqueidentifier PhotoID PK
nvarchar Hyperlink "nullable"
uniqueidentifier UploadedByID FK
datetime UploadedAt
}
Avatar {
uniqueidentifier UserAvatarID PK
uniqueidentifier UserProfileID FK
uniqueidentifier PhotoID FK
datetime2 ValidFrom
datetime2 ValidTo "nullable; at most one row per UserProfileID has ValidTo IS NULL (active avatar)"
}
BreweryPhoto {
uniqueidentifier BreweryPostPhotoID PK
uniqueidentifier BreweryPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}
BeerPhoto {
uniqueidentifier BeerPostPhotoID PK
uniqueidentifier BeerPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}

View File

@@ -0,0 +1,48 @@
# Docker Deployment Architecture
Five diagrams, one per Compose file — mixing all five networks in one graph made
it impossible to tell which containers actually talk to each other. Solid arrows
are real container traffic; dashed arrows are `depends_on` startup gating with
no data flowing over them.
## Development (`docker-compose.dev.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/dev.mmd"
```
## Test (`docker-compose.test.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/test.mmd"
```
## Production (`docker-compose.prod.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/prod.mmd"
```
## Database-only (`docker-compose.db.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/db-only.mmd"
```
## Minimal (`docker-compose.min.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/minimal.mmd"
```
## Notes
| Environment | Details |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
| `.trx` results | Readable by Visual Studio, Azure DevOps, and GitHub Actions. |

View File

@@ -0,0 +1,13 @@
sequenceDiagram
box Database-only Environment (docker-compose.db.yaml)
participant DbOnlyDB as SQL Server<br/>:1434
participant DbOnlySeaweedFS as seaweedfs<br/>S3-compatible object storage
participant DbOnlyMig as Migrations (run-once)
participant DbOnlySeed as Seed (run-once)
end
DbOnlyMig-->>DbOnlyDB: depends_on
DbOnlyMig->>DbOnlyDB: Run migrations
DbOnlySeed-->>DbOnlyMig: depends_on
DbOnlySeed-->>DbOnlySeaweedFS: depends_on
DbOnlySeed->>DbOnlyDB: Seed data

View File

@@ -0,0 +1,20 @@
sequenceDiagram
actor Developer
participant Host as Docker Host
box Development Environment (docker-compose.dev.yaml)
participant DevDB as SQL Server 2022<br/>Biergarten Database<br/>:1434
participant DevMig as Migrations (run-once)<br/>Database.Migrations
participant DevSeed as Seed (run-once)<br/>Database.Seed
participant DevAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
participant DevMailpit as Mailpit<br/>:8025 Web UI / :1025 SMTP
end
Developer->>Host: docker compose up
DevMig-->>DevDB: depends_on (healthy)
DevMig->>DevDB: Run migrations
DevSeed-->>DevMig: depends_on
DevSeed->>DevDB: Seed data
DevAPI-->>DevSeed: depends_on
DevAPI->>DevDB: Connect and serve
DevAPI-->>DevMailpit: SMTP_HOST/PORT
Host->>DevAPI: http://localhost:8080

View File

@@ -0,0 +1,15 @@
sequenceDiagram
box Minimal Environment (docker-compose.min.yaml)
participant MinDB as SQL Server<br/>:1433
participant MinSeaweedFS as seaweedfs<br/>S3-compatible object storage
participant MinMig as Migrations (run-once)
participant MinSeed as Seed (run-once)
participant MinMailpit as Mailpit<br/>:8025 / :1025
end
MinMig-->>MinDB: depends_on
MinMig->>MinDB: Run migrations
MinSeed-->>MinMig: depends_on
MinSeed-->>MinSeaweedFS: depends_on
MinSeed->>MinDB: Seed data
Note over MinDB,MinMailpit: consumed by an externally run API.Core process (not shown)

View File

@@ -0,0 +1,11 @@
sequenceDiagram
box Production Environment (docker-compose.prod.yaml)
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
participant ProdMig as Migrations (run-once)
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
end
ProdMig-->>ProdDB: depends_on
ProdMig->>ProdDB: Run migrations
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
ProdAPI->>ProdDB: Connect and serve

View File

@@ -0,0 +1,29 @@
sequenceDiagram
box Test Environment (docker-compose.test.yaml)
participant TestDB as SQL Server, isolated instance<br/>Biergarten Test Database<br/>no host port published
participant TestSeaweedFS as seaweedfs<br/>S3-compatible object storage
participant TestMig as Migrations (test)
participant TestSeed as Seed (test)<br/>minimal: test.user only
participant Specs as API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)
participant UnitTests as unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container
participant FrontendTests as frontend.tests<br/>Playwright/Vitest
end
participant Results as test-results/ (mounted volume)
TestMig-->>TestDB: depends_on
TestMig->>TestDB: Migrate
TestSeed-->>TestMig: depends_on
TestSeed-->>TestSeaweedFS: depends_on
TestSeed->>TestDB: Seed
par Specs
Specs-->>TestSeed: depends_on
Specs->>TestDB: Integration test
Specs->>Results: Export TRX
and unit.tests
UnitTests-->>TestSeed: depends_on
UnitTests-->>TestDB: depends_on (startup ordering only, no real connection)
UnitTests->>Results: Export TRX, one per slice
and frontend.tests
FrontendTests->>Results: Export results
end

View File

@@ -0,0 +1,103 @@
# The Biergarten App — Vertical Slice Architecture
Two diagrams, split by concern: which host wires up which slice at startup, and
which projects each slice actually compiles against. Mixing DI wiring and
compile-time references in one graph is what made the old diagram unreadable.
## Host wiring
Each host (`API.Core`, `Database.Seed`) decides for itself which `Features.*`
assemblies to register — there's no shared bootstrap. `Database.Migrations`
doesn't wire up any slice; it applies schema directly.
```mermaid
--8<-- "web/diagrams/vertical-slice/host-wiring.mmd"
```
## Project reference dependencies
Solid arrows are compile-time project references. Dashed arrows are the two
places slices talk to each other without a direct reference: the email MediatR
contract routed through `Shared.Application`, and the one place that pattern is
broken outright. Every DB-backed slice depends on the same three foundation
projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
as one box instead of fifteen separate arrows; see the notes for what else each
of those foundation projects pulls in.
```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd"
```
## Notes
- **API.Core**: Program.cs wiring only
- MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly
- Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter
- Only `Features.Users` and `Features.Breweries` get `AddApplicationPart`
(controllers)
- `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers
only
- `Features.Locations` and `Features.Beers` not referenced by `API.Core` at
all
- **Feature slices**: each owns its own controller (where present),
commands/queries + handlers, validators, and Dapper repository
- `Features.Users` covers auth (`Commands/Authentication`), account management
(`Commands/Account`), and profile/avatar (`Commands/Profile`)
- internally namespaced `Features.Auth`, despite project name
`Features.Users`
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked
only via MediatR commands sent from other slices
- `Features.Locations` has no controller; one MediatR handler
(`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core`
never scans it
- `Features.Beers` is scaffolded (project + references only, no source files
yet)
- **The one exception to slice isolation**: `Features.Users` takes a direct
project reference to `Features.PhotoUpload`
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
- unlike the email flow, the command type lives in the target slice itself,
not in `Shared.Application`
- **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline)
- Cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
- MediatR command contract (not project reference)
- Every slice (including `Features.Emails`) references it for the pipeline
behavior alone
- **Domain.Entities**:
- `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`,
`UserAvatar`
- `BreweryPost`, `BreweryPostLocation`
- `City`, `StateProvince`, `Country`
- `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for
`BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no
code)
- **Database.Connection**: the shared connection-factory project
- each slice's own repository issues inline SQL via Dapper against it
- no ORM, no stored procedures
- Replaces the old `Infrastructure.Sql` project
- **Database.Migrations**: separate console app
- `schema.dbml` + SQL scripts
- applies DDL directly
- distinct from `Database.Seed`, which loads data through the feature slices'
own DI extensions
- **Database.Seed**:
- calls each slice's `AddFeatures*()` DI extension directly
- builds its own MediatR registration
- no API host, no shared pipeline with `API.Core`
- **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including
all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
- `Infrastructure.Configuration` (the shared options-binding project
referenced by `Database.Connection`, `Infrastructure.Email`,
`Infrastructure.FileUpload`, and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing
them would just add a second copy of the same fan-in

View File

@@ -0,0 +1,31 @@
---
config:
flowchart:
curve: stepAfter
layout: elk
---
flowchart TB
API["API.Core<br/>(ASP.NET Core Web API)"]
Seed["Database.Seed<br/>(console app)"]
Migrations["Database.Migrations<br/>(console app)"]
subgraph SLICES["Feature Slices"]
direction LR
UsersSlice["Features.Users<br/>(controller)"]
BrewerySlice["Features.Breweries<br/>(controller)"]
PhotoSlice["Features.PhotoUpload<br/>(MediatR only)"]
EmailsSlice["Features.Emails<br/>(MediatR only)"]
LocationsSlice["Features.Locations<br/>(plain DI service)"]
BeersSlice["Features.Beers<br/>(scaffolded, no code yet)"]
end
API -->|AddApplicationPart + MediatR scan| UsersSlice
API -->|AddApplicationPart + MediatR scan| BrewerySlice
API -->|MediatR scan only| PhotoSlice
API -->|MediatR scan only| EmailsSlice
Seed -->|AddFeaturesUsers| UsersSlice
Seed -->|AddFeaturesBreweries| BrewerySlice
Seed -->|AddFeaturesPhotoUpload| PhotoSlice
Seed -->|AddFeaturesLocations| LocationsSlice

View File

@@ -0,0 +1,43 @@
---
config:
flowchart: {}
layout: fixed
---
flowchart TB
subgraph SLICES["Feature Slices"]
direction LR
BrewerySlice["Features.Breweries"]
LocationsSlice["Features.Locations"]
BeersSlice["Features.Beers<br>(scaffolded)"]
PhotoSlice["Features.PhotoUpload"]
UsersSlice["Features.Users"]
EmailsSlice["Features.Emails"]
end
subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"]
direction LR
SharedContracts["Shared.Contracts"]
Domain["Domain.Entities"]
DBConn["Database.Connection"]
end
subgraph INFRA["Slice-specific Infrastructure"]
direction LR
JWT["Infrastructure.Jwt"]
PwdHash["Infrastructure.PasswordHashing"]
FileUpload["Infrastructure.FileUpload"]
Email["Infrastructure.Email"]
EmailTemplates["Infrastructure.Email.Templates"]
end
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
UsersSlice -. sends email commands .-> SharedApp["Shared.Application<br>(email command contract)"]
EmailsSlice -. handles email commands .-> SharedApp
BrewerySlice --> FOUNDATION
LocationsSlice --> FOUNDATION
BeersSlice --> FOUNDATION
PhotoSlice --> FOUNDATION & FileUpload
UsersSlice --> FOUNDATION & JWT & PwdHash
EmailsSlice --> Email & EmailTemplates
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]

406
docs/web/docker.md Normal file
View File

@@ -0,0 +1,406 @@
---
title: Docker deployment, configuration, and troubleshooting
last-updated: 2026-08-31
tags:
- docker
- docker-compose
- deployment
---
This document covers Docker deployment, configuration, and troubleshooting for
The Biergarten App.
## Overview
The project uses Docker Compose to orchestrate multiple services:
- SQL Server 2022 database
- Database migrations runner (DbUp)
- Database seeder
- .NET API
- React Router website (SSR frontend)
- Test runners
See the [deployment diagram](diagrams/deployment.md) for visual representation.
## Docker Compose environments
### 1. Development (`docker-compose.dev.yaml`)
**Purpose**: Local development with persistent data
**Features**:
- Persistent SQL Server volume
- NuGet package cache volume (speeds up rebuilds)
- Swagger UI enabled
- Seed data included
- Local Mailpit SMTP server for dev email testing
- Local SeaweedFS S3-compatible object storage for photo uploads
- `CLEAR_DATABASE=true` (drops and recreates schema)
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1434)
database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
frontend # React Router website (port 3000)
mailpit # Local dev SMTP server + UI (port 8025)
seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646)
```
**Start Development Environment**:
```bash
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
**Access**:
- Website: http://localhost:3000
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
- SQL Server: localhost:1434 (sa credentials from .env.dev)
- SeaweedFS S3 API: http://localhost:8333 (credentials from
`SEAWEEDFS_ACCESS_KEY_ID`/`SEAWEEDFS_SECRET_ACCESS_KEY` in .env.dev)
- SeaweedFS Filer UI: http://localhost:8888
- SeaweedFS Master UI: http://localhost:9333
**Stop Environment**:
```bash
# Stop services (keep volumes)
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
# Stop and remove volumes (fresh start)
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
```
### 2. Testing (`docker-compose.test.yaml`)
**Purpose**: Automated CI/CD testing in an isolated environment
**Features**:
- Fresh database each run
- All test suites execute in parallel
- Test results exported to `./test-results/`
- Containers auto-exit after completion
- Fully isolated testnet network
**Services**:
```yaml
sqlserver # Test database
database.migrations # Fresh schema
database.seed # Test data
api.specs # Reqnroll BDD tests
unit.tests # All Features.*.Tests unit test projects
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
```
**Run Tests**:
```bash
# Run all tests
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests
# View results
ls -la test-results/
cat test-results/api-specs/results.trx
cat test-results/Features.Users.Tests.trx
# Clean up
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
`wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
stopped, regardless of which one finishes first; `up --abort-on-container-exit`
isn't used here because the one-shot `database.migrations`/`database.seed` jobs
and `frontend.tests` (no database dependency) can exit before the other test
containers, which would end the run prematurely. See
[Testing](testing.md#running-tests-with-docker-recommended) for details.
### 3. Production (`docker-compose.prod.yaml`)
**Purpose**: Production-ready deployment
**Features**:
- Production logging levels
- No database clearing
- Optimized build configurations
- Health checks enabled
- Restart policies (unless-stopped)
- Security hardening
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
reachable only from other containers on `prodnet`
**Services**:
```yaml
sqlserver # Production SQL Server (internal only)
database.migrations # Schema updates only
api.core # Production API (internal only, no published ports)
frontend # Production website (port 3000, the only public binding)
```
**Deploy Production**:
```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
```
### 4. Database only (`docker-compose.db.yaml`)
**Purpose**: Run just the database for local backend development outside Docker
(for example, `dotnet run` against a containerized SQL Server)
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1434)
database.migrations # DbUp migrations
database.seed # Seed initial data
```
No `api.core` or `mailpit` container. Uses `.env.dev`, same as development.
**Start**:
```bash
docker compose --env-file web/.env.dev -f web/docker-compose.db.yaml up -d
```
### 5. Minimal (`docker-compose.min.yaml`)
**Purpose**: Local dev where the API runs outside Docker but the database and
mail capture still run in containers
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1433)
database.migrations # DbUp migrations
database.seed # Seed initial data
mailpit # Local dev SMTP server + UI (port 8025)
```
No `api.core` container. Uses `.env.local` instead of `.env.dev`, and exposes
SQL Server on the standard port 1433 (`docker-compose.db.yaml` and
`docker-compose.dev.yaml` both use 1434).
**Start**:
```bash
docker compose --env-file web/.env.local -f web/docker-compose.min.yaml up -d
```
## Service dependencies
Docker Compose manages startup order using health checks:
```mermaid
graph TD
A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
B --> C[database.seed: completes successfully]
C --> D[api.core / tests: start when ready]
E[seaweedfs: container started] --> D
```
In development, `api.core` also depends on `seaweedfs`
(`condition: service_started`, not a health check) alongside `database.seed`.
**Health Check Example** (SQL Server):
```yaml
healthcheck:
test:
[
"CMD-SHELL",
"sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'",
]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
```
**Dependency Configuration**:
```yaml
api.core:
depends_on:
database.seed:
condition: service_completed_successfully
```
## Volumes
### Persistent volumes
**Development**:
- `sqlserverdata-dev` - Database files persist between restarts
- `nuget-cache-dev` - NuGet package cache (speeds up builds)
- `seaweedfsdata-dev` - SeaweedFS object storage data persists between restarts
**Testing**:
- `sqlserverdata-test` - Temporary, typically removed after tests
**Production**:
- `sqlserverdata-prod` - Production database files
- `nuget-cache-prod` - Production NuGet cache
### Mounted volumes
**Test Results**:
```yaml
volumes:
- ./test-results:/app/test-results
```
Test results are written to host filesystem for CI/CD integration.
## Networks
Each environment uses isolated bridge networks:
- `devnet` - Development network
- `testnet` - Testing network (fully isolated)
- `prodnet` - Production network
## Environment variables
All containers are configured via environment variables from `.env` files:
```yaml
env_file: ".env.dev" # or .env.test, .env.prod
environment:
ASPNETCORE_ENVIRONMENT: "Development"
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
```
For complete list, see [Environment Variables](environment-variables.md).
## Common commands
### View services
```bash
# Running services
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
# All containers (including stopped)
docker ps -a
```
### View logs
```bash
# All services
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
# Specific service
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f api.core
# Last 100 lines
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
```
### Execute commands in container
```bash
# Interactive shell
docker exec -it dev-env-api-core bash
# Run command (replace <db-password> with the value of DB_PASSWORD from .env.dev)
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<db-password>' -C
```
### Restart services
```bash
# Restart all services
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart
# Restart specific service
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart api.core
# Rebuild and restart
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
```
### Build images
```bash
# Build all images
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build
# Build specific service
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.core
# Build without cache
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
```
### Clean up
```bash
# Stop and remove containers
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
# Remove containers and volumes
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
# Remove containers, volumes, and images
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --rmi all
# System-wide cleanup
docker system prune -af --volumes
```
## Dockerfile structure
### Multi-stage build
```dockerfile
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["Project/Project.csproj", "Project/"]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/build .
ENTRYPOINT ["dotnet", "Project.dll"]
```
## Additional resources
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
- [SQL Server Docker Images](https://hub.docker.com/_/microsoft-mssql-server)

View File

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

166
docs/web/getting-started.md Normal file
View File

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

472
docs/web/testing.md Normal file
View File

@@ -0,0 +1,472 @@
---
title: Testing strategy — unit, integration, and Storybook suites
last-updated: 2026-08-31
tags:
- testing
- ci
- xunit
- reqnroll
- storybook
---
This document describes the testing strategy and how to run tests for The
Biergarten App.
## Overview
The project uses a multi-layered testing approach across backend and frontend:
| Suite | Purpose |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **API.Specs** | BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database |
| **Features.\*.Tests** | One unit test project per backend feature slice (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that slice's command/query handlers, with dependencies mocked via Moq (no real database required) |
| **Storybook Vitest project** | Browser-based interaction tests for shared website stories |
| **Storybook Playwright suite** | Browser checks against Storybook-rendered components |
## Running tests with Docker (recommended)
To run all tests, use Docker Compose, which sets up an isolated test
environment:
```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests
```
This command:
1. Starts a fresh SQL Server instance
2. Runs database migrations
3. Seeds test data
4. Executes all test suites in parallel, including the frontend's Storybook
Vitest and Playwright suites (`frontend.tests`, which needs no database)
5. Exports results to `./test-results/`
6. Returns once all three test containers have exited
`wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
stopped, regardless of which one finishes first. Long-running services
(`sqlserver`, `seaweedfs`) and the one-shot `database.migrations` /
`database.seed` jobs keep running in the background alongside them and are torn
down separately (see below); `wait` doesn't touch them.
### View test results
```bash
# List test result files
ls -la test-results/
# View specific test results
cat test-results/api-specs/results.trx
cat test-results/Features.Users.Tests.trx
cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.Emails.Tests.trx
cat test-results/Features.PhotoUpload.Tests.trx
cat test-results/frontend/vitest-storybook.xml
cat test-results/frontend/playwright-storybook.xml
```
### Clean up
```bash
# Remove test containers and volumes
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
## Running tests locally
You can run individual test projects locally without Docker:
### Feature slice unit tests
Each feature slice has its own test project, covering its command/query
handlers:
```bash
cd web/backend
dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
# Or run all of them at once via the solution:
for proj in Features/*.Tests; do dotnet test "$proj"; done
```
**Requirements**:
- No database required (handlers are tested with their repository/service
dependencies mocked via Moq)
### Frontend Storybook tests
```bash
cd web/frontend
npm install
npm run test:storybook
```
### Integration tests (API.Specs)
```bash
cd web/backend
dotnet test API/API.Specs/API.Specs.csproj
```
**Requirements**:
- SQL Server instance running
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
**Purpose**:
- Verifies shared stories such as form fields, submit buttons, navbar states,
toasts, and the theme gallery
- Runs in browser mode via Vitest and Storybook integration
### Frontend Playwright Storybook tests
```bash
cd web/frontend
npm install
npm run test:storybook:playwright
```
**Requirements**:
- Storybook dependencies installed
- Playwright browser dependencies installed
- The command will start or reuse the Storybook server defined in
`playwright.storybook.config.ts`
### Running Storybook tests with a visible browser
Both of the above run headless by default. To watch the browser while debugging
a failure:
```bash
# Playwright Storybook suite
npx playwright test -c playwright.storybook.config.ts --headed
# or the interactive step-through runner
npx playwright test -c playwright.storybook.config.ts --ui
# Vitest/story play-function suite
npx vitest run --project storybook --browser.headless=false
# or Vitest's own UI (drop `run` for watch mode)
npx vitest --project storybook --browser.headless=false --ui
```
The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
## Test coverage
### Current coverage
**Features.Users.Tests**:
- User registration with validation
- User login with JWT token generation
- Password hashing and verification (Argon2id)
- Email confirmation and confirmation-email resend
- Refresh token exchange
- JWT token generation, validation, and claims handling
- Invalid credentials and 404 error responses (via API.Specs)
- Account management: username, email, password, and profile updates, and
account deletion (blocked while dependent posts, comments, photos, or follows
exist)
- User get-by-id/get-all queries
**Features.Breweries.Tests**:
- Brewery create/update/delete commands and get-by-id/get-all queries
- Brewery location queries: all locations, and locations within a given range of
a coordinate
**Features.Emails.Tests**:
- Registration and resend-confirmation email dispatch handlers
**Features.PhotoUpload.Tests**:
- Upload command handler: storage key construction, content type passthrough,
and photo persistence via the mocked storage provider and repository
- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature checks
(including rejection of files with a mismatched or missing signature)
**Frontend UI Coverage**:
- Shared submit button states
- Form field happy path and error presentation
- Navbar guest, authenticated, and mobile behavior
- Theme gallery rendering across Biergarten themes
- Toast interactions and themed notification display
### Planned coverage
- [ ] Password reset functionality
- [ ] Beer post operations
- [ ] User follow/unfollow
- [ ] Frontend route integration coverage beyond Storybook stories
## Testing frameworks and tools
### xUnit
- Primary unit testing framework
- Used for handler and service layer tests
- Supports parallel test execution
### Reqnroll (Gherkin/BDD)
- Behavior-driven development framework
- Used for API integration tests
- Human-readable test scenarios in `.feature` files
### FluentAssertions
- Expressive assertion library
- Makes test assertions more readable
- Used across all test projects
### Moq
- Mocking framework for .NET
- Used in Service layer tests
- Enables isolated unit testing
## Test structure
### API.Specs (integration tests)
```
API.Specs/
├── Features/
│ ├── Registration.feature # Registration scenarios
│ ├── Login.feature # Login scenarios
│ ├── Confirmation.feature # Email confirmation scenarios
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
│ ├── TokenRefresh.feature # Refresh token scenarios
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
│ └── NotFound.feature # 404 handling
├── Steps/
│ ├── AuthSteps.cs # Step definitions for the Auth features
│ └── ApiGeneralSteps.cs # Shared/general step definitions
├── Mocks/
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
```
**Example Feature**:
```gherkin
Feature: User Registration
As a user
I want to register
So that I can access the platform
Scenario: Successful user registration
Given I have valid registration details
When I register a new account
Then I should receive a JWT token
And my account should be created
```
### Features.Users.Tests
```
Features.Users.Tests/
├── Commands/
│ ├── RegisterUserHandlerTests.cs
│ ├── ConfirmUserHandlerTests.cs
│ ├── ResendConfirmationEmailHandlerTests.cs
│ ├── RefreshTokenHandlerTests.cs
│ ├── UpdateUsernameHandlerTests.cs
│ ├── UpdateEmailHandlerTests.cs
│ ├── UpdatePasswordHandlerTests.cs
│ ├── UpdateProfileHandlerTests.cs
│ └── DeleteAccountHandlerTests.cs
├── Queries/
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
│ ├── GetAllUsersHandlerTests.cs
│ └── GetUserByIdHandlerTests.cs
├── Services/
│ ├── TokenServiceRefreshTests.cs
│ └── TokenServiceValidationTests.cs
└── TestSupport/ # shared test fixtures/helpers
```
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a
validator test file alongside its handler test).
## Writing tests
### Unit test example (xUnit)
```csharp
public class LoginHandlerTests
{
[Fact]
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{
// Arrange
var authRepoMock = new Mock<IAuthRepository>();
var passwordInfraMock = new Mock<IPasswordInfrastructure>();
var tokenServiceMock = new Mock<ITokenService>();
var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
// ...set up mocks for a known user/credential...
// Act
var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.AccessToken.Should().NotBeNullOrEmpty();
}
}
```
### Integration test example (Reqnroll)
```gherkin
Scenario: User login with valid credentials
Given a registered user with username "testuser"
When I POST to "/api/auth/login" with valid credentials
Then the response status should be 200
And the response should contain a JWT token
```
## Continuous integration
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push and
pull request to `main`. It runs the same test Docker Compose configuration used
locally, so backend and frontend tests execute in the same containerized
environment in CI as they do on a developer machine:
```bash
# CI/CD command
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
The workflow inspects the exit code of each test container
(`test-env-api-specs`, `test-env-unit-tests`, `test-env-frontend-tests`) after
`wait` returns and fails the job if any of them is non-zero. `./test-results/`
is uploaded as a build artifact regardless of outcome.
`up -d` + `wait` keeps container teardown independent of any single container's
exit. The one-shot `database.migrations`/`database.seed` jobs exit 0 partway
through the run, and `frontend.tests` (which has no database dependency)
reliably finishes before `api.specs`/`unit.tests` start; because `wait` blocks
on all three test containers, neither early exit ends the run prematurely.
Exit codes:
- `0` - All tests passed
- Non-zero - Test failures occurred
The frontend's Storybook Vitest and Playwright suites run inside the
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that same
compose run, so no separate frontend CI step is needed. To run them locally
without Docker:
```bash
cd web/frontend
npm ci
npm run test:storybook
npm run test:storybook:playwright
```
### Running the workflow locally with `act`
[`act`](https://github.com/nektos/act) replays `.github/workflows/*.yml` on your
own machine using Docker, so you can reproduce a CI run (or a fix for one)
without pushing a branch.
**Install** (macOS):
```bash
brew install act
```
**Run the workflow** from the repo root:
```bash
act push \
-j containerized-tests \
-W .github/workflows/tests.yml \
-P ubuntu-latest=catthehacker/ubuntu:act-latest \
--container-architecture linux/amd64
```
- `-j containerized-tests` runs only that job (there's currently just the one).
- `-P ubuntu-latest=catthehacker/ubuntu:act-latest` pins the runner image act
uses to impersonate `ubuntu-latest`. Without it, act's first run prompts
interactively to choose a default image size, which hangs/fails under a
non-interactive shell.
- `--container-architecture linux/amd64` is required on Apple Silicon: the
`sqlserver` service's image is amd64-only, and act needs to know to emulate
that platform for the whole job container, not just that one service.
- No secrets or `.actrc` are needed: `generate-env.sh` creates its own
`.env.test` with freshly randomized values, same as in real CI.
**Known act-only limitation**: the "Upload test results" step
(`actions/upload-artifact@v4`) fails locally with
`Unable to get the ACTIONS_RUNTIME_TOKEN env variable`. act doesn't provide a
real Actions artifact backend, so this step, and only this step, is expected to
fail under act even when everything else passes. It works normally on
GitHub-hosted runners.
**Stale local state**: `docker-compose.test.yaml` uses fixed container names and
named volumes (`sqlserverdata-test`, `seaweedfsdata-test`) scoped to the `web`
compose project. If a previous local run (via `act` or a manual `docker compose`
invocation) didn't get torn down, its SQL Server volume can persist with an old
`SA_PASSWORD` baked in, while a fresh `.env.test` generates a new one each run,
causing `Login failed for user 'sa'` errors that look like a test bug but are
really a leftover volume. Clear it before re-running:
```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
## Troubleshooting
### Tests failing due to database connection
Ensure SQL Server is running and environment variables are set:
```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
```
### Port conflicts
If port 1433 is in use, stop other SQL Server instances or modify the port in
`docker-compose.test.yaml`.
### Stale test data
Clean up test database:
```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
### View container logs
```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
```
## Best practices
1. **Isolation**: Each test should be independent and not rely on other tests
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
3. **Mocking**: Mock external dependencies in unit tests
4. **Descriptive Names**: Use clear, descriptive test method names
5. **Arrange-Act-Assert**: Follow AAA pattern in unit tests
6. **Given-When-Then**: Follow GWT pattern in BDD scenarios

View File

@@ -0,0 +1,293 @@
---
title: JWT token validation architecture
last-updated: 2026-08-31
tags:
- jwt
- authentication
- security
- tokens
---
## Overview
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
| Token Type | Lifetime | Purpose |
| ----------------------- | ---------- | --------------------------- |
| **Access Tokens** | 1 hour | API authentication |
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
| **Confirmation Tokens** | 30 minutes | Email confirmation |
## Components
### Infrastructure layer
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
Low-level JWT operations.
**Methods:**
- `GenerateJwt()` - Creates signed JWT tokens
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:**
[JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness
### Features.Users slice
#### [ITokenService](../../web/backend/Features/Features.Users/Services/ITokenService.cs)
Token generation and validation live on the same slice-internal service.
**Generation methods:**
- `GenerateAccessToken(Guid userId, string username)` - Creates 1-hour access
token
- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day refresh
token
- `GenerateConfirmationToken(Guid userId, string username)` - Creates 30-minute
confirmation token
**Validation methods:**
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
There is no `ValidateAccessTokenAsync` on `ITokenService`. Access-token
validation doesn't go through `ITokenService` at all: `JwtAuthenticationHandler`
(the host-level auth middleware) calls `ITokenInfrastructure.ValidateJwtAsync()`
directly, bypassing the `Features.Users` slice entirely.
**Returns (validation):** `ValidatedToken` record containing:
- `UserId` (Guid)
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:**
[TokenService.cs](../../web/backend/Features/Features.Users/Services/TokenService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()`
extension method, except for the lower-level `ITokenInfrastructure` (JWT
signing/verification) it depends on, which is registered by the host
(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
middleware) also depends on it directly.
### Integration points
#### [ConfirmUserHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserHandler.cs)
**Flow:**
1. Receives confirmation token from user via `ConfirmUserCommand`
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token and looks it up via
`UserManager<ApplicationUser>`
4. Marks the account confirmed via
`IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`, then persists it
via `UserManager<ApplicationUser>.UpdateAsync()`
5. Returns confirmation result
`UserManager<ApplicationUser>` and `IUserEmailStore<ApplicationUser>` are
ASP.NET Core Identity's abstractions, not EF Core's default Identity store: the
app backs them with its own `DapperUserStore` (see
[Database](database.md#database-error-handling)), which persists against the
existing `UserAccount`/`UserCredential`/`UserVerification` tables via Dapper,
consistent with the rest of the app's repository pattern.
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
**Flow:**
1. Receives a user ID via `ResendConfirmationEmailCommand`
2. Looks up the user and checks they aren't already verified
3. Generates a fresh confirmation token via `ITokenService`
4. Sends `SendResendConfirmationEmailCommand` over MediatR, handled by the
`Features.Emails` slice, with no direct project reference between the two
slices
#### [RefreshTokenHandler](../../web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenHandler.cs)
**Flow:**
1. Receives a refresh token via `RefreshTokenCommand`
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
retrieves the user account, and issues a new access/refresh token pair
3. Maps the result onto `LoginPayload`
#### [AuthController](../../web/backend/Features/Features.Users/Controllers/AuthController.cs)
**Endpoints:**
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Authenticate user
- `POST /api/auth/confirm?token=...` - Confirm email
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
- `POST /api/auth/refresh` - Refresh access token
Account management (require a valid access token; user ID comes from the token,
never the request body):
- `PATCH /api/auth/username` - Change the authenticated user's username
- `PATCH /api/auth/email` - Change the authenticated user's email address;
resets `EmailConfirmed` to false, so the user must re-confirm via
`POST /api/auth/confirm/resend`
- `PATCH /api/auth/password` - Change the authenticated user's password;
requires the current password
- `PATCH /api/auth/profile` - Update the authenticated user's profile (first
name, last name, date of birth)
- `DELETE /api/auth/account` - Permanently delete the authenticated user's
account; blocked while the account still has associated posts, comments,
photos, or follows
## Validation security
### Token secrets
Three independent secrets enable:
- **Key rotation** - Rotate each secret type independently
- **Isolation** - Compromise of one secret doesn't affect others
- **Different expiration** - Different token types can expire at different rates
**Environment Variables:**
```bash
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
```
### Validation checks
Each token is validated for:
1. **Signature Verification** - Token must be signed with correct secret
2. **Expiration** - Token must not be expired (checked against current time)
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
4. **Claims Format** - UserId claim must be a valid GUID
### Error handling
Validation failures return HTTP 401 Unauthorized. Regardless of the specific
cause (invalid signature, expired token, missing header, malformed claims),
`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same generic
message: `"Unauthorized: Invalid or missing authentication token"` — it doesn't
distinguish failure reasons in the response.
## Token lifecycle
### Access token lifecycle
| Step | Description |
| ---------- | ------------------------------------------------ |
| Generation | During login (1-hour validity) |
| Usage | Included in Authorization header on API requests |
| Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token |
### Refresh token lifecycle
| Step | Description |
| ---------- | ------------------------------------------------ |
| Generation | During login (21-day validity) |
| Storage | Client-side (secure storage) |
| Usage | Posted to `/api/auth/refresh` endpoint |
| Validation | Validated by `ITokenService.RefreshTokenAsync()` |
| Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days |
### Confirmation token lifecycle
| Step | Description |
| ---------- | ----------------------------------------------------- |
| Generation | During user registration (30-minute validity) |
| Delivery | Emailed to user in confirmation link |
| Usage | User clicks link, token posted to `/api/auth/confirm` |
| Validation | Validated by `ConfirmUserHandler` |
| Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes |
## Testing
All of the following live in `Features.Users.Tests`.
### Unit tests
**Services/TokenServiceValidationTests.cs**
- Happy path: Valid token extraction
- Error cases: Invalid, expired, malformed tokens
- Missing/invalid claims scenarios
**Services/TokenServiceRefreshTests.cs**
- Successful refresh with valid token
- Invalid/expired refresh token rejection
- Non-existent user handling
**Commands/RefreshTokenHandlerTests.cs**
- Verifies the handler maps `ITokenService.RefreshTokenAsync()`'s result onto
`LoginPayload`
**Commands/ConfirmUserHandlerTests.cs**
- Successful confirmation with valid token
- Token validation failures
- User not found scenarios
**Commands/ResendConfirmationEmailHandlerTests.cs**
- Sends a fresh confirmation email when the user exists and is unverified
- No-ops when the user doesn't exist or is already verified
### BDD tests (Reqnroll)
**TokenRefresh.feature**
- Successful token refresh
- Invalid/expired token rejection
- Missing token validation
**Confirmation.feature**
- Successful email confirmation
- Expired/tampered token rejection
- Missing token validation
**AccessTokenValidation.feature**
- Protected endpoint access token validation
- Invalid/expired access token rejection
- Token type mismatch (refresh used as access token)
## Future enhancements
### Stretch goals
1. **Middleware for Access Token Validation** - Automatically validate access
tokens on protected routes - Populate HttpContext.User from token claims -
Return 401 for invalid/missing tokens
2. **Token Denylisting** - Implement token revocation (for example, on logout) -
Store denylisted tokens in cache/database - Check the denylist during
validation
3. **Refresh Token Rotation Strategy** - Detect token reuse (replay attacks) -
Automatically invalidate entire token chain on reuse - Log suspicious
activity
4. **Structured Logging** - Log token validation attempts - Track failed
validation reasons - Alert on repeated validation failures (brute force
detection)