Replace stored-procedure repositories with Dapper-based repositories

Removes the USP_* stored procedures for Auth, Location, and Brewery
data access in favour of services built with Dapper.

Adds a new Features.Locations slice and optimistic concurrency support
for the Brewery slice.
This commit is contained in:
Aaron Po
2026-08-17 20:50:44 -04:00
parent e8499181cb
commit cac6d7f193
81 changed files with 1656 additions and 1401 deletions

View File

@@ -13,8 +13,8 @@ active website:
- **Architecture Style**: Vertical-slice backend plus server-rendered React
frontend
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
for reference only.
The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for
reference only.
## Diagrams
@@ -35,9 +35,9 @@ 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`) is a single project that owns
its own controller, MediatR commands/queries/handlers, validators, and
repository, end to end:
`Features.UserManagement`, `Features.Emails`) is a single project that owns its
own controller, MediatR commands/queries/handlers, validators, and repository,
end to end:
```
┌───────────────────────────────────────────────────────────────────────┐
@@ -74,18 +74,18 @@ repository, end to end:
└─────────────────────────────────────┘
```
Slices never reference each other's project. The one cross-slice
interaction (`Features.Auth` triggering a confirmation email handled by
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
whose contract lives in `Shared.Application`, so neither slice takes a
project reference on the other.
Slices never reference each other's project. The one cross-slice interaction
(`Features.Auth` triggering a confirmation email handled by `Features.Emails`)
goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
lives in `Shared.Application`, so neither slice takes a project reference on the
other.
### Layer responsibilities
#### API layer (`API.Core`)
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
its own
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
own
**Components**:
@@ -114,26 +114,25 @@ its own
**Components** (per slice):
- `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 stored-procedure 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
- `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 stored-procedure 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
`Features.Emails` has no `Controllers/` folder. It's invoked only via
MediatR commands sent from other slices, never over HTTP.
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
commands sent from other slices, never over HTTP.
**Dependencies**:
- `Domain.Entities`, `Domain.Exceptions`
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
infrastructure project the slice needs (`Infrastructure.Jwt`/
- `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)
- `Shared.Contracts`, `Shared.Application`
@@ -142,29 +141,27 @@ MediatR commands sent from other slices, never over HTTP.
**Rules**:
- All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through
MediatR
- 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
**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**:
- `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`)
- `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," not a general-purpose dumping ground
- Kept deliberately small: this is the exception to "no slice depends on another
slice," not a general-purpose dumping ground
#### Infrastructure layer
@@ -218,16 +215,15 @@ whichever slices need them
#### 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
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 bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- Controllers bind directly to the Command/Query as the request body; there is
no separate request DTO + mapping step for writes
- A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes
@@ -250,14 +246,14 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
**Purpose**: Abstract database access behind interfaces
**Implementation**: each slice owns its own repository, scoped to that
feature only:
**Implementation**: each slice owns its own repository, scoped to that feature
only:
- `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
connection factory every slice's repository builds on
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection
factory every slice's repository builds on
**Benefits**:
@@ -279,9 +275,9 @@ public interface IAuthRepository
**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
**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**:
@@ -351,8 +347,8 @@ All component styling should prefer semantic tokens such as `primary`,
### Legacy frontend
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
for reference only. Active product and engineering documentation should point
to `web/frontend`.
for reference only. Active product and engineering documentation should point to
`web/frontend`.
## Security architecture
@@ -411,6 +407,10 @@ to `web/frontend`.
## Database architecture
For the table-by-table schema, the stored procedure reference (parameters,
behavior, and exactly what each one throws), and the custom SQL error code
scheme, see [Database](website/database.md).
### SQL-first philosophy
**Principles**:

View File

