update stale docs

This commit is contained in:
Aaron Po
2026-08-20 22:51:27 -04:00
parent 7d3298f5e1
commit 55e3d8d632
11 changed files with 240 additions and 185 deletions

View File

@@ -71,7 +71,6 @@ MediatR/DI, never over HTTP:
↓ ↓
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Database (SQL Server) │ │ Database (SQL Server) │
│ - Stored procedures │
│ - Tables & constraints │ │ - Tables & constraints │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
``` ```
@@ -121,7 +120,7 @@ own
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation, - `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
each containing the Command/Query record, its `IRequestHandler`, and (for each containing the Command/Query record, its `IRequestHandler`, and (for
commands) a FluentValidation validator commands) a FluentValidation validator
- `Repository/`: the slice's own stored-procedure repository implementation - `Repository/`: the slice's own Dapper repository implementation
- `Dtos/`: response shapes returned by query handlers (never the raw domain - `Dtos/`: response shapes returned by query handlers (never the raw domain
entity) entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the - `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
@@ -208,6 +207,11 @@ whichever slices need them
- `BreweryPost` - A user-submitted brewery listing - `BreweryPost` - A user-submitted brewery listing
- `BreweryPostLocation` - A brewery's address and coordinates - `BreweryPostLocation` - A brewery's address and coordinates
- `City` - A city referenced by a brewery location - `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**: **Dependencies**:
@@ -301,15 +305,16 @@ it, without an ORM's change-tracking or mapping magic
**Strategy**: **Strategy**:
- No ORM (Entity Framework not used); each - No ORM (Entity Framework not used); each repository issues inline SQL
repository issues inline SQL (a mix of Dapper and raw through Dapper, with no manual `IDbCommand` parameter binding. The one
`DbCommand`/`DbDataReader` for the few reads that need manual column mapping, wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
such as the `GEOGRAPHY` column on `BreweryPostLocation`) 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 - Referential checks that a stored procedure used to perform (e.g. "does this
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the `CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
repository method, run inside the same transaction as the write repository method, run inside the same transaction as the write
- Optimistic concurrency uses each table's `Timer` (`ROWVERSION`) column, - Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
checked in the `UPDATE ... WHERE ... AND Timer = @Timer` clause checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
- Repositories throw `Domain.Exceptions` types (`NotFoundException`, - Repositories throw `Domain.Exceptions` types (`NotFoundException`,
`ConflictException`, ...) directly when a check fails, rather than relying on `ConflictException`, ...) directly when a check fails, rather than relying on
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
@@ -436,23 +441,22 @@ for reference only. Active product and engineering documentation should point to
## Database architecture ## Database architecture
For the table-by-table schema, the stored procedure reference (parameters, For the table-by-table schema and the custom SQL error code scheme, see
behavior, and exactly what each one throws), and the custom SQL error code [Database](website/database.md).
scheme, see [Database](website/database.md).
### SQL-first philosophy ### SQL-first philosophy
**Principles**: **Principles**:
1. Database is source of truth 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 3. Database handles referential integrity
4. Application orchestrates, database executes 4. Application orchestrates, database executes
**Benefits**: **Benefits**:
- Performance optimization via execution plans - 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) - Version-controlled schema (migrations)
- Easier query profiling and tuning - Easier query profiling and tuning
@@ -488,14 +492,15 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
**Seed Data**: **Seed Data**:
- Cities (via `Features.Locations`' `ILocationRepository`) - 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) - User accounts (via `Features.Auth`'s repository)
- Brewery posts with locations (via `Features.Breweries`' repository) - Brewery posts with locations (via `Features.Breweries`' repository)
Tables that exist in the schema but have no corresponding `Domain.Entities` type Tables that exist in the schema but have no repository yet (`Photo`,
or feature slice yet (`Photo`, `UserAvatar`, `UserFollow`, `Country`, `UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`,
`StateProvince`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, `BeerPostComment`, `BreweryPostPhoto`) are not seeded.
`BreweryPostPhoto`) are not seeded.
## Deployment architecture ## Deployment architecture
@@ -543,8 +548,8 @@ healthcheck:
├──────────────┤ ├──────────────┤
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests, │ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests │ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
│ handlers + │ (commands/queries/handlers + that slice's own │ handler │ (commands/queries/handlers, with repository/service
│ repository) │ repository, mocked with Moq/DbMocker) │ tests) │ dependencies mocked via Moq)
└──────────────┘ └──────────────┘
``` ```

View File

