Update outdated documentation (#255)

This commit is contained in:
Aaron Po
2026-08-29 23:50:05 -04:00
committed by GitHub
parent be672b6ca9
commit e5b354e322
7 changed files with 130 additions and 39 deletions

View File

@@ -50,6 +50,7 @@ Active areas in the repository:
- React 19 website (React Router 7 + Vite)
- Shared Biergarten theme system + Storybook coverage
- Auth flows and account/email integration (local Mailpit in dev compose)
- Photo upload to S3-compatible object storage (local SeaweedFS in dev compose)
- Data generation pipeline with C++ and Llama.cpp
Archived/reference areas:
@@ -98,6 +99,7 @@ Backend access:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI (dev SMTP): http://localhost:8025
- SeaweedFS S3 API (dev object storage): http://localhost:8333
### Frontend (Node)

View File

@@ -35,10 +35,11 @@ For visual representations, see:
The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
project that owns its own MediatR commands/queries/handlers, validators, and
repository end to end. `Features.Emails` and `Features.Locations` have no
`Controllers/` folder — both are invoked internally by other slices via
`Features.UserManagement`, `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:
```
@@ -48,15 +49,15 @@ MediatR/DI, never over HTTP:
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
└───────────────────────────────────────────────────────────────────────┘
↓ discovers controllers via AddApplicationPart
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │
│ │Breweries │ UserManagement │ (no controller, │ Locations │
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
│ Handlers │ Handlers │ Handlers │ │ │
│ Repository │ Repository │ Repository │ EmailDispatcher │ │
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┬──────────────────┐
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ Features. │
│ │Breweries │ UserManagement │ (no controller, │ Locations │ PhotoUpload │
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │ (no controller, │
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │
│ Handlers │ Handlers │ Handlers │ │ │ Handlers │
│ Repository │ Repository │ Repository │ EmailDispatcher │ │ Repository │
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┴──────────────────┘
↓ each slice depends only on shared/domain/infra, never on another slice
┌─────────────────────────┬─────────────────────────┬────────────────────┐
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
@@ -109,7 +110,7 @@ own
- No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`)
**Purpose**: Each slice is the complete vertical for one business capability
@@ -132,7 +133,12 @@ no `Controllers/` folder and no MediatR handlers — it exposes
`ILocationRepository` directly as a plain service, currently consumed only by
`Database.Seed` to look up cities while seeding brewery locations. Other slices
(for example, `Features.Breweries`) do not yet depend on it; they run their own
`CityID` existence checks inline.
`CityID` existence checks inline. `Features.PhotoUpload` also has no
`Controllers/` folder — its `UploadPhotoCommand` is sent by other features'
handlers (for example, a future brewery photo upload command) rather than
bound to an HTTP route directly. The one exception today is the `/upload-test`
minimal API endpoint wired directly in `API.Core/Program.cs`, a temporary route
for exercising the upload path end to end.
**Dependencies**:
@@ -140,7 +146,8 @@ no `Controllers/` folder and no MediatR handlers — it exposes
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails)
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application`
- **Never** another `Features.*` project
@@ -184,11 +191,16 @@ whichever slices need them
- **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, and so on)
- External libraries (ADO.NET, JWT, Argon2, MailKit, AWSSDK.S3, and so on)
**Rules**:
@@ -212,6 +224,8 @@ whichever slices need them
- `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**:
@@ -266,8 +280,14 @@ only:
- `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.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**:
@@ -524,9 +544,11 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
- User accounts (via `Features.Auth`'s repository)
- Brewery posts with locations (via `Features.Breweries`' repository)
Tables that exist in the schema but have no repository yet (`Photo`,
`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`,
`BeerPostComment`, `BreweryPostPhoto`) are not seeded.
`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`,
but `Database.Seed` doesn't call it, so photos aren't part of the seed data.
Tables that exist in the schema but have no repository yet (`UserAvatar`,
`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
`BreweryPostPhoto`) are not seeded either.
## Deployment architecture
@@ -539,6 +561,7 @@ Tables that exist in the schema but have no repository yet (`Photo`,
- `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**:
@@ -573,9 +596,9 @@ healthcheck:
│ Tests │
├──────────────┤
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
│ handler │ (commands/queries/handlers, with repository/service
│ tests) │ dependencies mocked via Moq)
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests,
│ handler │ Features.PhotoUpload.Tests (commands/queries/handlers,
│ tests) │ with repository/service dependencies mocked via Moq)
└──────────────┘
```

View File

@@ -33,6 +33,7 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| `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
@@ -41,7 +42,6 @@ repository or seed data yet:
| Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| `Photo` | A photo uploaded by a user account |
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
@@ -66,8 +66,9 @@ an already-applied script means it won't re-run against existing databases.
`Database.Seed` populates a target database from data produced by the C++
pipeline (see [Pipeline README](../pipeline/README.md)): countries,
state/provinces, and cities (created on demand while resolving each brewery's
location), user accounts, and brewery posts with locations. It only seeds
tables that have a feature slice to write through (see the table above).
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

View File

@@ -29,6 +29,7 @@ representation.
- 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**:
@@ -39,6 +40,7 @@ database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
mailpit # Local dev SMTP server + UI (port 8025)
seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646)
```
**Start Development Environment**:
@@ -53,6 +55,10 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
- 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**:
@@ -182,8 +188,12 @@ 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
@@ -216,6 +226,8 @@ api.core:
- `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**:
@@ -262,6 +274,10 @@ environment:
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).

View File

@@ -35,8 +35,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`,
`SMTP_PASSWORD`) with freshly randomized ones via `openssl`. Non-secret values
are left untouched. The output file is written with `600` permissions.
`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
@@ -221,6 +222,25 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
- **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
@@ -288,6 +308,10 @@ reference only and is not run as part of the active stack.
| `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 |
@@ -309,8 +333,10 @@ reference only and is not run as part of the active stack.
Variables are validated at startup:
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
values) cause the application to fail with an `InvalidOperationException`
`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
@@ -347,6 +373,12 @@ 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

View File

@@ -58,14 +58,16 @@ section for details on `generate-env.sh`.
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev
SMTP).
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:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
- SeaweedFS S3 API: http://localhost:8333
### 4. Start the active frontend
@@ -124,6 +126,10 @@ 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

View File

@@ -11,9 +11,9 @@ The project uses a multi-layered testing approach across backend and frontend:
live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
slice's command/query handlers, with dependencies mocked via Moq (no real
database required)
`Features.UserManagement.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
@@ -49,6 +49,7 @@ cat test-results/Features.Auth.Tests.trx
cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx
cat test-results/Features.PhotoUpload.Tests.trx
```
### Clean up
@@ -86,6 +87,7 @@ dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
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
@@ -175,6 +177,14 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- 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
@@ -188,7 +198,6 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- [ ] Password reset functionality
- [ ] Beer post operations
- [ ] User follow/unfollow
- [ ] Image upload service
- [ ] Frontend route integration coverage beyond Storybook stories
## Testing frameworks and tools
@@ -276,9 +285,11 @@ Features.Auth.Tests/
└── TokenServiceValidationTests.cs
```
Each of the other three slices (`Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
shape: a `Commands/`/`Queries/` folder with one test file per handler.
Each of the other slices (`Features.Breweries.Tests`,
`Features.UserManagement.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