@@ -136,8 +136,8 @@ consumers are informed of the AI-generated origin before engagement.
When forced by the system prompt to generate a "highly specialized technical
brewing detail," the model frequently hallucinates fermentation science and
brewing chemistry. While the resulting text sounds confident, it is
nonsensical to a reader with brewing or scientific expertise.
brewing chemistry. While the resulting text sounds confident, it is nonsensical
to a reader with brewing or scientific expertise.
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
calling for specialist brewing detail. This is consistent with the **CHOKE**
@@ -262,8 +262,8 @@ idiomatic phrasing.
]
```
When fed into the pipeline, this dataset often causes the model to reason that
a local variant of French is needed, but it often defaults to a standardized
When fed into the pipeline, this dataset often causes the model to reason that a
local variant of French is needed, but it often defaults to a standardized
dialect of French, without cultural or linguistic nuance.
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
@@ -312,10 +312,10 @@ not a source of truth.
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
[^llm-bias]:
For example, Blasi et al. (2022), "Systematic Inequalities in Language Technology
Performance across the World's Languages," _ACL Anthology_. The pattern is
consistent with models trained predominantly on English-language web
corpora.
For example, Blasi et al. (2022), "Systematic Inequalities in Language
Technology Performance across the World's Languages," _ACL Anthology_. The
pattern is consistent with models trained predominantly on English-language
web corpora.
[^wp-license]:
Source:

View File

@@ -101,21 +101,21 @@ Each run writes a fresh dated SQLite file such as
#### CLI flags
| Flag | Purpose |
| ------------------ | ---------------------------------------------------------------------------------------------------- |
| `--mocked` | Deterministic mock generator, no model required. |
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
| Flag | Purpose |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `--mocked` | Deterministic mock generator, no model required. |
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
| `--temperature` | Sampling temperature. Default: `1.0`. |
| `--top-p` | Nucleus sampling. Default: `0.95`. |
| `--top-k` | Top-k sampling. Default: `64`. |
| `--n-ctx` | Context window size. Default: `8192`. |
| `--seed` | Random seed. Default: `-1` (random at runtime). |
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
| `--help, -h` | Print usage and exit. |
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
| `--temperature` | Sampling temperature. Default: `1.0`. |
| `--top-p` | Nucleus sampling. Default: `0.95`. |
| `--top-k` | Top-k sampling. Default: `64`. |
| `--n-ctx` | Context window size. Default: `8192`. |
| `--seed` | Random seed. Default: `-1` (random at runtime). |
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
| `--help, -h` | Print usage and exit. |
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an
error before the pipeline starts. Sampling flags are ignored when `--mocked` is

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -3,102 +3,207 @@
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam linetype ortho
skinparam packageStyle rectangle
title Key Database Schema - User & Authentication
title Database Schema
caption Every table also has a Timer ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no CRUD stored procedures yet.
entity "UserAccount" as User {
* UserAccountId: INT <<PK>>
--
* Username: NVARCHAR(30) <<UNIQUE>>
* Email: NVARCHAR(255) <<UNIQUE>>
* FirstName: NVARCHAR(50)
* LastName: NVARCHAR(50)
Bio: NVARCHAR(500)
CreatedAt: DATETIME2
UpdatedAt: DATETIME2
LastLoginAt: DATETIME2
package "User & Auth" {
entity "UserAccount" as User {
* UserAccountID: UNIQUEIDENTIFIER <<PK>>
--
* Username: VARCHAR(64) <<UNIQUE>>
* FirstName: NVARCHAR(128)
* LastName: NVARCHAR(128)
* Email: VARCHAR(128) <<UNIQUE>>
* CreatedAt: DATETIME
UpdatedAt: DATETIME
* DateOfBirth: DATE
}
entity "UserCredential" as Cred {
* UserCredentialID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
* Expiry: DATETIME
* Hash: NVARCHAR(256)
* IsRevoked: BIT
RevokedAt: DATETIME
}
entity "UserVerification" as Verify {
* UserVerificationID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* VerificationDateTime: DATETIME
}
entity "UserAvatar (*)" as Avatar {
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
}
entity "UserFollow (*)" as Follow {
* UserFollowID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
* FollowingID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
}
entity "Photo (*)" as Photo {
* PhotoID: UNIQUEIDENTIFIER <<PK>>
--
Hyperlink: NVARCHAR(256)
* UploadedByID: UNIQUEIDENTIFIER <<FK>>
* UploadedAt: DATETIME
}
}
entity "UserCredential" as Cred {
* UserCredentialId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
* PasswordHash: VARBINARY(32)
* PasswordSalt: VARBINARY(16)
CredentialRotatedAt: DATETIME2
CredentialExpiresAt: DATETIME2
CredentialRevokedAt: DATETIME2
* IsActive: BIT
CreatedAt: DATETIME2
package "Location" {
entity "Country" as Country {
* CountryID: UNIQUEIDENTIFIER <<PK>>
--
* CountryName: NVARCHAR(100)
* ISO3166_1: CHAR(2) <<UNIQUE>>
}
entity "StateProvince" as StateProvince {
* StateProvinceID: UNIQUEIDENTIFIER <<PK>>
--
* StateProvinceName: NVARCHAR(100)
* ISO3166_2: CHAR(6) <<UNIQUE>>
* CountryID: UNIQUEIDENTIFIER <<FK>>
}
entity "City" as City {
* CityID: UNIQUEIDENTIFIER <<PK>>
--
* CityName: NVARCHAR(100)
* StateProvinceID: UNIQUEIDENTIFIER <<FK>>
}
}
entity "UserVerification" as Verify {
* UserVerificationId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
* IsVerified: BIT
VerifiedAt: DATETIME2
VerificationToken: NVARCHAR(255)
TokenExpiresAt: DATETIME2
package "Brewery" {
entity "BreweryPost" as Brewery {
* BreweryPostID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryName: NVARCHAR(256)
* PostedByID: UNIQUEIDENTIFIER <<FK>>
* Description: NVARCHAR(512)
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
entity "BreweryPostLocation" as BreweryLoc {
* BreweryPostLocationID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryPostID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* AddressLine1: NVARCHAR(256)
AddressLine2: NVARCHAR(256)
* PostalCode: NVARCHAR(20)
* CityID: UNIQUEIDENTIFIER <<FK>>
Coordinates: GEOGRAPHY
}
entity "BreweryPostPhoto (*)" as BreweryPhoto {
* BreweryPostPhotoID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryPostID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* LinkedAt: DATETIME
}
}
entity "UserAvatar" as Avatar {
* UserAvatarId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
PhotoId: INT <<FK>>
* IsActive: BIT
CreatedAt: DATETIME2
package "Beer (*)" {
entity "BeerStyle (*)" as BeerStyle {
* BeerStyleID: UNIQUEIDENTIFIER <<PK>>
--
* StyleName: NVARCHAR(100) <<UNIQUE>>
Description: NVARCHAR(MAX)
}
entity "BeerPost (*)" as Beer {
* BeerPostID: UNIQUEIDENTIFIER <<PK>>
--
* Name: NVARCHAR(100)
* Description: NVARCHAR(MAX)
* ABV: DECIMAL(4,2)
* IBU: INT
* PostedByID: UNIQUEIDENTIFIER <<FK>>
* BeerStyleID: UNIQUEIDENTIFIER <<FK>>
* BrewedByID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
entity "BeerPostPhoto (*)" as BeerPhoto {
* BeerPostPhotoID: UNIQUEIDENTIFIER <<PK>>
--
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* LinkedAt: DATETIME
}
entity "BeerPostComment (*)" as BeerComment {
* BeerPostCommentID: UNIQUEIDENTIFIER <<PK>>
--
* Comment: NVARCHAR(250)
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
* CommentedByID: UNIQUEIDENTIFIER <<FK>>
* Rating: INT
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
}
entity "UserFollow" as Follow {
* UserFollowId: INT <<PK>>
--
* FollowerUserId: INT <<FK>>
* FollowedUserId: INT <<FK>>
CreatedAt: DATETIME2
}
entity "Photo" as Photo {
* PhotoId: INT <<PK>>
--
* Url: NVARCHAR(500)
* CloudinaryPublicId: NVARCHAR(255)
Width: INT
Height: INT
Format: NVARCHAR(10)
CreatedAt: DATETIME2
}
' Relationships
' Relationships: User & Auth
User ||--o{ Cred : "has"
User ||--o| Verify : "has"
User ||--o{ Avatar : "has"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
User ||--o| Avatar : "has"
Avatar }o--|| Photo : "refers to"
User ||--o{ Follow : "follows (UserAccountID)"
User ||--o{ Follow : "followed by (FollowingID)"
User ||--o{ Photo : "uploaded"
note right of Cred
Password hashing:
- Algorithm: Argon2id
- Memory: 64MB
- Iterations: 4
- Salt: 128-bit
- Hash: 256-bit
end note
' Relationships: Location
Country ||--o{ StateProvince : "has"
StateProvince ||--o{ City : "has"
note right of Verify
Account verification
via email token
with expiry
end note
' Relationships: Brewery
User ||--o{ Brewery : "posted"
Brewery ||--o| BreweryLoc : "has"
City ||--o{ BreweryLoc : "located in"
Brewery ||--o{ BreweryPhoto : "has"
Photo ||--o{ BreweryPhoto : "linked to"
' Relationships: Beer
User ||--o{ Beer : "posted"
BeerStyle ||--o{ Beer : "categorizes"
Brewery ||--o{ Beer : "brewed"
Beer ||--o{ BeerPhoto : "has"
Photo ||--o{ BeerPhoto : "linked to"
Beer ||--o{ BeerComment : "has"
User ||--o{ BeerComment : "commented"
note bottom of User
Core stored procedures:
- USP_RegisterUser
- USP_GetUserAccountByUsername
- usp_UpdateUserAccount
- usp_DeleteUserAccount
- USP_RotateUserCredential
- USP_UpdateUserAccount
- USP_CreateUserVerification
end note
note bottom of Brewery
Core stored procedures:
- USP_CreateBrewery
- USP_UpdateBrewery
- USP_DeleteBrewery
(optimistic concurrency via Timer)
end note
@enduml

View File

@@ -166,8 +166,8 @@ NODE_ENV=development # Standard Node runtime mode
### SMTP configuration (backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
and account emails.
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
account emails.
```bash
SMTP_HOST=mailpit # Required, no default
@@ -183,8 +183,8 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
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)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
so on)
## Docker-specific variables
@@ -234,35 +234,35 @@ reference only and is not run as part of the active stack.
## 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` |
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode |
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
| 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` |
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode |
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
@@ -276,9 +276,8 @@ 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`
- No minimum length is enforced on the token secrets in code; the
"minimum 32 characters" guidance above is a recommendation, not an
enforced check
- 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

