mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)
* Integrate Spectre.Console into seed application, use async data loading * 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. * Add new documentation for Dapper migration/database schema * Update FluentAssertions to most recent open source ver * Update XML doc comment in backend projects * Modify brewery location dto to include denormalized data * - Updated column names from 'Timer' to 'RowVersion' in schema.sql for - Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency. - Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'. - Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'. - Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly. - Ensured all repository methods and interfaces are aligned with the new concurrency handling approach. * updates * Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts * Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture * Remove repository tests, usages of registration dto used in repo * archive old stored procs/functions * update stale docs * Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
This commit is contained in:
@@ -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,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`) is a single project that owns
|
||||
its own controller, MediatR commands/queries/handlers, validators, and
|
||||
repository, end to end:
|
||||
`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
|
||||
MediatR/DI, never over HTTP:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
@@ -46,15 +48,15 @@ repository, end to end:
|
||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
↓ discovers controllers via AddApplicationPart
|
||||
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
|
||||
│ Features.Auth │Features. │ Features. │ Features.Emails │
|
||||
│ │Breweries │ UserManagement │ (no controller, │
|
||||
│ Controller │ Controller │ Controller │ internal only) │
|
||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
||||
│ Queries + │ Queries + │ Queries + │ Handlers │
|
||||
│ Handlers │ Handlers │ Handlers │ │
|
||||
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
||||
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
||||
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
|
||||
│ 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 │ │
|
||||
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
|
||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||
@@ -69,23 +71,22 @@ repository, end to end:
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Database (SQL Server) │
|
||||
│ - Stored procedures │
|
||||
│ - Tables & constraints │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
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**:
|
||||
|
||||
@@ -108,32 +109,36 @@ its 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`)
|
||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
|
||||
|
||||
**Purpose**: Each slice is the complete vertical for one business capability
|
||||
|
||||
**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 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
|
||||
|
||||
`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. `Features.Locations` also has
|
||||
no `Controllers/` folder and no MediatR handlers — it exposes
|
||||
`ILocationRepository` directly as a plain service, currently consumed only by
|
||||
`Database.Seed` to look up cities while seeding brewery locations. Other slices
|
||||
(for example, `Features.Breweries`) do not yet depend on it; they run their own
|
||||
`CityID` existence checks inline.
|
||||
|
||||
**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 +147,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
|
||||
|
||||
@@ -201,6 +204,14 @@ whichever slices need them
|
||||
- `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)
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
@@ -218,16 +229,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,19 +260,18 @@ 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**:
|
||||
|
||||
- Testable (easy to mock)
|
||||
- SQL-first approach (stored procedures)
|
||||
- Each slice's data access logic is self-contained
|
||||
|
||||
**Example**:
|
||||
@@ -279,9 +288,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**:
|
||||
|
||||
@@ -289,22 +298,32 @@ extension method that registers its repository and slice-internal services
|
||||
- Singleton: `ISqlConnectionFactory`
|
||||
- Transient: Utilities, helpers
|
||||
|
||||
#### SQL-first approach
|
||||
#### Direct SQL via Dapper/ADO.NET
|
||||
|
||||
**Purpose**: Push complex logic into the database
|
||||
**Purpose**: Keep data access explicit and colocated with the slice that owns
|
||||
it, without an ORM's change-tracking or mapping magic
|
||||
|
||||
**Strategy**:
|
||||
|
||||
- All queries via stored procedures
|
||||
- No ORM (Entity Framework not used)
|
||||
- Database handles complex logic
|
||||
- Application focuses on orchestration
|
||||
- No ORM (Entity Framework not used); each repository issues inline SQL
|
||||
through Dapper, with no manual `IDbCommand` parameter binding. The one
|
||||
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
|
||||
can't deserialize as a UDT: reads select `CONVERT(varbinary(max),
|
||||
Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind
|
||||
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||
`CityId` exist?") are now 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, rather than relying on
|
||||
a database-side `THROW`; `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
|
||||
|
||||
**Stored Procedure Examples**:
|
||||
|
||||
- `USP_RegisterUser` - User registration
|
||||
- `USP_GetUserAccountByUsername` - User lookup
|
||||
- `USP_RotateUserCredential` - Password update
|
||||
See [Database](website/database.md) for the schema and the app's current SQL
|
||||
error-handling approach in more detail.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
@@ -351,8 +370,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
|
||||
|
||||
@@ -361,18 +380,29 @@ to `web/frontend`.
|
||||
1. **Registration**:
|
||||
- User submits credentials
|
||||
- Password hashed with Argon2id
|
||||
- User account created
|
||||
- JWT token issued
|
||||
- User account created (unverified) and a confirmation email is dispatched
|
||||
via `Features.Emails`
|
||||
|
||||
2. **Login**:
|
||||
2. **Email confirmation**:
|
||||
- User follows the confirmation link/token from the email
|
||||
- `Features.Auth`'s `ConfirmUser` command marks the account verified
|
||||
|
||||
3. **Login**:
|
||||
- User submits credentials
|
||||
- Password verified against hash
|
||||
- JWT token issued
|
||||
- Token stored client-side
|
||||
- Access and refresh JWTs are issued; the refresh token is stored server-side
|
||||
against the user's credential row
|
||||
|
||||
3. **API Requests**:
|
||||
- Client sends JWT in Authorization header
|
||||
- Middleware validates token
|
||||
4. **Token refresh**:
|
||||
- Client exchanges a valid, unexpired refresh token for a new access/refresh
|
||||
pair via `Features.Auth`'s `RefreshToken` command
|
||||
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
|
||||
refresh token is not invalidated and remains usable until its own
|
||||
expiration
|
||||
|
||||
5. **API Requests**:
|
||||
- Client sends the access JWT in the `Authorization` header
|
||||
- `JwtAuthenticationHandler` validates the token
|
||||
- Request proceeds if valid
|
||||
|
||||
### Password security
|
||||
@@ -411,19 +441,22 @@ to `web/frontend`.
|
||||
|
||||
## Database architecture
|
||||
|
||||
For the table-by-table schema and the custom SQL error code scheme, see
|
||||
[Database](website/database.md).
|
||||
|
||||
### SQL-first philosophy
|
||||
|
||||
**Principles**:
|
||||
|
||||
1. Database is source of truth
|
||||
2. Complex queries in stored procedures
|
||||
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
|
||||
- Centralized query logic
|
||||
- Query logic scoped to the repository that owns it, not spread across layers
|
||||
- Version-controlled schema (migrations)
|
||||
- Easier query profiling and tuning
|
||||
|
||||
@@ -433,32 +466,41 @@ to `web/frontend`.
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Write SQL migration script
|
||||
2. Embed in `Database.Migrations` project
|
||||
3. Run migrations on startup
|
||||
4. Idempotent and versioned
|
||||
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/
|
||||
├── 001-CreateUserTables.sql
|
||||
├── 002-CreateLocationTables.sql
|
||||
├── 003-CreateBreweryTables.sql
|
||||
└── ...
|
||||
└── 01-schema/
|
||||
└── schema.sql # full table/index/constraint definitions
|
||||
```
|
||||
|
||||
Currently the whole schema is a single versioned script. As the schema evolves,
|
||||
new numbered scripts get added alongside it rather than editing `schema.sql` in
|
||||
place, so DbUp's already-applied tracking stays valid.
|
||||
|
||||
### Data seeding
|
||||
|
||||
**Purpose**: Populate development/test databases
|
||||
**Purpose**: Populate development/test databases with realistic data
|
||||
|
||||
**Implementation**: `Database.Seed` project
|
||||
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
||||
|
||||
**Seed Data**:
|
||||
|
||||
- Countries, states/provinces, cities
|
||||
- Test user accounts
|
||||
- Sample breweries (future)
|
||||
- Countries, state/provinces, and cities (via `Features.Locations`'
|
||||
`ILocationRepository`, which creates `Country`/`StateProvince` rows on
|
||||
demand while resolving a city)
|
||||
- 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.
|
||||
|
||||
## Deployment architecture
|
||||
|
||||
@@ -470,6 +512,7 @@ scripts/
|
||||
- `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)
|
||||
|
||||
**Environments**:
|
||||
|
||||
@@ -505,8 +548,8 @@ healthcheck:
|
||||
├──────────────┤
|
||||
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
|
||||
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
|
||||
│ handlers + │ (commands/queries/handlers + that slice's own
|
||||
│ repository) │ repository, mocked with Moq/DbMocker)
|
||||
│ handler │ (commands/queries/handlers, with repository/service
|
||||
│ tests) │ dependencies mocked via Moq)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
79
docs/website/database.md
Normal file
79
docs/website/database.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Database
|
||||
|
||||
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 in one script:
|
||||
`web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql`, run 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)).
|
||||
|
||||
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.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
|
||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
|
||||
| `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 |
|
||||
|
||||
### 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) |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `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` |
|
||||
|
||||
## Migrations
|
||||
|
||||
**Tool**: DbUp, run by the `Database.Migrations` project/container.
|
||||
|
||||
Scripts live under `scripts/`, applied in order and tracked so a script never
|
||||
re-runs once it has succeeded. Today that's a single `01-schema/schema.sql`.
|
||||
When the schema needs to change, add a new numbered script rather than editing
|
||||
`schema.sql` in place — DbUp's tracking is per-script, so editing an
|
||||
already-applied script means it won't re-run against 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 only seeds
|
||||
tables that have a feature slice to write through (see the table above).
|
||||
|
||||
## 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` (e.g. a genuine connectivity or
|
||||
constraint-violation failure) maps to 503.
|
||||
|
||||
- One repository-level exception: `Features.Auth`'s `AuthRepository` 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 `Auth Repository.IsDuplicateVerificationViolation`.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,18 +23,26 @@ package "Feature Slices" #F3E5F5 {
|
||||
[Features.Breweries] as BrewerySlice
|
||||
[Features.UserManagement] as UserSlice
|
||||
[Features.Emails] as EmailsSlice
|
||||
[Features.Locations] as LocationsSlice
|
||||
note right of AuthSlice
|
||||
Each slice owns its own:
|
||||
- Controller (HTTP endpoints)
|
||||
- Commands/Queries + Handlers
|
||||
- Validators
|
||||
- Repository
|
||||
- Repository (Dapper)
|
||||
end note
|
||||
note right of EmailsSlice
|
||||
No controller - invoked only
|
||||
via MediatR commands sent
|
||||
from other slices
|
||||
end note
|
||||
note right of LocationsSlice
|
||||
No controller and no MediatR
|
||||
handlers - exposes ILocationRepository
|
||||
directly as a plain DI service.
|
||||
Not registered by API.Core at all;
|
||||
only Database.Seed depends on it today
|
||||
end note
|
||||
}
|
||||
|
||||
package "Shared" #FCE4EC {
|
||||
@@ -61,24 +69,30 @@ package "Domain Layer" #E8F5E9 {
|
||||
[Domain.Entities] as Domain
|
||||
[Domain.Exceptions] as DomainExceptions
|
||||
note right of Domain
|
||||
- UserAccount
|
||||
- UserCredential
|
||||
- UserVerification
|
||||
- BreweryPost
|
||||
- UserAccount, UserCredential, UserVerification
|
||||
- BreweryPost, BreweryPostLocation
|
||||
- City, StateProvince, Country
|
||||
- BeerPost, BeerStyle
|
||||
(schema tables exist; no repository yet)
|
||||
end note
|
||||
}
|
||||
|
||||
database "SQL Server" {
|
||||
[Stored Procedures] as SP
|
||||
[Tables] as Tables
|
||||
}
|
||||
|
||||
node "Database.Seed\n(separate console app)" as Seed
|
||||
|
||||
' Relationships
|
||||
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
||||
|
||||
Seed ..> AuthSlice : AddFeaturesAuth()\n(DI only, no API/MediatR)
|
||||
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
||||
Seed ..> LocationsSlice : AddFeaturesLocations()
|
||||
|
||||
AuthSlice --> Sql
|
||||
AuthSlice --> JWT
|
||||
AuthSlice --> PwdHash
|
||||
@@ -93,6 +107,8 @@ UserSlice --> Sql
|
||||
UserSlice --> SharedContracts
|
||||
UserSlice --> SharedApp
|
||||
|
||||
LocationsSlice --> Sql
|
||||
|
||||
EmailsSlice --> Email
|
||||
EmailsSlice --> EmailTemplates
|
||||
EmailsSlice --> SharedApp
|
||||
@@ -101,20 +117,25 @@ EmailsSlice --> SharedApp
|
||||
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
|
||||
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
||||
|
||||
Sql --> SP
|
||||
Sql --> Domain
|
||||
SP --> Tables
|
||||
|
||||
AuthSlice --> Tables : Dapper SQL
|
||||
BrewerySlice --> Tables : Dapper SQL
|
||||
UserSlice --> Tables : Dapper SQL
|
||||
LocationsSlice --> Tables : Dapper SQL
|
||||
|
||||
AuthSlice --> Domain
|
||||
BrewerySlice --> Domain
|
||||
UserSlice --> Domain
|
||||
LocationsSlice --> Domain
|
||||
AuthSlice --> DomainExceptions
|
||||
|
||||
' Notes
|
||||
note left of Sql
|
||||
SQL-first approach
|
||||
All queries via
|
||||
stored procedures
|
||||
Each slice's own repository issues
|
||||
inline SQL via Dapper - no ORM,
|
||||
no stored procedures
|
||||
end note
|
||||
|
||||
note bottom of AuthSlice
|
||||
|
||||
@@ -9,11 +9,11 @@ title User Authentication Flow - Expanded
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
box "Service Layer" #LightBlue
|
||||
participant "RegisterService" as RegSvc
|
||||
participant "LoginService" as LoginSvc
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #LightBlue
|
||||
participant "RegisterUserHandler" as RegHandler
|
||||
participant "LoginHandler" as LoginHandler
|
||||
participant "TokenService" as TokenSvc
|
||||
participant "EmailService" as EmailSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #LightGreen
|
||||
participant "Argon2\nInfrastructure" as Argon2
|
||||
@@ -21,55 +21,59 @@ participant "JWT\nInfrastructure" as JWT
|
||||
participant "Email\nProvider" as SMTP
|
||||
participant "Template\nProvider" as Template
|
||||
end box
|
||||
box "Repository Layer" #LightYellow
|
||||
box "Repository / Cross-slice" #LightYellow
|
||||
participant "AuthRepository" as AuthRepo
|
||||
participant "UserAccount\nRepository" as UserRepo
|
||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||
end box
|
||||
database "SQL Server\nStored Procedures" as DB
|
||||
database "SQL Server" as DB
|
||||
|
||||
== Registration Flow ==
|
||||
|
||||
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
||||
activate API
|
||||
|
||||
note right of API
|
||||
FluentValidation runs:
|
||||
API -> Mediator: Send(RegisterUserCommand)
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs RegisterUserValidator
|
||||
(FluentValidation) before the handler:
|
||||
- Username: 3-64 chars, alphanumeric + [._-]
|
||||
- Email: valid format, max 128 chars
|
||||
- Password: min 8 chars, uppercase,\n lowercase, number, special char
|
||||
- DateOfBirth: must be 19+ years old
|
||||
end note
|
||||
|
||||
API -> API: Validate request\n(FluentValidation)
|
||||
|
||||
alt Validation fails
|
||||
API -> User: 400 Bad Request\n{errors: {...}}
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
API -> RegSvc: RegisterAsync(userAccount, password)
|
||||
activate RegSvc
|
||||
Mediator -> RegHandler: Handle(command)
|
||||
activate RegHandler
|
||||
|
||||
RegSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
||||
RegHandler -> AuthRepo: GetUserByUsernameAsync(username)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
||||
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> AuthRepo: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
RegSvc -> AuthRepo: GetUserByEmailAsync(email)
|
||||
RegHandler -> AuthRepo: GetUserByEmailAsync(email)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail
|
||||
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
|
||||
activate DB
|
||||
DB --> AuthRepo: null (email doesn't exist)
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
alt User/Email already exists
|
||||
RegSvc -> API: throw ConflictException
|
||||
alt Username or email already exists
|
||||
RegHandler -> Mediator: throw ConflictException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict\n"Username or email already exists"
|
||||
else User doesn't exist
|
||||
else Username and email are free
|
||||
|
||||
RegSvc -> Argon2: Hash(password)
|
||||
RegHandler -> Argon2: Hash(password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Argon2id parameters:
|
||||
@@ -81,33 +85,39 @@ else Validation succeeds
|
||||
end note
|
||||
Argon2 -> Argon2: Generate random salt\n(16 bytes)
|
||||
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
|
||||
Argon2 --> RegSvc: "base64(salt):base64(hash)"
|
||||
Argon2 --> RegHandler: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash)
|
||||
RegHandler -> AuthRepo: RegisterUserAsync(userAccount)
|
||||
activate AuthRepo
|
||||
|
||||
AuthRepo -> DB: EXEC USP_RegisterUser
|
||||
AuthRepo -> DB: BEGIN TRANSACTION
|
||||
activate DB
|
||||
AuthRepo -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
|
||||
DB --> AuthRepo: UserAccountId (GUID)
|
||||
AuthRepo -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
AuthRepo -> DB: COMMIT TRANSACTION
|
||||
deactivate DB
|
||||
note right of AuthRepo
|
||||
All three statements run via Dapper's
|
||||
ExecuteScalarAsync/ExecuteAsync against
|
||||
a single DbTransaction, not a stored
|
||||
procedure - no manual parameter binding
|
||||
end note
|
||||
|
||||
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @UserAccountId
|
||||
activate DB
|
||||
note right of DB
|
||||
Transaction begins:
|
||||
1. INSERT UserAccount
|
||||
2. INSERT UserCredential
|
||||
(with hashed password)
|
||||
Transaction commits
|
||||
Round trip to load the row the
|
||||
INSERT just created
|
||||
end note
|
||||
DB -> DB: BEGIN TRANSACTION
|
||||
DB -> DB: INSERT INTO UserAccount\n(Username, FirstName, LastName,\nEmail, DateOfBirth)
|
||||
DB -> DB: OUTPUT INSERTED.UserAccountID
|
||||
DB -> DB: INSERT INTO UserCredential\n(UserAccountId, Hash)
|
||||
DB -> DB: COMMIT TRANSACTION
|
||||
DB --> AuthRepo: UserAccountId (GUID)
|
||||
DB --> AuthRepo: UserAccount row
|
||||
deactivate DB
|
||||
|
||||
AuthRepo --> RegSvc: UserAccount entity
|
||||
AuthRepo --> RegHandler: UserAccount entity
|
||||
deactivate AuthRepo
|
||||
|
||||
RegSvc -> TokenSvc: GenerateAccessToken(userAccount)
|
||||
RegHandler -> TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
@@ -118,33 +128,55 @@ else Validation succeeds
|
||||
- Claims:
|
||||
* sub: userId
|
||||
* unique_name: username
|
||||
* jti: unique token ID
|
||||
* iat / jti
|
||||
end note
|
||||
JWT -> JWT: Create JWT with claims
|
||||
JWT -> JWT: Sign with secret key
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegSvc: Access Token
|
||||
TokenSvc --> RegHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount)
|
||||
RegHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Refresh Token:
|
||||
- Expires: 21 days
|
||||
- Same structure as access token
|
||||
- Expires: 21 days (504 hours)
|
||||
- Same structure as access token,\n signed with a separate secret
|
||||
end note
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegSvc: Refresh Token
|
||||
TokenSvc --> RegHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken)
|
||||
activate EmailSvc
|
||||
RegHandler -> TokenSvc: GenerateConfirmationToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Confirmation Token:
|
||||
- Expires: 30 minutes (0.5 hours)
|
||||
- Signed with a third, separate secret
|
||||
end note
|
||||
JWT --> TokenSvc: Confirmation Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegHandler: Confirmation Token
|
||||
deactivate TokenSvc
|
||||
|
||||
EmailSvc -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
|
||||
RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
|
||||
activate Mediator
|
||||
Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
|
||||
note right of Mediator
|
||||
Routed to Features.Emails'
|
||||
SendRegistrationEmailHandler,
|
||||
the only cross-slice call in
|
||||
the backend
|
||||
end note
|
||||
activate EmailDispatcher
|
||||
|
||||
EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
|
||||
activate Template
|
||||
note right of Template
|
||||
Razor Component:
|
||||
@@ -154,38 +186,37 @@ else Validation succeeds
|
||||
- Footer
|
||||
end note
|
||||
Template -> Template: Render Razor component\nto HTML
|
||||
Template --> EmailSvc: HTML email content
|
||||
Template --> EmailDispatcher: HTML email content
|
||||
deactivate Template
|
||||
|
||||
EmailSvc -> SMTP: SendAsync(email, subject, body)
|
||||
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
|
||||
activate SMTP
|
||||
note right of SMTP
|
||||
SMTP Configuration:
|
||||
- Host: from env (SMTP_HOST)
|
||||
- Port: from env (SMTP_PORT)
|
||||
- TLS: StartTLS
|
||||
- Auth: username/password
|
||||
SMTP Configuration (MailKit):
|
||||
- Host/port: from env (SMTP_HOST/SMTP_PORT)
|
||||
- TLS: StartTLS when SMTP_USE_SSL=true
|
||||
- Auth: username/password, if configured
|
||||
end note
|
||||
SMTP -> SMTP: Create MIME message
|
||||
SMTP -> SMTP: Connect to SMTP server
|
||||
SMTP -> SMTP: Authenticate
|
||||
SMTP -> SMTP: Send email
|
||||
SMTP -> SMTP: Disconnect
|
||||
SMTP --> EmailSvc: Success / Failure
|
||||
SMTP -> SMTP: Build MIME message
|
||||
SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
|
||||
SMTP --> EmailDispatcher: Success / throws
|
||||
deactivate SMTP
|
||||
|
||||
deactivate EmailDispatcher
|
||||
deactivate Mediator
|
||||
|
||||
alt Email sent successfully
|
||||
EmailSvc --> RegSvc: emailSent = true
|
||||
else Email failed
|
||||
EmailSvc --> RegSvc: emailSent = false\n(error suppressed)
|
||||
RegHandler -> RegHandler: emailSent = true
|
||||
else mediator.Send throws
|
||||
RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
|
||||
end
|
||||
deactivate EmailSvc
|
||||
|
||||
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent)
|
||||
deactivate RegSvc
|
||||
RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
|
||||
deactivate RegHandler
|
||||
Mediator --> API: RegistrationPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> API: Create response body
|
||||
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
|
||||
API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
@@ -195,88 +226,94 @@ deactivate API
|
||||
User -> API: POST /api/auth/login\n{username, password}
|
||||
activate API
|
||||
|
||||
API -> API: Validate request\n(FluentValidation)
|
||||
API -> Mediator: Send(LoginCommand)
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs LoginValidator:
|
||||
username and password must both be non-empty
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
API -> User: 400 Bad Request\n{errors: {...}}
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
|
||||
API -> LoginSvc: LoginAsync(username, password)
|
||||
activate LoginSvc
|
||||
Mediator -> LoginHandler: Handle(command)
|
||||
activate LoginHandler
|
||||
|
||||
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
||||
LoginHandler -> AuthRepo: GetUserByUsernameAsync(username)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
||||
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username
|
||||
DB --> AuthRepo: UserAccount entity
|
||||
DB --> AuthRepo: UserAccount row or null
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
alt User not found
|
||||
LoginSvc -> API: throw UnauthorizedException\n"Invalid username or password"
|
||||
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
|
||||
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
|
||||
LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC USP_GetActiveUserCredentialByUserAccountId
|
||||
AuthRepo -> DB: SELECT UserCredentialId, UserAccountId,\nHash, CreatedAt, RowVersion\nFROM dbo.UserCredential\nWHERE UserAccountId = @UserAccountId\n AND IsRevoked = 0
|
||||
activate DB
|
||||
note right of DB
|
||||
SELECT FROM UserCredential
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
AND IsRevoked = 0
|
||||
end note
|
||||
DB --> AuthRepo: UserCredential entity
|
||||
DB --> AuthRepo: UserCredential row or null
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
alt No active credential
|
||||
LoginSvc -> API: throw UnauthorizedException
|
||||
LoginHandler -> Mediator: throw UnauthorizedException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Active credential found
|
||||
|
||||
LoginSvc -> Argon2: Verify(password, storedHash)
|
||||
LoginHandler -> Argon2: Verify(password, storedHash)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
1. Split stored hash: "salt:hash"
|
||||
2. Extract salt
|
||||
3. Hash provided password\n with same salt
|
||||
4. Constant-time comparison
|
||||
4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
||||
end note
|
||||
Argon2 -> Argon2: Parse salt from stored hash
|
||||
Argon2 -> Argon2: Hash provided password\nwith extracted salt
|
||||
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
|
||||
Argon2 --> LoginSvc: true/false
|
||||
Argon2 --> LoginHandler: true/false
|
||||
deactivate Argon2
|
||||
|
||||
alt Password invalid
|
||||
LoginSvc -> API: throw UnauthorizedException
|
||||
LoginHandler -> Mediator: throw UnauthorizedException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Password valid
|
||||
|
||||
LoginSvc -> TokenSvc: GenerateAccessToken(user)
|
||||
LoginHandler -> TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginSvc: Access Token
|
||||
TokenSvc --> LoginHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginSvc -> TokenSvc: GenerateRefreshToken(user)
|
||||
LoginHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginSvc: Refresh Token
|
||||
TokenSvc --> LoginHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken)
|
||||
deactivate LoginSvc
|
||||
LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
|
||||
deactivate LoginHandler
|
||||
Mediator --> API: LoginPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{\n message: "Logged in successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken\n }\n}
|
||||
API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -286,13 +323,15 @@ deactivate API
|
||||
== Error Handling (Global Exception Filter) ==
|
||||
|
||||
note over API
|
||||
GlobalExceptionFilter catches:
|
||||
- ValidationException → 400 Bad Request
|
||||
- ConflictException → 409 Conflict
|
||||
- NotFoundException → 404 Not Found
|
||||
- UnauthorizedException → 401 Unauthorized
|
||||
- ForbiddenException → 403 Forbidden
|
||||
- All others → 500 Internal Server Error
|
||||
API.Core's GlobalExceptionFilter catches:
|
||||
- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
|
||||
- ConflictException -> 409 Conflict
|
||||
- NotFoundException -> 404 Not Found
|
||||
- UnauthorizedException -> 401 Unauthorized
|
||||
- ForbiddenException -> 403 Forbidden
|
||||
- Domain.Exceptions.ValidationException -> 400 Bad Request
|
||||
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
|
||||
- All others -> 500 Internal Server Error
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -3,102 +3,205 @@
|
||||
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 RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice 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_RotateUserCredential
|
||||
- USP_UpdateUserAccount
|
||||
Backed by Features.Auth/Features.UserManagement's
|
||||
Dapper repositories (AuthRepository,
|
||||
UserAccountRepository) - registration, credential
|
||||
rotation, and verification each issue their own
|
||||
inline SQL, no stored procedures
|
||||
end note
|
||||
|
||||
note bottom of Brewery
|
||||
Backed by Features.Breweries' BreweryRepository
|
||||
(Dapper); create/update/delete run inline SQL
|
||||
inside a transaction, with optimistic concurrency
|
||||
via RowVersion
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -113,7 +113,7 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||
}
|
||||
|
||||
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
|
||||
component "xUnit + Moq + DbMocker" as UnitComp
|
||||
component "xUnit + Moq" as UnitComp
|
||||
note right
|
||||
Builds Dockerfile.tests, which
|
||||
restores/builds against Core.slnx
|
||||
@@ -124,8 +124,8 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||
- Features.UserManagement.Tests
|
||||
- Features.Emails.Tests
|
||||
|
||||
Uses: Moq for handlers,
|
||||
DbMocker for repositories
|
||||
Uses: Moq to mock each handler's
|
||||
repository/service dependencies
|
||||
No real database needed
|
||||
end note
|
||||
}
|
||||
@@ -164,7 +164,7 @@ DevAPI .up.> DevSeed : depends_on
|
||||
TestMig --> TestDB : 1. Migrate
|
||||
TestSeed --> TestDB : 2. Seed
|
||||
Specs --> TestDB : 3. Integration test
|
||||
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq/DbMocker,\nno real connection)
|
||||
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq mocks,\nno real connection)
|
||||
|
||||
TestMig .up.> TestDB : depends_on
|
||||
TestSeed .up.> TestMig : depends_on
|
||||
|
||||
@@ -88,12 +88,26 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||
|
||||
**Generate Secrets**:
|
||||
|
||||
```bash
|
||||
# macOS/Linux - Generate 127-character base64 secret
|
||||
openssl rand -base64 127
|
||||
**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw bytes)**
|
||||
|
||||
# Windows PowerShell
|
||||
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
|
||||
```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**:
|
||||
@@ -166,8 +180,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 +197,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 +248,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 +290,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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ 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
|
||||
slice's command/query handlers and its own repository (mocked with Moq /
|
||||
DbMocker, no real database required)
|
||||
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
|
||||
@@ -78,7 +78,7 @@ 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:
|
||||
handlers:
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
@@ -93,8 +93,8 @@ for proj in Features/*.Tests; do dotnet test "$proj"; done
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- No database required (handlers use Moq; repository tests use DbMocker to
|
||||
simulate SQL Server responses)
|
||||
- No database required (handlers are tested with their repository/service
|
||||
dependencies mocked via Moq)
|
||||
|
||||
### Frontend Storybook tests
|
||||
|
||||
@@ -137,7 +137,6 @@ npm run test:storybook:playwright
|
||||
- Email confirmation and confirmation-email resend
|
||||
- Refresh token exchange
|
||||
- JWT token generation, validation, and claims handling
|
||||
- `IAuthRepository` data access (DbMocker-backed)
|
||||
- Invalid credentials and 404 error responses (via API.Specs)
|
||||
|
||||
**Features.Breweries.Tests**:
|
||||
@@ -173,7 +172,7 @@ npm run test:storybook:playwright
|
||||
### xUnit
|
||||
|
||||
- Primary unit testing framework
|
||||
- Used for Repository and Service layer tests
|
||||
- Used for handler and service layer tests
|
||||
- Supports parallel test execution
|
||||
|
||||
### Reqnroll (Gherkin/BDD)
|
||||
@@ -194,12 +193,6 @@ npm run test:storybook:playwright
|
||||
- Used in Service layer tests
|
||||
- Enables isolated unit testing
|
||||
|
||||
### DbMocker
|
||||
|
||||
- Database mocking for repository tests
|
||||
- Simulates SQL Server responses
|
||||
- No real database required for unit tests
|
||||
|
||||
## Test structure
|
||||
|
||||
### API.Specs (integration tests)
|
||||
@@ -248,9 +241,7 @@ Features.Auth.Tests/
|
||||
│ ├── ResendConfirmationEmailHandlerTests.cs
|
||||
│ └── RefreshTokenHandlerTests.cs
|
||||
├── Queries/
|
||||
│ └── LoginHandlerTests.cs
|
||||
├── Repository/
|
||||
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
|
||||
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
|
||||
└── Services/
|
||||
├── TokenServiceRefreshTests.cs
|
||||
└── TokenServiceValidationTests.cs
|
||||
@@ -278,7 +269,7 @@ public class LoginHandlerTests
|
||||
// ...set up mocks for a known user/credential...
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None);
|
||||
var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user