@@ -13,8 +13,8 @@ owning feature slice's repository (see
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)). [Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
most carry a `Timer ROWVERSION` column used for optimistic concurrency on most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
updates (`UPDATE ... WHERE ... AND Timer = @Timer`). updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
### Tables backed by the API today ### Tables backed by the API today
@@ -24,23 +24,23 @@ updates (`UPDATE ... WHERE ... AND Timer = @Timer`).
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | | `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 | | `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | | `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 manually via `DbDataReader.GetFieldValue<byte[]>` because Dapper can't deserialize SQL Server UDTs | | `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`; looked up via `ILocationRepository`, currently consumed only by `Database.Seed` | | `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 ### 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 These exist so the schema can support planned functionality, but have no
`Domain.Entities` type, repository, or seed data yet: repository or seed data yet:
| Table | Purpose (from schema) | | Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- | | ------------------ | -------------------------------------------------------------------------------------------------------- |
| `Photo` | A photo uploaded by a user account | | `Photo` | A photo uploaded by a user account |
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar | | `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) | | `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `StateProvince` | Referenced by `City.StateProvinceID` | | `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
| `Country` | Referenced by `StateProvince.CountryID` | | `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 |
| `BeerStyle` | A beer style/category |
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`) |
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` | | `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` | | `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` | | `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
@@ -58,14 +58,15 @@ already-applied script means it won't re-run against existing databases.
## Data seeding ## Data seeding
`Database.Seed` populates a target database from data produced by the C++ `Database.Seed` populates a target database from data produced by the C++
pipeline (see [Pipeline README](../pipeline/README.md)): cities, user accounts, pipeline (see [Pipeline README](../pipeline/README.md)): countries,
and brewery posts with locations. It only seeds tables that have a feature slice state/provinces, and cities (created on demand while resolving each brewery's
to write through (see the table above). 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 ## Database error handling
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero - Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
rows against the `Timer` column. rows against the `RowVersion` column.
- On failure, the repository throws a `Domain.Exceptions` type directly - On failure, the repository throws a `Domain.Exceptions` type directly
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...). (`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404, `API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,

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

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 71 KiB

File diff suppressed because one or more lines are too long

View File

@@ -23,18 +23,26 @@ package "Feature Slices" #F3E5F5 {
[Features.Breweries] as BrewerySlice [Features.Breweries] as BrewerySlice
[Features.UserManagement] as UserSlice [Features.UserManagement] as UserSlice
[Features.Emails] as EmailsSlice [Features.Emails] as EmailsSlice
[Features.Locations] as LocationsSlice
note right of AuthSlice note right of AuthSlice
Each slice owns its own: Each slice owns its own:
- Controller (HTTP endpoints) - Controller (HTTP endpoints)
- Commands/Queries + Handlers - Commands/Queries + Handlers
- Validators - Validators
- Repository - Repository (Dapper)
end note end note
note right of EmailsSlice note right of EmailsSlice
No controller - invoked only No controller - invoked only
via MediatR commands sent via MediatR commands sent
from other slices from other slices
end note 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 { package "Shared" #FCE4EC {
@@ -61,24 +69,30 @@ package "Domain Layer" #E8F5E9 {
[Domain.Entities] as Domain [Domain.Entities] as Domain
[Domain.Exceptions] as DomainExceptions [Domain.Exceptions] as DomainExceptions
note right of Domain note right of Domain
- UserAccount - UserAccount, UserCredential, UserVerification
- UserCredential - BreweryPost, BreweryPostLocation
- UserVerification - City, StateProvince, Country
- BreweryPost - BeerPost, BeerStyle
(schema tables exist; no repository yet)
end note end note
} }
database "SQL Server" { database "SQL Server" {
[Stored Procedures] as SP
[Tables] as Tables [Tables] as Tables
} }
node "Database.Seed\n(separate console app)" as Seed
' Relationships ' Relationships
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
API ..> UserSlice : AddApplicationPart\n+ MediatR scan API ..> UserSlice : AddApplicationPart\n+ MediatR scan
API ..> EmailsSlice : MediatR scan only\n(no controller) 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 --> Sql
AuthSlice --> JWT AuthSlice --> JWT
AuthSlice --> PwdHash AuthSlice --> PwdHash
@@ -93,6 +107,8 @@ UserSlice --> Sql
UserSlice --> SharedContracts UserSlice --> SharedContracts
UserSlice --> SharedApp UserSlice --> SharedApp
LocationsSlice --> Sql
EmailsSlice --> Email EmailsSlice --> Email
EmailsSlice --> EmailTemplates EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp EmailsSlice --> SharedApp
@@ -101,20 +117,25 @@ EmailsSlice --> SharedApp
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> SP
Sql --> Domain Sql --> Domain
SP --> Tables
AuthSlice --> Tables : Dapper SQL
BrewerySlice --> Tables : Dapper SQL
UserSlice --> Tables : Dapper SQL
LocationsSlice --> Tables : Dapper SQL
AuthSlice --> Domain AuthSlice --> Domain
BrewerySlice --> Domain BrewerySlice --> Domain
UserSlice --> Domain UserSlice --> Domain
LocationsSlice --> Domain
AuthSlice --> DomainExceptions AuthSlice --> DomainExceptions
' Notes ' Notes
note left of Sql note left of Sql
SQL-first approach SQL-first approach
All queries via Each slice's own repository issues
stored procedures inline SQL via Dapper - no ORM,
no stored procedures
end note end note
note bottom of AuthSlice note bottom of AuthSlice

View File

@@ -9,11 +9,11 @@ title User Authentication Flow - Expanded
actor User actor User
participant "API\nController" as API participant "API\nController" as API
box "Service Layer" #LightBlue participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
participant "RegisterService" as RegSvc box "Handler Layer" #LightBlue
participant "LoginService" as LoginSvc participant "RegisterUserHandler" as RegHandler
participant "LoginHandler" as LoginHandler
participant "TokenService" as TokenSvc participant "TokenService" as TokenSvc
participant "EmailService" as EmailSvc
end box end box
box "Infrastructure Layer" #LightGreen box "Infrastructure Layer" #LightGreen
participant "Argon2\nInfrastructure" as Argon2 participant "Argon2\nInfrastructure" as Argon2
@@ -21,55 +21,59 @@ participant "JWT\nInfrastructure" as JWT
participant "Email\nProvider" as SMTP participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template participant "Template\nProvider" as Template
end box end box
box "Repository Layer" #LightYellow box "Repository / Cross-slice" #LightYellow
participant "AuthRepository" as AuthRepo participant "AuthRepository" as AuthRepo
participant "UserAccount\nRepository" as UserRepo participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
end box end box
database "SQL Server\nStored Procedures" as DB database "SQL Server" as DB
== Registration Flow == == Registration Flow ==
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password} User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
activate API activate API
note right of API API -> Mediator: Send(RegisterUserCommand)
FluentValidation runs: activate Mediator
note right of Mediator
ValidationBehavior runs RegisterUserValidator
(FluentValidation) before the handler:
- Username: 3-64 chars, alphanumeric + [._-] - Username: 3-64 chars, alphanumeric + [._-]
- Email: valid format, max 128 chars - Email: valid format, max 128 chars
- Password: min 8 chars, uppercase,\n lowercase, number, special char - Password: min 8 chars, uppercase,\n lowercase, number, special char
- DateOfBirth: must be 19+ years old - DateOfBirth: must be 19+ years old
end note end note
API -> API: Validate request\n(FluentValidation)
alt Validation fails 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 else Validation succeeds
API -> RegSvc: RegisterAsync(userAccount, password) Mediator -> RegHandler: Handle(command)
activate RegSvc activate RegHandler
RegSvc -> AuthRepo: GetUserByUsernameAsync(username) RegHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB activate DB
DB --> AuthRepo: null (user doesn't exist) DB --> AuthRepo: null (user doesn't exist)
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
RegSvc -> AuthRepo: GetUserByEmailAsync(email) RegHandler -> AuthRepo: GetUserByEmailAsync(email)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
activate DB activate DB
DB --> AuthRepo: null (email doesn't exist) DB --> AuthRepo: null (email doesn't exist)
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
alt User/Email already exists alt Username or email already exists
RegSvc -> API: throw ConflictException RegHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict\n"Username or email already exists" 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 activate Argon2
note right of Argon2 note right of Argon2
Argon2id parameters: Argon2id parameters:
@@ -81,33 +85,39 @@ else Validation succeeds
end note end note
Argon2 -> Argon2: Generate random salt\n(16 bytes) Argon2 -> Argon2: Generate random salt\n(16 bytes)
Argon2 -> Argon2: Hash password with\nArgon2id algorithm Argon2 -> Argon2: Hash password with\nArgon2id algorithm
Argon2 --> RegSvc: "base64(salt):base64(hash)" Argon2 --> RegHandler: "base64(salt):base64(hash)"
deactivate Argon2 deactivate Argon2
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash) RegHandler -> AuthRepo: RegisterUserAsync(userAccount)
activate AuthRepo 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 activate DB
note right of DB note right of DB
Transaction begins: Round trip to load the row the
1. INSERT UserAccount INSERT just created
2. INSERT UserCredential
(with hashed password)
Transaction commits
end note end note
DB -> DB: BEGIN TRANSACTION DB --> AuthRepo: UserAccount row
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)
deactivate DB deactivate DB
AuthRepo --> RegSvc: UserAccount entity AuthRepo --> RegHandler: UserAccount entity
deactivate AuthRepo deactivate AuthRepo
RegSvc -> TokenSvc: GenerateAccessToken(userAccount) RegHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry) TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT activate JWT
@@ -118,33 +128,55 @@ else Validation succeeds
- Claims: - Claims:
* sub: userId * sub: userId
* unique_name: username * unique_name: username
* jti: unique token ID * iat / jti
end note end note
JWT -> JWT: Create JWT with claims JWT -> JWT: Create JWT with claims
JWT -> JWT: Sign with secret key JWT -> JWT: Sign with secret key
JWT --> TokenSvc: Access Token JWT --> TokenSvc: Access Token
deactivate JWT deactivate JWT
TokenSvc --> RegSvc: Access Token TokenSvc --> RegHandler: Access Token
deactivate TokenSvc deactivate TokenSvc
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount) RegHandler -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry) TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT activate JWT
note right of JWT note right of JWT
Refresh Token: Refresh Token:
- Expires: 21 days - Expires: 21 days (504 hours)
- Same structure as access token - Same structure as access token,\n signed with a separate secret
end note end note
JWT --> TokenSvc: Refresh Token JWT --> TokenSvc: Refresh Token
deactivate JWT deactivate JWT
TokenSvc --> RegSvc: Refresh Token TokenSvc --> RegHandler: Refresh Token
deactivate TokenSvc deactivate TokenSvc
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken) RegHandler -> TokenSvc: GenerateConfirmationToken(user)
activate EmailSvc 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 activate Template
note right of Template note right of Template
Razor Component: Razor Component:
@@ -154,38 +186,37 @@ else Validation succeeds
- Footer - Footer
end note end note
Template -> Template: Render Razor component\nto HTML Template -> Template: Render Razor component\nto HTML
Template --> EmailSvc: HTML email content Template --> EmailDispatcher: HTML email content
deactivate Template deactivate Template
EmailSvc -> SMTP: SendAsync(email, subject, body) EmailDispatcher -> SMTP: SendAsync(email, subject, body)
activate SMTP activate SMTP
note right of SMTP note right of SMTP
SMTP Configuration: SMTP Configuration (MailKit):
- Host: from env (SMTP_HOST) - Host/port: from env (SMTP_HOST/SMTP_PORT)
- Port: from env (SMTP_PORT) - TLS: StartTLS when SMTP_USE_SSL=true
- TLS: StartTLS - Auth: username/password, if configured
- Auth: username/password
end note end note
SMTP -> SMTP: Create MIME message SMTP -> SMTP: Build MIME message
SMTP -> SMTP: Connect to SMTP server SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
SMTP -> SMTP: Authenticate SMTP --> EmailDispatcher: Success / throws
SMTP -> SMTP: Send email
SMTP -> SMTP: Disconnect
SMTP --> EmailSvc: Success / Failure
deactivate SMTP deactivate SMTP
deactivate EmailDispatcher
deactivate Mediator
alt Email sent successfully alt Email sent successfully
EmailSvc --> RegSvc: emailSent = true RegHandler -> RegHandler: emailSent = true
else Email failed else mediator.Send throws
EmailSvc --> RegSvc: emailSent = false\n(error suppressed) RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
end end
deactivate EmailSvc
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent) RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
deactivate RegSvc 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 refreshToken, accessToken,\n confirmationEmailSent\n }\n}
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
end end
end end
deactivate API deactivate API
@@ -195,88 +226,94 @@ deactivate API
User -> API: POST /api/auth/login\n{username, password} User -> API: POST /api/auth/login\n{username, password}
activate API 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 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 else Validation succeeds
API -> LoginSvc: LoginAsync(username, password) Mediator -> LoginHandler: Handle(command)
activate LoginSvc activate LoginHandler
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username) LoginHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB activate DB
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username DB --> AuthRepo: UserAccount row or null
DB --> AuthRepo: UserAccount entity
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
alt User not found 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 API -> User: 401 Unauthorized
else User found else User found
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId) LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
activate AuthRepo 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 activate DB
note right of DB DB --> AuthRepo: UserCredential row or null
SELECT FROM UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0
end note
DB --> AuthRepo: UserCredential entity
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
alt No active credential alt No active credential
LoginSvc -> API: throw UnauthorizedException LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized API -> User: 401 Unauthorized
else Active credential found else Active credential found
LoginSvc -> Argon2: Verify(password, storedHash) LoginHandler -> Argon2: Verify(password, storedHash)
activate Argon2 activate Argon2
note right of Argon2 note right of Argon2
1. Split stored hash: "salt:hash" 1. Split stored hash: "salt:hash"
2. Extract salt 2. Extract salt
3. Hash provided password\n with same salt 3. Hash provided password\n with same salt
4. Constant-time comparison 4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
end note end note
Argon2 -> Argon2: Parse salt from stored hash Argon2 -> Argon2: Parse salt from stored hash
Argon2 -> Argon2: Hash provided password\nwith extracted salt Argon2 -> Argon2: Hash provided password\nwith extracted salt
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored) Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
Argon2 --> LoginSvc: true/false Argon2 --> LoginHandler: true/false
deactivate Argon2 deactivate Argon2
alt Password invalid alt Password invalid
LoginSvc -> API: throw UnauthorizedException LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized API -> User: 401 Unauthorized
else Password valid else Password valid
LoginSvc -> TokenSvc: GenerateAccessToken(user) LoginHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...) TokenSvc -> JWT: GenerateJwt(...)
activate JWT activate JWT
JWT --> TokenSvc: Access Token JWT --> TokenSvc: Access Token
deactivate JWT deactivate JWT
TokenSvc --> LoginSvc: Access Token TokenSvc --> LoginHandler: Access Token
deactivate TokenSvc deactivate TokenSvc
LoginSvc -> TokenSvc: GenerateRefreshToken(user) LoginHandler -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...) TokenSvc -> JWT: GenerateJwt(...)
activate JWT activate JWT
JWT --> TokenSvc: Refresh Token JWT --> TokenSvc: Refresh Token
deactivate JWT deactivate JWT
TokenSvc --> LoginSvc: Refresh Token TokenSvc --> LoginHandler: Refresh Token
deactivate TokenSvc deactivate TokenSvc
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken) LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
deactivate LoginSvc 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 end
end end
@@ -286,13 +323,15 @@ deactivate API
== Error Handling (Global Exception Filter) == == Error Handling (Global Exception Filter) ==
note over API note over API
GlobalExceptionFilter catches: API.Core's GlobalExceptionFilter catches:
- ValidationException → 400 Bad Request - FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
- ConflictException → 409 Conflict - ConflictException -> 409 Conflict
- NotFoundException → 404 Not Found - NotFoundException -> 404 Not Found
- UnauthorizedException → 401 Unauthorized - UnauthorizedException -> 401 Unauthorized
- ForbiddenException → 403 Forbidden - ForbiddenException -> 403 Forbidden
- All others → 500 Internal Server Error - Domain.Exceptions.ValidationException -> 400 Bad Request
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
- All others -> 500 Internal Server Error
end note end note
@enduml @enduml

View File

@@ -6,7 +6,7 @@ skinparam linetype ortho
skinparam packageStyle rectangle skinparam packageStyle rectangle
title Database Schema 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. caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
package "User & Auth" { package "User & Auth" {
entity "UserAccount" as User { entity "UserAccount" as User {
@@ -190,20 +190,18 @@ Beer ||--o{ BeerComment : "has"
User ||--o{ BeerComment : "commented" User ||--o{ BeerComment : "commented"
note bottom of User note bottom of User
Core stored procedures: Backed by Features.Auth/Features.UserManagement's
- USP_RegisterUser Dapper repositories (AuthRepository,
- usp_UpdateUserAccount UserAccountRepository) - registration, credential
- usp_DeleteUserAccount rotation, and verification each issue their own
- USP_RotateUserCredential inline SQL, no stored procedures
- USP_CreateUserVerification
end note end note
note bottom of Brewery note bottom of Brewery
Core stored procedures: Backed by Features.Breweries' BreweryRepository
- USP_CreateBrewery (Dapper); create/update/delete run inline SQL
- USP_UpdateBrewery inside a transaction, with optimistic concurrency
- USP_DeleteBrewery via RowVersion
(optimistic concurrency via Timer)
end note end note
@enduml @enduml

View File

@@ -113,7 +113,7 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
} }
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests { node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
component "xUnit + Moq + DbMocker" as UnitComp component "xUnit + Moq" as UnitComp
note right note right
Builds Dockerfile.tests, which Builds Dockerfile.tests, which
restores/builds against Core.slnx restores/builds against Core.slnx
@@ -124,8 +124,8 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
- Features.UserManagement.Tests - Features.UserManagement.Tests
- Features.Emails.Tests - Features.Emails.Tests
Uses: Moq for handlers, Uses: Moq to mock each handler's
DbMocker for repositories repository/service dependencies
No real database needed No real database needed
end note end note
} }
@@ -164,7 +164,7 @@ DevAPI .up.> DevSeed : depends_on
TestMig --> TestDB : 1. Migrate TestMig --> TestDB : 1. Migrate
TestSeed --> TestDB : 2. Seed TestSeed --> TestDB : 2. Seed
Specs --> TestDB : 3. Integration test 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 TestMig .up.> TestDB : depends_on
TestSeed .up.> TestMig : depends_on TestSeed .up.> TestMig : depends_on

View File

@@ -12,8 +12,8 @@ The project uses a multi-layered testing approach across backend and frontend:
- **Features.\*.Tests** - One unit test project per backend feature slice - **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`, (`Features.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that `Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
slice's command/query handlers and its own repository (mocked with Moq / slice's command/query handlers, with dependencies mocked via Moq (no real
DbMocker, no real database required) database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared - **Storybook Vitest project** - Browser-based interaction tests for shared
website stories website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered - **Storybook Playwright suite** - Browser checks against Storybook-rendered
@@ -77,8 +77,8 @@ dotnet test API/API.Specs/API.Specs.csproj
### Feature slice unit tests ### Feature slice unit tests
Each feature slice has its own test project, covering its command/query handlers Each feature slice has its own test project, covering its command/query
and repository: handlers:
```bash ```bash
cd web/backend cd web/backend
@@ -93,8 +93,8 @@ for proj in Features/*.Tests; do dotnet test "$proj"; done
**Requirements**: **Requirements**:
- No database required (handlers use Moq; repository tests use DbMocker to - No database required (handlers are tested with their repository/service
simulate SQL Server responses) dependencies mocked via Moq)
### Frontend Storybook tests ### Frontend Storybook tests
@@ -137,7 +137,6 @@ npm run test:storybook:playwright
- Email confirmation and confirmation-email resend - Email confirmation and confirmation-email resend
- Refresh token exchange - Refresh token exchange
- JWT token generation, validation, and claims handling - JWT token generation, validation, and claims handling
- `IAuthRepository` data access (DbMocker-backed)
- Invalid credentials and 404 error responses (via API.Specs) - Invalid credentials and 404 error responses (via API.Specs)
**Features.Breweries.Tests**: **Features.Breweries.Tests**:
@@ -173,7 +172,7 @@ npm run test:storybook:playwright
### xUnit ### xUnit
- Primary unit testing framework - Primary unit testing framework
- Used for Repository and Service layer tests - Used for handler and service layer tests
- Supports parallel test execution - Supports parallel test execution
### Reqnroll (Gherkin/BDD) ### Reqnroll (Gherkin/BDD)
@@ -194,12 +193,6 @@ npm run test:storybook:playwright
- Used in Service layer tests - Used in Service layer tests
- Enables isolated unit testing - Enables isolated unit testing
### DbMocker
- Database mocking for repository tests
- Simulates SQL Server responses
- No real database required for unit tests
## Test structure ## Test structure
### API.Specs (integration tests) ### API.Specs (integration tests)
@@ -248,9 +241,7 @@ Features.Auth.Tests/
│ ├── ResendConfirmationEmailHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs
│ └── RefreshTokenHandlerTests.cs │ └── RefreshTokenHandlerTests.cs
├── Queries/ ├── Queries/
│ └── LoginHandlerTests.cs │ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
├── Repository/
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
└── Services/ └── Services/
├── TokenServiceRefreshTests.cs ├── TokenServiceRefreshTests.cs
└── TokenServiceValidationTests.cs └── TokenServiceValidationTests.cs
@@ -278,7 +269,7 @@ public class LoginHandlerTests
// ...set up mocks for a known user/credential... // ...set up mocks for a known user/credential...
// Act // Act
var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None); var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();