View File

@@ -46,7 +46,8 @@ See [Environment Variables](environment-variables.md) for the full list.
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, and Mailpit (local dev
SMTP).
Available endpoints:

View File

@@ -7,8 +7,8 @@ Biergarten App.
The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against
a live, seeded database
- **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.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
@@ -77,8 +77,8 @@ dotnet test API/API.Specs/API.Specs.csproj
### Feature slice unit tests
Each feature slice has its own test project, covering its command/query
handlers and repository:
Each feature slice has its own test project, covering its command/query handlers
and repository:
```bash
cd web/backend

View File

@@ -34,8 +34,8 @@ Low-level JWT operations.
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
Both token generation and validation live on the same slice-internal
service (there is no separate validation service/class).
Both token generation and validation live on the same slice-internal service
(there is no separate validation service/class).
**Generation methods:**
@@ -56,18 +56,18 @@ service (there is no separate validation service/class).
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
**Implementation:**
[TokenService.cs](../../web/backend/Features/Features.Auth/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.Auth`'s own
`AddFeaturesAuth()` 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.
`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()`
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
@@ -97,9 +97,9 @@ it directly.
**Flow:**
1. Receives a refresh token via `RefreshTokenCommand`
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`,
and issues a new access/refresh token pair
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and
issues a new access/refresh token pair
3. Maps the result onto `LoginPayload`
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)