From 55e3d8d632db8d124063212c0698501258c033d4 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 20 Aug 2026 22:51:27 -0400 Subject: [PATCH] update stale docs --- docs/architecture.md | 45 ++-- docs/website/database.md | 27 +- docs/website/diagrams-out/architecture.svg | 2 +- .../diagrams-out/authentication-flow.svg | 2 +- docs/website/diagrams-out/database-schema.svg | 2 +- docs/website/diagrams-out/deployment.svg | 2 +- docs/website/diagrams-src/architecture.puml | 41 ++- .../diagrams-src/authentication-flow.puml | 247 ++++++++++-------- .../website/diagrams-src/database-schema.puml | 22 +- docs/website/diagrams-src/deployment.puml | 8 +- docs/website/testing.md | 27 +- 11 files changed, 240 insertions(+), 185 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index dc388693..330e7a5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,6 @@ MediatR/DI, never over HTTP: ↓ ┌─────────────────────────────────────┐ │ Database (SQL Server) │ -│ - Stored procedures │ │ - Tables & constraints │ └─────────────────────────────────────┘ ``` @@ -121,7 +120,7 @@ own - `Commands//` and `Queries//`: 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 +- `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 @@ -208,6 +207,11 @@ whichever slices need them - `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**: @@ -301,15 +305,16 @@ it, without an ORM's change-tracking or mapping magic **Strategy**: -- No ORM (Entity Framework not used); each - repository issues inline SQL (a mix of Dapper and raw - `DbCommand`/`DbDataReader` for the few reads that need manual column mapping, - such as the `GEOGRAPHY` column on `BreweryPostLocation`) +- 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 `Timer` (`ROWVERSION`) column, - checked in the `UPDATE ... WHERE ... AND Timer = @Timer` clause +- 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 @@ -436,23 +441,22 @@ for reference only. Active product and engineering documentation should point to ## Database architecture -For the table-by-table schema, the stored procedure reference (parameters, -behavior, and exactly what each one throws), and the custom SQL error code -scheme, see [Database](website/database.md). +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 @@ -488,14 +492,15 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md)) **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) - Brewery posts with locations (via `Features.Breweries`' repository) -Tables that exist in the schema but have no corresponding `Domain.Entities` type -or feature slice yet (`Photo`, `UserAvatar`, `UserFollow`, `Country`, -`StateProvince`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, -`BreweryPostPhoto`) are not seeded. +Tables that exist in the schema but have no repository yet (`Photo`, +`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, +`BeerPostComment`, `BreweryPostPhoto`) are not seeded. ## Deployment architecture @@ -543,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) └──────────────┘ ``` diff --git a/docs/website/database.md b/docs/website/database.md index 2b93f692..dbb28d24 100644 --- a/docs/website/database.md +++ b/docs/website/database.md @@ -13,8 +13,8 @@ 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 `Timer ROWVERSION` column used for optimistic concurrency on -updates (`UPDATE ... WHERE ... AND Timer = @Timer`). +most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on +updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`). ### 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 | | `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 manually via `DbDataReader.GetFieldValue` because Dapper can't deserialize SQL Server UDTs | -| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; looked up via `ILocationRepository`, currently consumed only by `Database.Seed` | +| `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 -`Domain.Entities` type, repository, or seed data yet: +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) | -| `StateProvince` | Referenced by `City.StateProvinceID` | -| `Country` | Referenced by `StateProvince.CountryID` | -| `BeerStyle` | A beer style/category | -| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`) | +| `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` | @@ -58,14 +58,15 @@ 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)): cities, user accounts, -and brewery posts with locations. It only seeds tables that have a feature slice -to write through (see the table above). +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 `Timer` column. + 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, diff --git a/docs/website/diagrams-out/architecture.svg b/docs/website/diagrams-out/architecture.svg index 028ae98d..b92d9990 100644 --- a/docs/website/diagrams-out/architecture.svg +++ b/docs/website/diagrams-out/architecture.svg @@ -1 +1 @@ -The Biergarten App - Vertical Slice ArchitectureThe Biergarten App - Vertical Slice ArchitectureAPI.Core (thin host)Feature SlicesSharedInfrastructure LayerDomain LayerSQL ServerAPI.CoreASP.NET Core Web API host- Program.cs wiring only- MediatR + AddApplicationPartper Features.* assembly- Swagger/OpenAPI, health checks- JWT auth middleware- Global exception filterFeatures.AuthFeatures.BreweriesFeatures.UserManagementFeatures.EmailsEach slice owns its own:- Controller (HTTP endpoints)- Commands/Queries + Handlers- Validators- RepositoryNo controller - invoked onlyvia MediatR commands sentfrom other slicesShared.ContractsShared.Application- ValidationBehavior(MediatR pipeline)- Cross-slice email commands(SendRegistrationEmailCommand,SendResendConfirmationEmailCommand)Infrastructure.SqlInfrastructure.JwtInfrastructure.PasswordHashingInfrastructure.EmailInfrastructure.Email.TemplatesDomain.EntitiesDomain.Exceptions- UserAccount- UserCredential- UserVerification- BreweryPostStored ProceduresTablesSQL-first approachAll queries viastored proceduresNo Features.* projectever references anotherFeatures.* project directlyAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanMediatR scan only(no controller)sendsSendRegistrationEmailCommandhandlesSendRegistrationEmailCommand \ No newline at end of file +The Biergarten App - Vertical Slice ArchitectureThe Biergarten App - Vertical Slice ArchitectureAPI.Core (thin host)Feature SlicesSharedInfrastructure LayerDomain LayerSQL ServerAPI.CoreASP.NET Core Web API host- Program.cs wiring only- MediatR + AddApplicationPartper Features.* assembly- Swagger/OpenAPI, health checks- JWT auth middleware- Global exception filterFeatures.AuthFeatures.BreweriesFeatures.UserManagementFeatures.EmailsFeatures.LocationsEach slice owns its own:- Controller (HTTP endpoints)- Commands/Queries + Handlers- Validators- Repository (Dapper)No controller - invoked onlyvia MediatR commands sentfrom other slicesNo controller and no MediatRhandlers - exposes ILocationRepositorydirectly as a plain DI service.Not registered by API.Core at all;only Database.Seed depends on it todayShared.ContractsShared.Application- ValidationBehavior(MediatR pipeline)- Cross-slice email commands(SendRegistrationEmailCommand,SendResendConfirmationEmailCommand)Infrastructure.SqlInfrastructure.JwtInfrastructure.PasswordHashingInfrastructure.EmailInfrastructure.Email.TemplatesDomain.EntitiesDomain.Exceptions- UserAccount, UserCredential, UserVerification- BreweryPost, BreweryPostLocation- City, StateProvince, Country- BeerPost, BeerStyle(schema tables exist; no repository yet)TablesDatabase.Seed(separate console app)SQL-first approachEach slice's own repository issuesinline SQL via Dapper - no ORM,no stored proceduresNo Features.* projectever references anotherFeatures.* project directlyAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanMediatR scan only(no controller)AddFeaturesAuth()(DI only, no API/MediatR)AddFeaturesBreweries()AddFeaturesLocations()sendsSendRegistrationEmailCommandhandlesSendRegistrationEmailCommandDapper SQLDapper SQLDapper SQLDapper SQL \ No newline at end of file diff --git a/docs/website/diagrams-out/authentication-flow.svg b/docs/website/diagrams-out/authentication-flow.svg index 178d020f..724bf07b 100644 --- a/docs/website/diagrams-out/authentication-flow.svg +++ b/docs/website/diagrams-out/authentication-flow.svg @@ -1 +1 @@ -User Authentication Flow - ExpandedUser Authentication Flow - ExpandedService LayerInfrastructure LayerRepository LayerAPIAPIRegisterServiceLoginServiceTokenServiceTokenServiceTokenServiceTokenServiceEmailServiceArgon2Argon2JWTJWTJWTJWTEmailTemplateAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositorySQL ServerSQL ServerSQL ServerSQL ServerSQL ServerUserAPIRegisterServiceLoginServiceTokenServiceEmailServiceArgon2JWTEmailTemplateAuthRepositoryUserAccountSQL ServerUserUserAPIControllerAPIControllerRegisterServiceRegisterServiceLoginServiceLoginServiceTokenServiceTokenServiceEmailServiceEmailServiceArgon2InfrastructureArgon2InfrastructureJWTInfrastructureJWTInfrastructureEmailProviderEmailProviderTemplateProviderTemplateProviderAuthRepositoryAuthRepositoryUserAccountRepositoryUserAccountRepositorySQL ServerStored ProceduresSQL ServerStored ProceduresAPIAPIRegisterServiceLoginServiceTokenServiceTokenServiceTokenServiceTokenServiceEmailServiceArgon2Argon2JWTJWTJWTJWTEmailTemplateAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositorySQL ServerSQL ServerSQL ServerSQL ServerSQL ServerRegistration FlowPOST /api/auth/register{username, firstName, lastName,email, dateOfBirth, password}FluentValidation runs:- 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 oldValidate request(FluentValidation)alt[Validation fails]400 Bad Request{errors: {...}}[Validation succeeds]RegisterAsync(userAccount,password)GetUserByUsernameAsync(username)EXECusp_GetUserAccountByUsernamenull (user doesn't exist)GetUserByEmailAsync(email)EXECusp_GetUserAccountByEmailnull (email doesn't exist)alt[User/Email already exists]throw ConflictException409 Conflict"Username or email alreadyexists"[User doesn't exist]Hash(password)Argon2id parameters:- Salt: 16 bytes (128-bit)- Memory: 64MB- Iterations: 4- Parallelism: CPU count- Hash output: 32 bytesGenerate random salt(16 bytes)Hash password withArgon2id algorithm"base64(salt):base64(hash)"RegisterUserAsync(  username, firstName, lastName,  email, dateOfBirth, hash)EXEC USP_RegisterUserTransaction begins:1. INSERT UserAccount2. INSERT UserCredential(with hashed password)Transaction commitsBEGIN TRANSACTIONINSERT INTO UserAccount(Username, FirstName,LastName,Email, DateOfBirth)OUTPUTINSERTED.UserAccountIDINSERT INTO UserCredential(UserAccountId, Hash)COMMIT TRANSACTIONUserAccountId (GUID)UserAccount entityGenerateAccessToken(userAccount)GenerateJwt(userId, username,expiry)JWT Configuration:- Algorithm: HS256- Expires: 1 hour- Claims:* sub: userId* unique_name: username* jti: unique token IDCreate JWT with claimsSign with secret keyAccess TokenAccess TokenGenerateRefreshToken(userAccount)GenerateJwt(userId, username,expiry)Refresh Token:- Expires: 21 days- Same structure as access tokenRefresh TokenRefresh TokenSendRegistrationEmailAsync(  createdUser, confirmationToken)RenderUserRegisteredEmailAsync(  firstName, confirmationLink)Razor Component:- Header with branding- Welcome message- Confirmation button- FooterRender Razor componentto HTMLHTML email contentSendAsync(email, subject, body)SMTP Configuration:- Host: from env (SMTP_HOST)- Port: from env (SMTP_PORT)- TLS: StartTLS- Auth: username/passwordCreate MIME messageConnect to SMTP serverAuthenticateSend emailDisconnectSuccess / Failurealt[Email sent successfully]emailSent = true[Email failed]emailSent = false(error suppressed)RegisterServiceReturn(  userAccount, accessToken,  refreshToken, emailSent)Create response body201 Created{  message: "User registeredsuccessfully",  payload: {    userAccountId, username,    accessToken, refreshToken,    confirmationEmailSent  }}Login FlowPOST /api/auth/login{username, password}Validate request(FluentValidation)alt[Validation fails]400 Bad Request{errors: {...}}[Validation succeeds]LoginAsync(username, password)GetUserByUsernameAsync(username)EXECusp_GetUserAccountByUsernameSELECT FROM UserAccountWHERE Username = @UsernameUserAccount entityalt[User not found]throw UnauthorizedException"Invalid username or password"401 Unauthorized[User found]GetActiveCredentialByUserAccountIdAsync(userId)EXECUSP_GetActiveUserCredentialByUserAccountIdSELECT FROM UserCredentialWHERE UserAccountId = @UserAccountIdAND IsRevoked = 0UserCredential entityalt[No active credential]throw UnauthorizedException401 Unauthorized[Active credential found]Verify(password, storedHash)1. Split stored hash: "salt:hash"2. Extract salt3. Hash provided password\n with same salt4. Constant-time comparisonParse salt from stored hashHash provided passwordwith extracted saltFixedTimeEquals(  computed, stored)true/falsealt[Password invalid]throw UnauthorizedException401 Unauthorized[Password valid]GenerateAccessToken(user)GenerateJwt(...)Access TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(...)Refresh TokenRefresh TokenLoginServiceReturn(  userAccount, accessToken,  refreshToken)200 OK{  message: "Logged insuccessfully",  payload: {    userAccountId, username,    accessToken, refreshToken  }}Error Handling (Global Exception Filter)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 \ No newline at end of file +User Authentication Flow - ExpandedUser Authentication Flow - ExpandedHandler LayerInfrastructure LayerRepository / Cross-sliceAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2Argon2JWTJWTJWTJWTJWTEmailTemplateAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerUserAPIMediatR pipelineRegisterUserHandlerLoginHandlerTokenServiceArgon2JWTEmailTemplateAuthRepositoryEmailDispatcherSQL ServerUserUserAPIControllerAPIControllerMediatR pipeline(ValidationBehavior)MediatR pipeline(ValidationBehavior)RegisterUserHandlerRegisterUserHandlerLoginHandlerLoginHandlerTokenServiceTokenServiceArgon2InfrastructureArgon2InfrastructureJWTInfrastructureJWTInfrastructureEmailProviderEmailProviderTemplateProviderTemplateProviderAuthRepositoryAuthRepositoryEmailDispatcher(Features.Emails)EmailDispatcher(Features.Emails)SQL ServerSQL ServerAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2Argon2JWTJWTJWTJWTJWTEmailTemplateAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerRegistration FlowPOST /api/auth/register{username, firstName, lastName,email, dateOfBirth, password}Send(RegisterUserCommand)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 oldalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)GetUserByUsernameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username = @Usernamenull (user doesn't exist)GetUserByEmailAsync(email)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Email = @Emailnull (email doesn't exist)alt[Username or email already exists]throw ConflictExceptionpropagate409 Conflict"Username or email alreadyexists"[Username and email are free]Hash(password)Argon2id parameters:- Salt: 16 bytes (128-bit)- Memory: 64MB- Iterations: 4- Parallelism: CPU count- Hash output: 32 bytesGenerate random salt(16 bytes)Hash password withArgon2id algorithm"base64(salt):base64(hash)"RegisterUserAsync(userAccount)BEGIN TRANSACTIONINSERT INTO dbo.UserAccount(Username, FirstName,LastName,DateOfBirth, Email)OUTPUTINSERTED.UserAccountIDUserAccountId (GUID)INSERT INTO dbo.UserCredential(UserAccountId, Hash)COMMIT TRANSACTIONAll three statements run via Dapper'sExecuteScalarAsync/ExecuteAsync againsta single DbTransaction, not a storedprocedure - no manual parameter bindingSELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE UserAccountID =@UserAccountIdRound trip to load the row theINSERT just createdUserAccount rowUserAccount entityGenerateAccessToken(user)GenerateJwt(userId, username,expiry)JWT Configuration:- Algorithm: HS256- Expires: 1 hour- Claims:* sub: userId* unique_name: username* iat / jtiCreate JWT with claimsSign with secret keyAccess TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(userId, username,expiry)Refresh Token:- Expires: 21 days (504 hours)- Same structure as access token,\n signed with a separate secretRefresh TokenRefresh TokenGenerateConfirmationToken(user)GenerateJwt(userId, username,expiry)Confirmation Token:- Expires: 30 minutes (0.5 hours)- Signed with a third, separate secretConfirmation TokenConfirmation TokenSend(SendRegistrationEmailCommand)(Shared.Application cross-slicecontract)SendRegistrationEmailAsync(  firstName, email,confirmationToken)Routed to Features.Emails'SendRegistrationEmailHandler,the only cross-slice call inthe backendRenderUserRegisteredEmailAsync(  firstName, confirmationLink)Razor Component:- Header with branding- Welcome message- Confirmation button- FooterRender Razor componentto HTMLHTML email contentSendAsync(email, subject, body)SMTP Configuration (MailKit):- Host/port: from env (SMTP_HOST/SMTP_PORT)- TLS: StartTLS when SMTP_USE_SSL=true- Auth: username/password, if configuredBuild MIME messageConnect, authenticate,send, disconnectSuccess / throwsalt[Email sent successfully]emailSent = true[mediator.Send throws]catch(Exception),log to stderr,emailSent = falseRegistrationPayload(  userAccountId, username,  refreshToken, accessToken,  confirmationEmailSent)RegistrationPayload201 Created{  message: "User registeredsuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken,    confirmationEmailSent  }}Login FlowPOST /api/auth/login{username, password}Send(LoginCommand)ValidationBehavior runs LoginValidator:username and password must both be non-emptyalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)GetUserByUsernameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username = @UsernameUserAccount row or nullalt[User not found]throw UnauthorizedException"Invalid username or password."propagate401 Unauthorized[User found]GetActiveCredentialByUserAccountIdAsync(userId)SELECT UserCredentialId,UserAccountId,Hash, CreatedAt, RowVersionFROM dbo.UserCredentialWHERE UserAccountId =@UserAccountId  AND IsRevoked = 0UserCredential row or nullalt[No active credential]throw UnauthorizedExceptionpropagate401 Unauthorized[Active credential found]Verify(password, storedHash)1. Split stored hash: "salt:hash"2. Extract salt3. Hash provided password\n with same salt4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)Parse salt from stored hashHash provided passwordwith extracted saltFixedTimeEquals(  computed, stored)true/falsealt[Password invalid]throw UnauthorizedExceptionpropagate401 Unauthorized[Password valid]GenerateAccessToken(user)GenerateJwt(...)Access TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(...)Refresh TokenRefresh TokenLoginPayload(  userAccountId, username,  refreshToken, accessToken)LoginPayload200 OK{  message: "Logged insuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken  }}Error Handling (Global Exception Filter)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 \ No newline at end of file diff --git a/docs/website/diagrams-out/database-schema.svg b/docs/website/diagrams-out/database-schema.svg index 1e8c49a0..d5acb9ae 100644 --- a/docs/website/diagrams-out/database-schema.svg +++ b/docs/website/diagrams-out/database-schema.svg @@ -1 +1 @@ -Database SchemaUser & AuthLocationBreweryBeer (*)UserAccountUserAccountID: UNIQUEIDENTIFIER «PK»Username: VARCHAR(64) «UNIQUE»FirstName: NVARCHAR(128)LastName: NVARCHAR(128)Email: VARCHAR(128) «UNIQUE»CreatedAt: DATETIMEUpdatedAt: DATETIMEDateOfBirth: DATEUserCredentialUserCredentialID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEExpiry: DATETIMEHash: NVARCHAR(256)IsRevoked: BITRevokedAt: DATETIMEUserVerificationUserVerificationID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»VerificationDateTime: DATETIMEUserAvatar (*)UserAvatarID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»PhotoID: UNIQUEIDENTIFIER «FK»UserFollow (*)UserFollowID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»FollowingID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEPhoto (*)PhotoID: UNIQUEIDENTIFIER «PK»Hyperlink: NVARCHAR(256)UploadedByID: UNIQUEIDENTIFIER «FK»UploadedAt: DATETIMECountryCountryID: UNIQUEIDENTIFIER «PK»CountryName: NVARCHAR(100)ISO3166_1: CHAR(2) «UNIQUE»StateProvinceStateProvinceID: UNIQUEIDENTIFIER «PK»StateProvinceName: NVARCHAR(100)ISO3166_2: CHAR(6) «UNIQUE»CountryID: UNIQUEIDENTIFIER «FK»CityCityID: UNIQUEIDENTIFIER «PK»CityName: NVARCHAR(100)StateProvinceID: UNIQUEIDENTIFIER «FK»BreweryPostBreweryPostID: UNIQUEIDENTIFIER «PK»BreweryName: NVARCHAR(256)PostedByID: UNIQUEIDENTIFIER «FK»Description: NVARCHAR(512)CreatedAt: DATETIMEUpdatedAt: DATETIMEBreweryPostLocationBreweryPostLocationID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK, UNIQUE»AddressLine1: NVARCHAR(256)AddressLine2: NVARCHAR(256)PostalCode: NVARCHAR(20)CityID: UNIQUEIDENTIFIER «FK»Coordinates: GEOGRAPHYBreweryPostPhoto (*)BreweryPostPhotoID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerStyle (*)BeerStyleID: UNIQUEIDENTIFIER «PK»StyleName: NVARCHAR(100) «UNIQUE»Description: NVARCHAR(MAX)BeerPost (*)BeerPostID: UNIQUEIDENTIFIER «PK»Name: NVARCHAR(100)Description: NVARCHAR(MAX)ABV: DECIMAL(4,2)IBU: INTPostedByID: UNIQUEIDENTIFIER «FK»BeerStyleID: UNIQUEIDENTIFIER «FK»BrewedByID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEUpdatedAt: DATETIMEBeerPostPhoto (*)BeerPostPhotoID: UNIQUEIDENTIFIER «PK»BeerPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerPostComment (*)BeerPostCommentID: UNIQUEIDENTIFIER «PK»Comment: NVARCHAR(250)BeerPostID: UNIQUEIDENTIFIER «FK»CommentedByID: UNIQUEIDENTIFIER «FK»Rating: INTCreatedAt: DATETIMEUpdatedAt: DATETIMECore stored procedures:- USP_RegisterUser- usp_UpdateUserAccount- usp_DeleteUserAccount- USP_RotateUserCredential- USP_CreateUserVerificationCore stored procedures:- USP_CreateBrewery- USP_UpdateBrewery- USP_DeleteBrewery(optimistic concurrency via Timer)hashashasrefers tofollows (UserAccountID)followed by (FollowingID)uploadedhashaspostedhaslocated inhaslinked topostedcategorizesbrewedhaslinked tohascommentedEvery table also has a Timer ROWVERSION column (optimistic concurrency), omitted below for brevity.Tables marked (*) have no CRUD stored procedures yet. \ No newline at end of file +Database SchemaDatabase SchemaUser & AuthLocationBreweryBeer (*)UserAccountUserAccountID: UNIQUEIDENTIFIER «PK»Username: VARCHAR(64) «UNIQUE»FirstName: NVARCHAR(128)LastName: NVARCHAR(128)Email: VARCHAR(128) «UNIQUE»CreatedAt: DATETIMEUpdatedAt: DATETIMEDateOfBirth: DATEUserCredentialUserCredentialID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEExpiry: DATETIMEHash: NVARCHAR(256)IsRevoked: BITRevokedAt: DATETIMEUserVerificationUserVerificationID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»VerificationDateTime: DATETIMEUserAvatar (*)UserAvatarID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»PhotoID: UNIQUEIDENTIFIER «FK»UserFollow (*)UserFollowID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»FollowingID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEPhoto (*)PhotoID: UNIQUEIDENTIFIER «PK»Hyperlink: NVARCHAR(256)UploadedByID: UNIQUEIDENTIFIER «FK»UploadedAt: DATETIMECountryCountryID: UNIQUEIDENTIFIER «PK»CountryName: NVARCHAR(100)ISO3166_1: CHAR(2) «UNIQUE»StateProvinceStateProvinceID: UNIQUEIDENTIFIER «PK»StateProvinceName: NVARCHAR(100)ISO3166_2: CHAR(6) «UNIQUE»CountryID: UNIQUEIDENTIFIER «FK»CityCityID: UNIQUEIDENTIFIER «PK»CityName: NVARCHAR(100)StateProvinceID: UNIQUEIDENTIFIER «FK»BreweryPostBreweryPostID: UNIQUEIDENTIFIER «PK»BreweryName: NVARCHAR(256)PostedByID: UNIQUEIDENTIFIER «FK»Description: NVARCHAR(512)CreatedAt: DATETIMEUpdatedAt: DATETIMEBreweryPostLocationBreweryPostLocationID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK, UNIQUE»AddressLine1: NVARCHAR(256)AddressLine2: NVARCHAR(256)PostalCode: NVARCHAR(20)CityID: UNIQUEIDENTIFIER «FK»Coordinates: GEOGRAPHYBreweryPostPhoto (*)BreweryPostPhotoID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerStyle (*)BeerStyleID: UNIQUEIDENTIFIER «PK»StyleName: NVARCHAR(100) «UNIQUE»Description: NVARCHAR(MAX)BeerPost (*)BeerPostID: UNIQUEIDENTIFIER «PK»Name: NVARCHAR(100)Description: NVARCHAR(MAX)ABV: DECIMAL(4,2)IBU: INTPostedByID: UNIQUEIDENTIFIER «FK»BeerStyleID: UNIQUEIDENTIFIER «FK»BrewedByID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEUpdatedAt: DATETIMEBeerPostPhoto (*)BeerPostPhotoID: UNIQUEIDENTIFIER «PK»BeerPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerPostComment (*)BeerPostCommentID: UNIQUEIDENTIFIER «PK»Comment: NVARCHAR(250)BeerPostID: UNIQUEIDENTIFIER «FK»CommentedByID: UNIQUEIDENTIFIER «FK»Rating: INTCreatedAt: DATETIMEUpdatedAt: DATETIMEBacked by Features.Auth/Features.UserManagement'sDapper repositories (AuthRepository,UserAccountRepository) - registration, credentialrotation, and verification each issue their owninline SQL, no stored proceduresBacked by Features.Breweries' BreweryRepository(Dapper); create/update/delete run inline SQLinside a transaction, with optimistic concurrencyvia RowVersionhashashasrefers tofollows (UserAccountID)followed by (FollowingID)uploadedhashaspostedhaslocated inhaslinked topostedcategorizesbrewedhaslinked tohascommentedEvery table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.Tables marked (*) have no repository/feature slice yet. \ No newline at end of file diff --git a/docs/website/diagrams-out/deployment.svg b/docs/website/diagrams-out/deployment.svg index 4c28a85e..4003a94d 100644 --- a/docs/website/diagrams-out/deployment.svg +++ b/docs/website/diagrams-out/deployment.svg @@ -1 +1 @@ -Docker Deployment ArchitectureDocker Deployment ArchitectureDevelopment Environment(docker-compose.dev.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)BiergartenDatabaseAPI Container(API.Core)Migrations(run-once)Seed(run-once)Test Environment(docker-compose.test.yaml)SQL Server(isolated instance)BiergartenTest DatabaseMigrations(test)Seed(test)API.Specs(Integration Tests)Infrastructure.Repository.Tests(Unit Tests)Service.Auth.Tests(Unit Tests)test-results/(mounted volume)Environment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Developer Volumes:- biergarten-dev-data1433ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Development- DB_SERVER=sql-server- DB_NAME=Biergarten- DB_USER/PASSWORD- JWT_SECRET- SMTP_* (10+ variables) Health Check:/health endpointDatabase.MigrationsRuns: DbUp migrationsEnvironment:- CLEAR_DATABASE=falseDepends on: sql-serverDatabase.SeedCreates:- 100 test users- Location data (US/CA/MX)- test.user accountDepends on: migrationsFresh instance each runCLEAR_DATABASE=true Volumes:- biergarten-test-data(ephemeral)1434Database.MigrationsDatabase.SeedMinimal seed:- test.user only- Essential dataReqnroll + xUnitTests:- Registration flow- Login flow- Validation rules- 404 handling Uses: TestApiFactoryMocks: Email servicesxUnit + DbMockerTests:- AuthRepository- UserAccountRepository- SQL command building Uses: Mock connectionsNo real database neededxUnit + MoqTests:- RegisterService- LoginService- Token generation Uses: Mocked dependenciesNo database or infrastructureapi-specs/results.trxrepository-tests/results.trxservice-auth-tests/results.trxTRX formatReadable by:- Visual Studio- Azure DevOps- GitHub ActionsDeveloperDocker HostDev Network (bridge: biergarten-dev)Internal DNS:- sql-server (resolves to SQL container)- api (resolves to API container)Test Network (bridge: biergarten-test)All test components isolatedStartup Order:1. SQL Server (health check)2. Migrations (run-once)3. Seed (run-once)4. API (long-running)Test Execution:All tests run in parallelResults aggregatedProduction Deployment (not shown): Would include:• Azure SQL Database / AWS RDS• Azure Container Apps / ECS• Azure Key Vault for secrets• Application Insights / CloudWatch• Load balancer• HTTPS termination• CDN for static assetsdocker compose uphttp://localhost:80801. Run migrationsdepends_on2. Seed data3. Connect & servedepends_ondepends_on1. Migratedepends_on2. Seed3. Integration testMock (no connection)Mock (no connection)depends_ondepends_onExport TRXExport TRXExport TRX \ No newline at end of file +Docker Deployment ArchitectureDocker Deployment ArchitectureDevelopment Environment(docker-compose.dev.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)BiergartenDatabaseAPI Container(API.Core)Migrations(run-once)Seed(run-once)Test Environment(docker-compose.test.yaml)SQL Server(isolated instance)BiergartenTest DatabaseMigrations(test)Seed(test)API.Specs(Integration Tests)unit.tests(Features.*.Tests, one container)test-results/(mounted volume)Environment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Developer Volumes:- biergarten-dev-data1433ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Development- DB_SERVER=sql-server- DB_NAME=Biergarten- DB_USER/PASSWORD- JWT_SECRET- SMTP_* (10+ variables) Health Check:/health endpointDatabase.MigrationsRuns: DbUp migrationsEnvironment:- CLEAR_DATABASE=falseDepends on: sql-serverDatabase.SeedCreates:- 100 test users- Location data (US/CA/MX)- test.user accountDepends on: migrationsFresh instance each runCLEAR_DATABASE=true Volumes:- biergarten-test-data(ephemeral)1434Database.MigrationsDatabase.SeedMinimal seed:- test.user only- Essential dataReqnroll + xUnitTests:- Registration flow- Login flow- Validation rules- 404 handling Uses: TestApiFactoryMocks: Email servicesxUnit + MoqBuilds Dockerfile.tests, whichrestores/builds against Core.slnxthen runs `dotnet test` for everyFeatures/*.Tests project in turn:- Features.Auth.Tests- Features.Breweries.Tests- Features.UserManagement.Tests- Features.Emails.Tests Uses: Moq to mock each handler'srepository/service dependenciesNo real database neededapi-specs/results.trxFeatures.Auth.Tests.trxFeatures.Breweries.Tests.trxFeatures.UserManagement.Tests.trxFeatures.Emails.Tests.trxTRX formatReadable by:- Visual Studio- Azure DevOps- GitHub ActionsDeveloperDocker HostDev Network (bridge: biergarten-dev)Internal DNS:- sql-server (resolves to SQL container)- api (resolves to API container)Test Network (bridge: biergarten-test)All test components isolatedStartup Order:1. SQL Server (health check)2. Migrations (run-once)3. Seed (run-once)4. API (long-running)Test Execution:All tests run in parallelResults aggregatedProduction Deployment (not shown): Would include:• Azure SQL Database / AWS RDS• Azure Container Apps / ECS• Azure Key Vault for secrets• Application Insights / CloudWatch• Load balancer• HTTPS termination• CDN for static assetsdocker compose uphttp://localhost:80801. Run migrationsdepends_on2. Seed data3. Connect & servedepends_ondepends_on1. Migratedepends_on2. Seed3. Integration testdepends_on for startupordering only (Moq mocks,no real connection)depends_ondepends_ondepends_onExport TRXExport TRX (one .trx per slice) \ No newline at end of file diff --git a/docs/website/diagrams-src/architecture.puml b/docs/website/diagrams-src/architecture.puml index 60f7c5ea..aa183798 100644 --- a/docs/website/diagrams-src/architecture.puml +++ b/docs/website/diagrams-src/architecture.puml @@ -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 diff --git a/docs/website/diagrams-src/authentication-flow.puml b/docs/website/diagrams-src/authentication-flow.puml index 25e6d4a9..72c8c25f 100644 --- a/docs/website/diagrams-src/authentication-flow.puml +++ b/docs/website/diagrams-src/authentication-flow.puml @@ -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 diff --git a/docs/website/diagrams-src/database-schema.puml b/docs/website/diagrams-src/database-schema.puml index 095bb307..d180e533 100644 --- a/docs/website/diagrams-src/database-schema.puml +++ b/docs/website/diagrams-src/database-schema.puml @@ -6,7 +6,7 @@ skinparam linetype ortho skinparam packageStyle rectangle 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" { entity "UserAccount" as User { @@ -190,20 +190,18 @@ Beer ||--o{ BeerComment : "has" User ||--o{ BeerComment : "commented" note bottom of User - Core stored procedures: - - USP_RegisterUser - - usp_UpdateUserAccount - - usp_DeleteUserAccount - - USP_RotateUserCredential - - USP_CreateUserVerification + 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 - Core stored procedures: - - USP_CreateBrewery - - USP_UpdateBrewery - - USP_DeleteBrewery - (optimistic concurrency via Timer) + Backed by Features.Breweries' BreweryRepository + (Dapper); create/update/delete run inline SQL + inside a transaction, with optimistic concurrency + via RowVersion end note @enduml diff --git a/docs/website/diagrams-src/deployment.puml b/docs/website/diagrams-src/deployment.puml index 970b9166..9437cc2d 100644 --- a/docs/website/diagrams-src/deployment.puml +++ b/docs/website/diagrams-src/deployment.puml @@ -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 diff --git a/docs/website/testing.md b/docs/website/testing.md index 3baddcda..9eee38a6 100644 --- a/docs/website/testing.md +++ b/docs/website/testing.md @@ -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.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 @@ -77,8 +77,8 @@ dotnet test API/API.Specs/API.Specs.csproj ### Feature slice unit tests -Each feature slice has its own test project, covering its command/query handlers -and repository: +Each feature slice has its own test project, covering its command/query +handlers: ```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();