diff --git a/README.md b/README.md index 116cfe8b..53d1a44a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The Biergarten App is a full-stack directory and discovery platform for breweries. It provides: - JWT-based user authentication, with email confirmation and refresh-token - rotation + reissue - A searchable database of brewery locations - An offline data-generation pipeline that uses large language models (LLMs) via Llama.cpp and Wikipedia to synthesize realistic seed data @@ -24,6 +24,7 @@ Website + backend (active stack): - [Getting Started](docs/website/getting-started.md) - [Architecture](docs/architecture.md) +- [Database](docs/website/database.md) - [Docker Guide](docs/website/docker.md) - [Testing](docs/website/testing.md) - [Environment Variables](docs/website/environment-variables.md) diff --git a/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetCountryIdByCode.sql b/archive/sql-stored-procs-functions/02-functions/UDF_GetCountryIdByCode.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetCountryIdByCode.sql rename to archive/sql-stored-procs-functions/02-functions/UDF_GetCountryIdByCode.sql diff --git a/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetStateProvinceIdByCode.sql b/archive/sql-stored-procs-functions/02-functions/UDF_GetStateProvinceIdByCode.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetStateProvinceIdByCode.sql rename to archive/sql-stored-procs-functions/02-functions/UDF_GetStateProvinceIdByCode.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql b/archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_CreateUserAccount.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql rename to archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_CreateUserAccount.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql b/archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_DeleteUserAccount.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql rename to archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_DeleteUserAccount.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql b/archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql rename to archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql b/archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql rename to archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql b/archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetUserAccountById.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql rename to archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetUserAccountById.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql b/archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql rename to archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql b/archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_UpdateUserAccount.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql rename to archive/sql-stored-procs-functions/03-crud/01-UserAccount/USP_UpdateUserAccount.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql b/archive/sql-stored-procs-functions/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql rename to archive/sql-stored-procs-functions/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql b/archive/sql-stored-procs-functions/03-crud/02-Auth/USP_InvalidateUserCredential.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql rename to archive/sql-stored-procs-functions/03-crud/02-Auth/USP_InvalidateUserCredential.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql b/archive/sql-stored-procs-functions/03-crud/02-Auth/USP_RegisterUser.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql rename to archive/sql-stored-procs-functions/03-crud/02-Auth/USP_RegisterUser.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql b/archive/sql-stored-procs-functions/03-crud/02-Auth/USP_RotateUserCredential.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql rename to archive/sql-stored-procs-functions/03-crud/02-Auth/USP_RotateUserCredential.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql b/archive/sql-stored-procs-functions/03-crud/03-UserVerification/USP_AddUserVerification.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql rename to archive/sql-stored-procs-functions/03-crud/03-UserVerification/USP_AddUserVerification.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCity.sql b/archive/sql-stored-procs-functions/03-crud/04-Location/USP_CreateCity.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCity.sql rename to archive/sql-stored-procs-functions/03-crud/04-Location/USP_CreateCity.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCountry.sql b/archive/sql-stored-procs-functions/03-crud/04-Location/USP_CreateCountry.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCountry.sql rename to archive/sql-stored-procs-functions/03-crud/04-Location/USP_CreateCountry.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateStateProvince.sql b/archive/sql-stored-procs-functions/03-crud/04-Location/USP_CreateStateProvince.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateStateProvince.sql rename to archive/sql-stored-procs-functions/03-crud/04-Location/USP_CreateStateProvince.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql b/archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_CreateBrewery.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql rename to archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_CreateBrewery.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_DeleteBrewery.sql b/archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_DeleteBrewery.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_DeleteBrewery.sql rename to archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_DeleteBrewery.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetAllBreweries.sql b/archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_GetAllBreweries.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetAllBreweries.sql rename to archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_GetAllBreweries.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql b/archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_GetBreweryById.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql rename to archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_GetBreweryById.sql diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_UpdateBrewery.sql b/archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_UpdateBrewery.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_UpdateBrewery.sql rename to archive/sql-stored-procs-functions/03-crud/05-Breweries/USP_UpdateBrewery.sql diff --git a/docs/architecture.md b/docs/architecture.md index f1847776..330e7a5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,8 +13,8 @@ active website: - **Architecture Style**: Vertical-slice backend plus server-rendered React frontend -The legacy Next.js frontend has been retained in `archive/next-js-web-app/` -for reference only. +The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for +reference only. ## Diagrams @@ -35,9 +35,11 @@ For visual representations, see: The backend organizes business capabilities as feature slices instead of technical layers. Each feature (`Features.Auth`, `Features.Breweries`, -`Features.UserManagement`, `Features.Emails`) is a single project that owns -its own controller, MediatR commands/queries/handlers, validators, and -repository, end to end: +`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single +project that owns its own MediatR commands/queries/handlers, validators, and +repository end to end. `Features.Emails` and `Features.Locations` have no +`Controllers/` folder — both are invoked internally by other slices via +MediatR/DI, never over HTTP: ``` ┌───────────────────────────────────────────────────────────────────────┐ @@ -46,15 +48,15 @@ repository, end to end: │ - Swagger/OpenAPI, JWT auth middleware, global exception filter │ └───────────────────────────────────────────────────────────────────────┘ ↓ discovers controllers via AddApplicationPart -┌───────────────┬───────────────┬───────────────────┬───────────────────┐ -│ Features.Auth │Features. │ Features. │ Features.Emails │ -│ │Breweries │ UserManagement │ (no controller, │ -│ Controller │ Controller │ Controller │ internal only) │ -│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ -│ Queries + │ Queries + │ Queries + │ Handlers │ -│ Handlers │ Handlers │ Handlers │ │ -│ Repository │ Repository │ Repository │ EmailDispatcher │ -└───────────────┴───────────────┴───────────────────┴───────────────────┘ +┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐ +│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ +│ │Breweries │ UserManagement │ (no controller, │ Locations │ +│ Controller │ Controller │ Controller │ internal only) │ (no controller, │ +│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ +│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ +│ Handlers │ Handlers │ Handlers │ │ │ +│ Repository │ Repository │ Repository │ EmailDispatcher │ │ +└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘ ↓ each slice depends only on shared/domain/infra, never on another slice ┌─────────────────────────┬─────────────────────────┬────────────────────┐ │ Shared.Contracts │ Shared.Application │ Domain.Entities / │ @@ -69,23 +71,22 @@ repository, end to end: ↓ ┌─────────────────────────────────────┐ │ Database (SQL Server) │ -│ - Stored procedures │ │ - Tables & constraints │ └─────────────────────────────────────┘ ``` -Slices never reference each other's project. The one cross-slice -interaction (`Features.Auth` triggering a confirmation email handled by -`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`) -whose contract lives in `Shared.Application`, so neither slice takes a -project reference on the other. +Slices never reference each other's project. The one cross-slice interaction +(`Features.Auth` triggering a confirmation email handled by `Features.Emails`) +goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract +lives in `Shared.Application`, so neither slice takes a project reference on the +other. ### Layer responsibilities #### API layer (`API.Core`) -**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of -its own +**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its +own **Components**: @@ -108,32 +109,36 @@ its own - No controllers, no business logic, no feature-specific contracts - Exists purely to host and wire up the feature slices -#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`) +#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`) **Purpose**: Each slice is the complete vertical for one business capability **Components** (per slice): -- `Controllers/`: HTTP endpoints, binding directly to Command/Query types - as the request contract -- `Commands//` 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 -- `Dtos/`: response shapes returned by query handlers (never the raw - domain entity) -- `DependencyInjection/`: an `AddFeaturesX()` extension method registering - the slice's repository/services +- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the + request contract +- `Commands//` 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 Dapper repository implementation +- `Dtos/`: response shapes returned by query handlers (never the raw domain + entity) +- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the + slice's repository/services -`Features.Emails` has no `Controllers/` folder. It's invoked only via -MediatR commands sent from other slices, never over HTTP. +`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR +commands sent from other slices, never over HTTP. `Features.Locations` also has +no `Controllers/` folder and no MediatR handlers — it exposes +`ILocationRepository` directly as a plain service, currently consumed only by +`Database.Seed` to look up cities while seeding brewery locations. Other slices +(for example, `Features.Breweries`) do not yet depend on it; they run their own +`CityID` existence checks inline. **Dependencies**: - `Domain.Entities`, `Domain.Exceptions` -- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever - infrastructure project the slice needs (`Infrastructure.Jwt`/ +- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure + project the slice needs (`Infrastructure.Jwt`/ `Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/ `Infrastructure.Email.Templates` for Emails) - `Shared.Contracts`, `Shared.Application` @@ -142,29 +147,27 @@ MediatR commands sent from other slices, never over HTTP. **Rules**: - All business logic for that feature lives in its command/query handlers -- No direct controller-to-repository calls; everything flows through - MediatR +- No direct controller-to-repository calls; everything flows through MediatR - Read endpoints return a dedicated `Dto`, never the domain entity directly #### Shared projects (`Shared.Contracts`, `Shared.Application`) -**Purpose**: The minimum cross-slice surface area required because every -slice needs it, or because duplicating it four times would be worse than -sharing it +**Purpose**: The minimum cross-slice surface area required because every slice +needs it, or because duplicating it four times would be worse than sharing it **Components**: - `Shared.Contracts`: `ResponseBody`/`ResponseBody`, the API response envelope every controller returns -- `Shared.Application`: `ValidationBehavior` (the - MediatR pipeline behavior that runs FluentValidation before a handler - executes) and the cross-slice email commands - (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) +- `Shared.Application`: `ValidationBehavior` (the MediatR + pipeline behavior that runs FluentValidation before a handler executes) and + the cross-slice email commands (`SendRegistrationEmailCommand`, + `SendResendConfirmationEmailCommand`) **Rules**: -- Kept deliberately small: this is the exception to "no slice depends on - another slice," not a general-purpose dumping ground +- Kept deliberately small: this is the exception to "no slice depends on another + slice," not a general-purpose dumping ground #### Infrastructure layer @@ -201,6 +204,14 @@ whichever slices need them - `UserAccount` - User profile data - `UserCredential` - Authentication credentials - `UserVerification` - Account verification state +- `BreweryPost` - A user-submitted brewery listing +- `BreweryPostLocation` - A brewery's address and coordinates +- `City` - A city referenced by a brewery location +- `StateProvince` - A state/province referenced by a city +- `Country` - A country referenced by a state/province +- `BeerStyle` - A beer style/category (schema table exists; no repository yet) +- `BeerPost` - A beer listing tied to a brewery and style (schema table + exists; no repository yet) **Dependencies**: @@ -218,16 +229,15 @@ whichever slices need them #### Vertical slice + MediatR **Purpose**: Organize code by feature instead of by technical layer, so -everything needed to understand or change one capability lives in one -project +everything needed to understand or change one capability lives in one project **Implementation**: - Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand` in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a `Query` (for example, `GetBreweryByIdQuery`) -- Controllers bind directly to the Command/Query as the request body; - there is no separate request DTO + mapping step for writes +- Controllers bind directly to the Command/Query as the request body; there is + no separate request DTO + mapping step for writes - A single shared `ValidationBehavior` (`Shared.Application/Behaviors/`) runs FluentValidation validators in the MediatR pipeline before any handler executes @@ -250,19 +260,18 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl **Purpose**: Abstract database access behind interfaces -**Implementation**: each slice owns its own repository, scoped to that -feature only: +**Implementation**: each slice owns its own repository, scoped to that feature +only: - `Features.Auth/Repository/IAuthRepository.cs` - `Features.Breweries/Repository/IBreweryRepository.cs` - `Features.UserManagement/Repository/IUserAccountRepository.cs` -- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic - connection factory every slice's repository builds on +- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection + factory every slice's repository builds on **Benefits**: - Testable (easy to mock) -- SQL-first approach (stored procedures) - Each slice's data access logic is self-contained **Example**: @@ -279,9 +288,9 @@ public interface IAuthRepository **Purpose**: Loose coupling and testability -**Configuration**: `Program.cs` wires up MediatR/FluentValidation across -every `Features.*` assembly; each slice exposes its own `AddFeaturesX()` -extension method that registers its repository and slice-internal services +**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every +`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension +method that registers its repository and slice-internal services **Lifetimes**: @@ -289,22 +298,32 @@ extension method that registers its repository and slice-internal services - Singleton: `ISqlConnectionFactory` - Transient: Utilities, helpers -#### SQL-first approach +#### Direct SQL via Dapper/ADO.NET -**Purpose**: Push complex logic into the database +**Purpose**: Keep data access explicit and colocated with the slice that owns +it, without an ORM's change-tracking or mapping magic **Strategy**: -- All queries via stored procedures -- No ORM (Entity Framework not used) -- Database handles complex logic -- Application focuses on orchestration +- No ORM (Entity Framework not used); each repository issues inline SQL + through Dapper, with no manual `IDbCommand` parameter binding. The one + wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper + can't deserialize as a UDT: reads select `CONVERT(varbinary(max), + Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind +- Referential checks that a stored procedure used to perform (e.g. "does this + `CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the + repository method, run inside the same transaction as the write +- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column, + checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause +- Repositories throw `Domain.Exceptions` types (`NotFoundException`, + `ConflictException`, ...) directly when a check fails, rather than relying on + a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those + exception types to HTTP status codes +- Application focuses on orchestration; the database enforces integrity via + keys, `CHECK` constraints, and cascades -**Stored Procedure Examples**: - -- `USP_RegisterUser` - User registration -- `USP_GetUserAccountByUsername` - User lookup -- `USP_RotateUserCredential` - Password update +See [Database](website/database.md) for the schema and the app's current SQL +error-handling approach in more detail. ## Frontend architecture @@ -351,8 +370,8 @@ All component styling should prefer semantic tokens such as `primary`, ### Legacy frontend The previous Next.js frontend has been archived at `archive/next-js-web-app/` -for reference only. Active product and engineering documentation should point -to `web/frontend`. +for reference only. Active product and engineering documentation should point to +`web/frontend`. ## Security architecture @@ -361,18 +380,29 @@ to `web/frontend`. 1. **Registration**: - User submits credentials - Password hashed with Argon2id - - User account created - - JWT token issued + - User account created (unverified) and a confirmation email is dispatched + via `Features.Emails` -2. **Login**: +2. **Email confirmation**: + - User follows the confirmation link/token from the email + - `Features.Auth`'s `ConfirmUser` command marks the account verified + +3. **Login**: - User submits credentials - Password verified against hash - - JWT token issued - - Token stored client-side + - Access and refresh JWTs are issued; the refresh token is stored server-side + against the user's credential row -3. **API Requests**: - - Client sends JWT in Authorization header - - Middleware validates token +4. **Token refresh**: + - Client exchanges a valid, unexpired refresh token for a new access/refresh + pair via `Features.Auth`'s `RefreshToken` command + - Refresh tokens are stateless JWTs, not tracked server-side: the previous + refresh token is not invalidated and remains usable until its own + expiration + +5. **API Requests**: + - Client sends the access JWT in the `Authorization` header + - `JwtAuthenticationHandler` validates the token - Request proceeds if valid ### Password security @@ -411,19 +441,22 @@ to `web/frontend`. ## Database architecture +For the table-by-table schema and the custom SQL error code scheme, see +[Database](website/database.md). + ### SQL-first philosophy **Principles**: 1. Database is source of truth -2. Complex queries in stored procedures +2. Queries live in the owning repository, not scattered across the app 3. Database handles referential integrity 4. Application orchestrates, database executes **Benefits**: - Performance optimization via execution plans -- Centralized query logic +- Query logic scoped to the repository that owns it, not spread across layers - Version-controlled schema (migrations) - Easier query profiling and tuning @@ -433,32 +466,41 @@ to `web/frontend`. **Process**: -1. Write SQL migration script -2. Embed in `Database.Migrations` project -3. Run migrations on startup -4. Idempotent and versioned +1. Write a SQL script under `Database.Migrations/scripts/` +2. Embed it in the `Database.Migrations` project +3. `Database.Migrations` runs each script against the target database on + startup, tracked so a script never re-runs once applied **Migration Files**: ``` scripts/ -├── 001-CreateUserTables.sql -├── 002-CreateLocationTables.sql -├── 003-CreateBreweryTables.sql -└── ... +└── 01-schema/ + └── schema.sql # full table/index/constraint definitions ``` +Currently the whole schema is a single versioned script. As the schema evolves, +new numbered scripts get added alongside it rather than editing `schema.sql` in +place, so DbUp's already-applied tracking stays valid. + ### Data seeding -**Purpose**: Populate development/test databases +**Purpose**: Populate development/test databases with realistic data -**Implementation**: `Database.Seed` project +**Implementation**: `Database.Seed` project, using data produced by the C++ +pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md)) **Seed Data**: -- Countries, states/provinces, cities -- Test user accounts -- Sample breweries (future) +- Countries, state/provinces, and cities (via `Features.Locations`' + `ILocationRepository`, which creates `Country`/`StateProvince` rows on + demand while resolving a city) +- User accounts (via `Features.Auth`'s repository) +- Brewery posts with locations (via `Features.Breweries`' repository) + +Tables that exist in the schema but have no repository yet (`Photo`, +`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, +`BeerPostComment`, `BreweryPostPhoto`) are not seeded. ## Deployment architecture @@ -470,6 +512,7 @@ scripts/ - `database.migrations` - Schema migration runner - `database.seed` - Data seeder - `api.core` - ASP.NET Core Web API +- `mailpit` - Local dev/test SMTP server + web UI (not used in production) **Environments**: @@ -505,8 +548,8 @@ healthcheck: ├──────────────┤ │ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests, │ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests - │ handlers + │ (commands/queries/handlers + that slice's own - │ repository) │ repository, mocked with Moq/DbMocker) + │ handler │ (commands/queries/handlers, with repository/service + │ tests) │ dependencies mocked via Moq) └──────────────┘ ``` diff --git a/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md b/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md index 87f8a798..fc54d59b 100644 --- a/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md +++ b/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md @@ -136,8 +136,8 @@ consumers are informed of the AI-generated origin before engagement. When forced by the system prompt to generate a "highly specialized technical brewing detail," the model frequently hallucinates fermentation science and -brewing chemistry. While the resulting text sounds confident, it is -nonsensical to a reader with brewing or scientific expertise. +brewing chemistry. While the resulting text sounds confident, it is nonsensical +to a reader with brewing or scientific expertise. Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts calling for specialist brewing detail. This is consistent with the **CHOKE** @@ -262,8 +262,8 @@ idiomatic phrasing. ] ``` -When fed into the pipeline, this dataset often causes the model to reason that -a local variant of French is needed, but it often defaults to a standardized +When fed into the pipeline, this dataset often causes the model to reason that a +local variant of French is needed, but it often defaults to a standardized dialect of French, without cultural or linguistic nuance. For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian @@ -312,10 +312,10 @@ not a source of truth. Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov. [^llm-bias]: - For example, Blasi et al. (2022), "Systematic Inequalities in Language Technology - Performance across the World's Languages," _ACL Anthology_. The pattern is - consistent with models trained predominantly on English-language web - corpora. + For example, Blasi et al. (2022), "Systematic Inequalities in Language + Technology Performance across the World's Languages," _ACL Anthology_. The + pattern is consistent with models trained predominantly on English-language + web corpora. [^wp-license]: Source: diff --git a/docs/pipeline/README.md b/docs/pipeline/README.md index 33a47ce0..7916007e 100644 --- a/docs/pipeline/README.md +++ b/docs/pipeline/README.md @@ -101,21 +101,21 @@ Each run writes a fresh dated SQLite file such as #### CLI flags -| Flag | Purpose | -| ------------------ | ---------------------------------------------------------------------------------------------------- | -| `--mocked` | Deterministic mock generator, no model required. | -| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. | +| Flag | Purpose | +| ------------------ | ------------------------------------------------------------------------------------------------------------ | +| `--mocked` | Deterministic mock generator, no model required. | +| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. | | `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. | -| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. | -| `--log-path` | Path for application logs. Default: `pipeline.log`. | -| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. | -| `--temperature` | Sampling temperature. Default: `1.0`. | -| `--top-p` | Nucleus sampling. Default: `0.95`. | -| `--top-k` | Top-k sampling. Default: `64`. | -| `--n-ctx` | Context window size. Default: `8192`. | -| `--seed` | Random seed. Default: `-1` (random at runtime). | -| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. | -| `--help, -h` | Print usage and exit. | +| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. | +| `--log-path` | Path for application logs. Default: `pipeline.log`. | +| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. | +| `--temperature` | Sampling temperature. Default: `1.0`. | +| `--top-p` | Nucleus sampling. Default: `0.95`. | +| `--top-k` | Top-k sampling. Default: `64`. | +| `--n-ctx` | Context window size. Default: `8192`. | +| `--seed` | Random seed. Default: `-1` (random at runtime). | +| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. | +| `--help, -h` | Print usage and exit. | `--mocked` and `--model` are mutually exclusive. Omitting both exits with an error before the pipeline starts. Sampling flags are ignored when `--mocked` is diff --git a/docs/website/database.md b/docs/website/database.md new file mode 100644 index 00000000..dbb28d24 --- /dev/null +++ b/docs/website/database.md @@ -0,0 +1,79 @@ +# Database + +SQL Server 2022 schema, migration approach, and the app's SQL error-handling +convention. For the vertical-slice code that talks to this schema, see +[Architecture](../architecture.md). + +## Schema + +The full schema lives in one script: +`web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql`, run by +`Database.Migrations` (DbUp) on startup. Each table is queried directly by the +owning feature slice's repository (see +[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)). + +All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and +most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on +updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`). + +### Tables backed by the API today + +| Table | Backed by | Notes | +| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique | +| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | +| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email | +| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | +| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly | +| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | +| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | +| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | + +### Tables defined in the schema but not yet wired to a feature slice + +These exist so the schema can support planned functionality, but have no +repository or seed data yet: + +| Table | Purpose (from schema) | +| ------------------ | -------------------------------------------------------------------------------------------------------- | +| `Photo` | A photo uploaded by a user account | +| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar | +| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) | +| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet | +| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet | +| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` | +| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` | +| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` | + +## Migrations + +**Tool**: DbUp, run by the `Database.Migrations` project/container. + +Scripts live under `scripts/`, applied in order and tracked so a script never +re-runs once it has succeeded. Today that's a single `01-schema/schema.sql`. +When the schema needs to change, add a new numbered script rather than editing +`schema.sql` in place — DbUp's tracking is per-script, so editing an +already-applied script means it won't re-run against existing databases. + +## Data seeding + +`Database.Seed` populates a target database from data produced by the C++ +pipeline (see [Pipeline README](../pipeline/README.md)): countries, +state/provinces, and cities (created on demand while resolving each brewery's +location), user accounts, and brewery posts with locations. It only seeds +tables that have a feature slice to write through (see the table above). + +## Database error handling + +- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero + rows against the `RowVersion` column. +- On failure, the repository throws a `Domain.Exceptions` type directly + (`NotFoundException`, `ConflictException`, `ArgumentException`, ...). + `API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404, + 409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or + constraint-violation failure) maps to 503. + + - One repository-level exception: `Features.Auth`'s `AuthRepository` does + catch `SqlException` directly, to detect a duplicate-key race (SQL Server + error 2601 or 2627) when two requests try to verify the same account + concurrently — see `Auth Repository.IsDuplicateVerificationViolation`. 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 47169b0d..d5acb9ae 100644 --- a/docs/website/diagrams-out/database-schema.svg +++ b/docs/website/diagrams-out/database-schema.svg @@ -1 +1 @@ -Key Database Schema - User & AuthenticationKey Database Schema - User & AuthenticationUserAccountUserAccountId: INT «PK»Username: NVARCHAR(30) «UNIQUE»Email: NVARCHAR(255) «UNIQUE»FirstName: NVARCHAR(50)LastName: NVARCHAR(50)Bio: NVARCHAR(500)CreatedAt: DATETIME2UpdatedAt: DATETIME2LastLoginAt: DATETIME2UserCredentialUserCredentialId: INT «PK»UserAccountId: INT «FK»PasswordHash: VARBINARY(32)PasswordSalt: VARBINARY(16)CredentialRotatedAt: DATETIME2CredentialExpiresAt: DATETIME2CredentialRevokedAt: DATETIME2IsActive: BITCreatedAt: DATETIME2UserVerificationUserVerificationId: INT «PK»UserAccountId: INT «FK»IsVerified: BITVerifiedAt: DATETIME2VerificationToken: NVARCHAR(255)TokenExpiresAt: DATETIME2UserAvatarUserAvatarId: INT «PK»UserAccountId: INT «FK»PhotoId: INT «FK»IsActive: BITCreatedAt: DATETIME2UserFollowUserFollowId: INT «PK»FollowerUserId: INT «FK»FollowedUserId: INT «FK»CreatedAt: DATETIME2PhotoPhotoId: INT «PK»Url: NVARCHAR(500)CloudinaryPublicId: NVARCHAR(255)Width: INTHeight: INTFormat: NVARCHAR(10)CreatedAt: DATETIME2Password hashing:- Algorithm: Argon2id- Memory: 64MB- Iterations: 4- Salt: 128-bit- Hash: 256-bitAccount verificationvia email tokenwith expiryCore stored procedures:- USP_RegisterUser- USP_GetUserAccountByUsername- USP_RotateUserCredential- USP_UpdateUserAccounthashashasfollowsfollowed byrefers to \ 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 9c752575..d180e533 100644 --- a/docs/website/diagrams-src/database-schema.puml +++ b/docs/website/diagrams-src/database-schema.puml @@ -3,102 +3,205 @@ skinparam backgroundColor #FFFFFF skinparam defaultFontName Arial skinparam linetype ortho +skinparam packageStyle rectangle -title Key Database Schema - User & Authentication +title Database Schema +caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet. -entity "UserAccount" as User { - * UserAccountId: INT <> - -- - * Username: NVARCHAR(30) <> - * Email: NVARCHAR(255) <> - * FirstName: NVARCHAR(50) - * LastName: NVARCHAR(50) - Bio: NVARCHAR(500) - CreatedAt: DATETIME2 - UpdatedAt: DATETIME2 - LastLoginAt: DATETIME2 +package "User & Auth" { + entity "UserAccount" as User { + * UserAccountID: UNIQUEIDENTIFIER <> + -- + * Username: VARCHAR(64) <> + * FirstName: NVARCHAR(128) + * LastName: NVARCHAR(128) + * Email: VARCHAR(128) <> + * CreatedAt: DATETIME + UpdatedAt: DATETIME + * DateOfBirth: DATE + } + + entity "UserCredential" as Cred { + * UserCredentialID: UNIQUEIDENTIFIER <> + -- + * UserAccountID: UNIQUEIDENTIFIER <> + * CreatedAt: DATETIME + * Expiry: DATETIME + * Hash: NVARCHAR(256) + * IsRevoked: BIT + RevokedAt: DATETIME + } + + entity "UserVerification" as Verify { + * UserVerificationID: UNIQUEIDENTIFIER <> + -- + * UserAccountID: UNIQUEIDENTIFIER <> + * VerificationDateTime: DATETIME + } + + entity "UserAvatar (*)" as Avatar { + * UserAvatarID: UNIQUEIDENTIFIER <> + -- + * UserAccountID: UNIQUEIDENTIFIER <> + * PhotoID: UNIQUEIDENTIFIER <> + } + + entity "UserFollow (*)" as Follow { + * UserFollowID: UNIQUEIDENTIFIER <> + -- + * UserAccountID: UNIQUEIDENTIFIER <> + * FollowingID: UNIQUEIDENTIFIER <> + * CreatedAt: DATETIME + } + + entity "Photo (*)" as Photo { + * PhotoID: UNIQUEIDENTIFIER <> + -- + Hyperlink: NVARCHAR(256) + * UploadedByID: UNIQUEIDENTIFIER <> + * UploadedAt: DATETIME + } } -entity "UserCredential" as Cred { - * UserCredentialId: INT <> - -- - * UserAccountId: INT <> - * PasswordHash: VARBINARY(32) - * PasswordSalt: VARBINARY(16) - CredentialRotatedAt: DATETIME2 - CredentialExpiresAt: DATETIME2 - CredentialRevokedAt: DATETIME2 - * IsActive: BIT - CreatedAt: DATETIME2 +package "Location" { + entity "Country" as Country { + * CountryID: UNIQUEIDENTIFIER <> + -- + * CountryName: NVARCHAR(100) + * ISO3166_1: CHAR(2) <> + } + + entity "StateProvince" as StateProvince { + * StateProvinceID: UNIQUEIDENTIFIER <> + -- + * StateProvinceName: NVARCHAR(100) + * ISO3166_2: CHAR(6) <> + * CountryID: UNIQUEIDENTIFIER <> + } + + entity "City" as City { + * CityID: UNIQUEIDENTIFIER <> + -- + * CityName: NVARCHAR(100) + * StateProvinceID: UNIQUEIDENTIFIER <> + } } -entity "UserVerification" as Verify { - * UserVerificationId: INT <> - -- - * UserAccountId: INT <> - * IsVerified: BIT - VerifiedAt: DATETIME2 - VerificationToken: NVARCHAR(255) - TokenExpiresAt: DATETIME2 +package "Brewery" { + entity "BreweryPost" as Brewery { + * BreweryPostID: UNIQUEIDENTIFIER <> + -- + * BreweryName: NVARCHAR(256) + * PostedByID: UNIQUEIDENTIFIER <> + * Description: NVARCHAR(512) + * CreatedAt: DATETIME + UpdatedAt: DATETIME + } + + entity "BreweryPostLocation" as BreweryLoc { + * BreweryPostLocationID: UNIQUEIDENTIFIER <> + -- + * BreweryPostID: UNIQUEIDENTIFIER <> + * AddressLine1: NVARCHAR(256) + AddressLine2: NVARCHAR(256) + * PostalCode: NVARCHAR(20) + * CityID: UNIQUEIDENTIFIER <> + Coordinates: GEOGRAPHY + } + + entity "BreweryPostPhoto (*)" as BreweryPhoto { + * BreweryPostPhotoID: UNIQUEIDENTIFIER <> + -- + * BreweryPostID: UNIQUEIDENTIFIER <> + * PhotoID: UNIQUEIDENTIFIER <> + * LinkedAt: DATETIME + } } -entity "UserAvatar" as Avatar { - * UserAvatarId: INT <> - -- - * UserAccountId: INT <> - PhotoId: INT <> - * IsActive: BIT - CreatedAt: DATETIME2 +package "Beer (*)" { + entity "BeerStyle (*)" as BeerStyle { + * BeerStyleID: UNIQUEIDENTIFIER <> + -- + * StyleName: NVARCHAR(100) <> + Description: NVARCHAR(MAX) + } + + entity "BeerPost (*)" as Beer { + * BeerPostID: UNIQUEIDENTIFIER <> + -- + * Name: NVARCHAR(100) + * Description: NVARCHAR(MAX) + * ABV: DECIMAL(4,2) + * IBU: INT + * PostedByID: UNIQUEIDENTIFIER <> + * BeerStyleID: UNIQUEIDENTIFIER <> + * BrewedByID: UNIQUEIDENTIFIER <> + * CreatedAt: DATETIME + UpdatedAt: DATETIME + } + + entity "BeerPostPhoto (*)" as BeerPhoto { + * BeerPostPhotoID: UNIQUEIDENTIFIER <> + -- + * BeerPostID: UNIQUEIDENTIFIER <> + * PhotoID: UNIQUEIDENTIFIER <> + * LinkedAt: DATETIME + } + + entity "BeerPostComment (*)" as BeerComment { + * BeerPostCommentID: UNIQUEIDENTIFIER <> + -- + * Comment: NVARCHAR(250) + * BeerPostID: UNIQUEIDENTIFIER <> + * CommentedByID: UNIQUEIDENTIFIER <> + * Rating: INT + * CreatedAt: DATETIME + UpdatedAt: DATETIME + } } -entity "UserFollow" as Follow { - * UserFollowId: INT <> - -- - * FollowerUserId: INT <> - * FollowedUserId: INT <> - CreatedAt: DATETIME2 -} - -entity "Photo" as Photo { - * PhotoId: INT <> - -- - * Url: NVARCHAR(500) - * CloudinaryPublicId: NVARCHAR(255) - Width: INT - Height: INT - Format: NVARCHAR(10) - CreatedAt: DATETIME2 -} - -' Relationships +' Relationships: User & Auth User ||--o{ Cred : "has" User ||--o| Verify : "has" -User ||--o{ Avatar : "has" -User ||--o{ Follow : "follows" -User ||--o{ Follow : "followed by" +User ||--o| Avatar : "has" Avatar }o--|| Photo : "refers to" +User ||--o{ Follow : "follows (UserAccountID)" +User ||--o{ Follow : "followed by (FollowingID)" +User ||--o{ Photo : "uploaded" -note right of Cred - Password hashing: - - Algorithm: Argon2id - - Memory: 64MB - - Iterations: 4 - - Salt: 128-bit - - Hash: 256-bit -end note +' Relationships: Location +Country ||--o{ StateProvince : "has" +StateProvince ||--o{ City : "has" -note right of Verify - Account verification - via email token - with expiry -end note +' Relationships: Brewery +User ||--o{ Brewery : "posted" +Brewery ||--o| BreweryLoc : "has" +City ||--o{ BreweryLoc : "located in" +Brewery ||--o{ BreweryPhoto : "has" +Photo ||--o{ BreweryPhoto : "linked to" + +' Relationships: Beer +User ||--o{ Beer : "posted" +BeerStyle ||--o{ Beer : "categorizes" +Brewery ||--o{ Beer : "brewed" +Beer ||--o{ BeerPhoto : "has" +Photo ||--o{ BeerPhoto : "linked to" +Beer ||--o{ BeerComment : "has" +User ||--o{ BeerComment : "commented" note bottom of User - Core stored procedures: - - USP_RegisterUser - - USP_GetUserAccountByUsername - - USP_RotateUserCredential - - USP_UpdateUserAccount + Backed by Features.Auth/Features.UserManagement's + Dapper repositories (AuthRepository, + UserAccountRepository) - registration, credential + rotation, and verification each issue their own + inline SQL, no stored procedures +end note + +note bottom of Brewery + Backed by Features.Breweries' BreweryRepository + (Dapper); create/update/delete run inline SQL + inside a transaction, with optimistic concurrency + via RowVersion end note @enduml 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/environment-variables.md b/docs/website/environment-variables.md index d4360183..c8e9ddd8 100644 --- a/docs/website/environment-variables.md +++ b/docs/website/environment-variables.md @@ -88,12 +88,26 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website **Generate Secrets**: -```bash -# macOS/Linux - Generate 127-character base64 secret -openssl rand -base64 127 +**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw bytes)** -# Windows PowerShell -[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256})) +```bash +openssl rand -base64 127 +``` + +**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw bytes)** + +```powershell +$bytes = New-Object byte[] 127 +$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new() +$rng.GetBytes($bytes) +[Convert]::ToBase64String($bytes) +$rng.Dispose() +``` + +**Windows - PowerShell 7+ compatible (cryptographically secure, 127 raw bytes)** + +```powershell +[Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(127)) ``` **Token Expiration**: @@ -166,8 +180,8 @@ NODE_ENV=development # Standard Node runtime mode ### SMTP configuration (backend) -Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation -and account emails. +Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and +account emails. ```bash SMTP_HOST=mailpit # Required, no default @@ -183,8 +197,8 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing - **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web UI on http://localhost:8025) -- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, - and so on) +- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and + so on) ## Docker-specific variables @@ -234,35 +248,35 @@ reference only and is not run as part of the active stack. ## Variable reference table -| Variable | Backend | Frontend | Docker | Required | Notes | -| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- | -| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address | -| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name | -| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username | -| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password | -| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components | -| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` | -| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing | -| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing | -| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing | -| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails | -| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host | -| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` | -| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username | -| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password | -| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` | -| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | -| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | -| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL | -| `SESSION_SECRET` | | ✓ | | Yes | Website session signing | -| `NODE_ENV` | | ✓ | | No | Runtime mode | -| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag | -| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment | -| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address | -| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container | -| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA | -| `MSSQL_PID` | | | ✓ | No | SQL Server edition | -| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag | +| Variable | Backend | Frontend | Docker | Required | Notes | +| ----------------------------- | :-----: | :------: | :----: | :------: | ---------------------------- | +| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address | +| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name | +| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username | +| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password | +| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components | +| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` | +| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing | +| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing | +| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing | +| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails | +| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host | +| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` | +| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username | +| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password | +| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` | +| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | +| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | +| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL | +| `SESSION_SECRET` | | ✓ | | Yes | Website session signing | +| `NODE_ENV` | | ✓ | | No | Runtime mode | +| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag | +| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment | +| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address | +| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container | +| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA | +| `MSSQL_PID` | | | ✓ | No | SQL Server edition | +| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag | \* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided. @@ -276,9 +290,8 @@ Variables are validated at startup: - Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection values) cause the application to fail with an `InvalidOperationException` -- No minimum length is enforced on the token secrets in code; the - "minimum 32 characters" guidance above is a recommendation, not an - enforced check +- No minimum length is enforced on the token secrets in code; the "minimum 32 + characters" guidance above is a recommendation, not an enforced check ### Frontend validation diff --git a/docs/website/getting-started.md b/docs/website/getting-started.md index ecbf4998..440ea9aa 100644 --- a/docs/website/getting-started.md +++ b/docs/website/getting-started.md @@ -46,7 +46,8 @@ See [Environment Variables](environment-variables.md) for the full list. docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d ``` -This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev SMTP). +This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev +SMTP). Available endpoints: diff --git a/docs/website/testing.md b/docs/website/testing.md index 850caaea..9eee38a6 100644 --- a/docs/website/testing.md +++ b/docs/website/testing.md @@ -7,13 +7,13 @@ Biergarten App. The project uses a multi-layered testing approach across backend and frontend: -- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against - a live, seeded database +- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a + live, seeded database - **Features.\*.Tests** - One unit test project per backend feature slice (`Features.Auth.Tests`, `Features.Breweries.Tests`, `Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that - slice's command/query handlers and its own repository (mocked with Moq / - DbMocker, no real database required) + slice's command/query handlers, with dependencies mocked via Moq (no real + database required) - **Storybook Vitest project** - Browser-based interaction tests for shared website stories - **Storybook Playwright suite** - Browser checks against Storybook-rendered @@ -78,7 +78,7 @@ dotnet test API/API.Specs/API.Specs.csproj ### Feature slice unit tests Each feature slice has its own test project, covering its command/query -handlers and repository: +handlers: ```bash cd web/backend @@ -93,8 +93,8 @@ for proj in Features/*.Tests; do dotnet test "$proj"; done **Requirements**: -- No database required (handlers use Moq; repository tests use DbMocker to - simulate SQL Server responses) +- No database required (handlers are tested with their repository/service + dependencies mocked via Moq) ### Frontend Storybook tests @@ -137,7 +137,6 @@ npm run test:storybook:playwright - Email confirmation and confirmation-email resend - Refresh token exchange - JWT token generation, validation, and claims handling -- `IAuthRepository` data access (DbMocker-backed) - Invalid credentials and 404 error responses (via API.Specs) **Features.Breweries.Tests**: @@ -173,7 +172,7 @@ npm run test:storybook:playwright ### xUnit - Primary unit testing framework -- Used for Repository and Service layer tests +- Used for handler and service layer tests - Supports parallel test execution ### Reqnroll (Gherkin/BDD) @@ -194,12 +193,6 @@ npm run test:storybook:playwright - Used in Service layer tests - Enables isolated unit testing -### DbMocker - -- Database mocking for repository tests -- Simulates SQL Server responses -- No real database required for unit tests - ## Test structure ### API.Specs (integration tests) @@ -248,9 +241,7 @@ Features.Auth.Tests/ │ ├── ResendConfirmationEmailHandlerTests.cs │ └── RefreshTokenHandlerTests.cs ├── Queries/ -│ └── LoginHandlerTests.cs -├── Repository/ -│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests +│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name └── Services/ ├── TokenServiceRefreshTests.cs └── TokenServiceValidationTests.cs @@ -278,7 +269,7 @@ public class LoginHandlerTests // ...set up mocks for a known user/credential... // Act - var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None); + var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None); // Assert result.Should().NotBeNull(); diff --git a/docs/website/token-validation.md b/docs/website/token-validation.md index d4efaa73..6eb5b90e 100644 --- a/docs/website/token-validation.md +++ b/docs/website/token-validation.md @@ -34,8 +34,8 @@ Low-level JWT operations. #### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs) -Both token generation and validation live on the same slice-internal -service (there is no separate validation service/class). +Both token generation and validation live on the same slice-internal service +(there is no separate validation service/class). **Generation methods:** @@ -56,18 +56,18 @@ service (there is no separate validation service/class). - `Username` (string) - `Principal` (ClaimsPrincipal) - Full JWT claims -**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs) +**Implementation:** +[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs) - Reads token secrets from environment variables - Extracts and validates claims (Sub, UniqueName) - Throws `UnauthorizedException` on validation failure -`TokenService` is registered by `Features.Auth`'s own -`AddFeaturesAuth()` extension method, except for the lower-level -`ITokenInfrastructure` (JWT signing/verification) it depends on, which is -registered by the host (`API.Core/Program.cs`) since -`JwtAuthenticationHandler` (host-level auth middleware) also depends on -it directly. +`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()` +extension method, except for the lower-level `ITokenInfrastructure` (JWT +signing/verification) it depends on, which is registered by the host +(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth +middleware) also depends on it directly. ### Integration points @@ -97,9 +97,9 @@ it directly. **Flow:** 1. Receives a refresh token via `RefreshTokenCommand` -2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the - token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, - and issues a new access/refresh token pair +2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token, + retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and + issues a new access/refresh token pair 3. Maps the result onto `LoginPayload` #### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs) diff --git a/web/backend/API/API.Core/Controllers/NotFoundController.cs b/web/backend/API/API.Core/Controllers/NotFoundController.cs index 58a1a8af..2f0ee490 100644 --- a/web/backend/API/API.Core/Controllers/NotFoundController.cs +++ b/web/backend/API/API.Core/Controllers/NotFoundController.cs @@ -14,6 +14,9 @@ namespace API.Core.Controllers; [Route("error")] // required public class NotFoundController : ControllerBase { + /// + /// Returns a 404 response for any request that did not match a route. + /// [HttpGet("404")] //required public IActionResult Handle404() { diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index 7567388c..28ac9b4d 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -27,6 +27,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] +COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] +COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 326a1e15..5e9b85d2 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -11,12 +11,12 @@ using Features.UserManagement.DependencyInjection; using FluentValidation; using FluentValidation.AspNetCore; using Infrastructure.Jwt; -using Infrastructure.Sql; +using Infrastructure.Sql.DependencyInjection; using Shared.Application.Behaviors; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -// Global Exception Filter +// Add current controllers to the exception filter builder .Services.AddControllers(options => { @@ -37,7 +37,7 @@ builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); builder.Services.AddFluentValidationAutoValidation(); -// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced; +// Add MediatR. // ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers. builder.Services.AddMediatR(cfg => { @@ -60,15 +60,14 @@ if (!builder.Environment.IsProduction()) // Configure Dependency Injection ------------------------------------------------------------------------------------- -builder.Services.AddSingleton(); +builder.Services.AddInfrastructureSql(); builder.Services.AddFeaturesBreweries(); builder.Services.AddFeaturesUserManagement(); builder.Services.AddFeaturesAuth(); builder.Services.AddFeaturesEmails(); -// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because -// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly. +// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly. builder.Services.AddScoped(); // Register the exception filter diff --git a/web/backend/API/API.Core/appsettings.json b/web/backend/API/API.Core/appsettings.json index 04953465..d4810ac2 100644 --- a/web/backend/API/API.Core/appsettings.json +++ b/web/backend/API/API.Core/appsettings.json @@ -12,7 +12,7 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "" + "Default": "" }, "Jwt": { "SecretKey": "", diff --git a/web/backend/API/API.Specs/API.Specs.csproj b/web/backend/API/API.Specs/API.Specs.csproj index 5ae428e8..3dffa56b 100644 --- a/web/backend/API/API.Specs/API.Specs.csproj +++ b/web/backend/API/API.Specs/API.Specs.csproj @@ -11,7 +11,7 @@ - + diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile index f1556b99..2937ff6f 100644 --- a/web/backend/API/API.Specs/Dockerfile +++ b/web/backend/API/API.Specs/Dockerfile @@ -17,6 +17,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] +COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] +COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] diff --git a/web/backend/API/API.Specs/Features/Registration.feature b/web/backend/API/API.Specs/Features/Registration.feature index edc33671..7af06b8b 100644 --- a/web/backend/API/API.Specs/Features/Registration.feature +++ b/web/backend/API/API.Specs/Features/Registration.feature @@ -14,6 +14,7 @@ Scenario: Successful registration with valid details Scenario: Registration fails with existing username Given the API is running + And I have an existing account When I submit a registration request with values: | Username | FirstName | LastName | Email | DateOfBirth | Password | | test.user | Test | User | example@example.com | 2001-11-11 | Password1! | @@ -21,6 +22,7 @@ Scenario: Registration fails with existing username Scenario: Registration fails with existing email Given the API is running + And I have an existing account When I submit a registration request with values: | Username | FirstName | LastName | Email | DateOfBirth | Password | | newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! | diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index 605859c9..0f4c045e 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -83,10 +83,59 @@ public class AuthSteps(ScenarioContext scenario) ); } + private const string FixtureUsername = "test.user"; + private const string FixturePassword = "Password1!"; + private const string FixtureEmail = "test.user@thebiergarten.app"; + + // Scenarios run in parallel across feature classes, and this fixture is shared by name. + // Serializing provisioning avoids two scenarios racing to INSERT the same username/email, + // which would otherwise surface as a raw SQL unique-constraint error instead of a clean 409. + private static readonly SemaphoreSlim FixtureProvisioningLock = new(1, 1); + [Given("I have an existing account")] - public void GivenIHaveAnExistingAccount() + public async Task GivenIHaveAnExistingAccount() { - scenario[TestUserKey] = ("test.user", "password"); + scenario[TestUserKey] = (FixtureUsername, FixturePassword); + + HttpClient client = GetClient(); + var registrationData = new + { + username = FixtureUsername, + firstName = "Test", + lastName = "User", + email = FixtureEmail, + dateOfBirth = "1990-01-01", + password = FixturePassword, + }; + + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register") + { + Content = new StringContent( + JsonSerializer.Serialize(registrationData), + Encoding.UTF8, + "application/json" + ), + }; + + // Registration is used (rather than relying on seeded data) so the fixture account is + // guaranteed to exist regardless of run order. A 409 means another scenario in this run + // already provisioned it, which is equally fine. + await FixtureProvisioningLock.WaitAsync(); + HttpResponseMessage response; + try + { + response = await client.SendAsync(requestMessage); + } + finally + { + FixtureProvisioningLock.Release(); + } + + if ((int)response.StatusCode is not (201 or 409)) + throw new InvalidOperationException( + $"Failed to provision fixture account '{FixtureUsername}': " + + $"{(int)response.StatusCode} {response.StatusCode}" + ); } [Given("I do not have an existing account")] @@ -320,7 +369,7 @@ public class AuthSteps(ScenarioContext scenario) public async Task GivenIAmLoggedIn() { HttpClient client = GetClient(); - var loginData = new { username = "test.user", password = "password" }; + var loginData = new { username = FixtureUsername, password = FixturePassword }; string body = JsonSerializer.Serialize(loginData); HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login") diff --git a/web/backend/Core.slnx b/web/backend/Core.slnx index 44d81d53..7d869556 100644 --- a/web/backend/Core.slnx +++ b/web/backend/Core.slnx @@ -27,6 +27,8 @@ + + diff --git a/web/backend/Database/Database.Migrations/Database.Migrations.csproj b/web/backend/Database/Database.Migrations/Database.Migrations.csproj index 0ec05c6c..35e0da20 100644 --- a/web/backend/Database/Database.Migrations/Database.Migrations.csproj +++ b/web/backend/Database/Database.Migrations/Database.Migrations.csproj @@ -10,7 +10,10 @@ - + + + + diff --git a/web/backend/Database/Database.Migrations/Dockerfile b/web/backend/Database/Database.Migrations/Dockerfile index 2fc184d8..7878561e 100644 --- a/web/backend/Database/Database.Migrations/Dockerfile +++ b/web/backend/Database/Database.Migrations/Dockerfile @@ -2,14 +2,15 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src -# Copy everything from the context (src/Core/Database) +# Copy everything from the context (src/Core), matching Database.Seed/Dockerfile's context so +# ProjectReference paths outside Database/ (e.g. Infrastructure.Sql) resolve. COPY . . -RUN dotnet restore "./Database.Migrations/Database.Migrations.csproj" -RUN dotnet build "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build +RUN dotnet restore "./Database/Database.Migrations/Database.Migrations.csproj" +RUN dotnet build "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet publish "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final WORKDIR /app diff --git a/web/backend/Database/Database.Migrations/Program.cs b/web/backend/Database/Database.Migrations/Program.cs index b3d973ec..202d5e06 100644 --- a/web/backend/Database/Database.Migrations/Program.cs +++ b/web/backend/Database/Database.Migrations/Program.cs @@ -2,6 +2,7 @@ using System.Reflection; using DbUp; using DbUp.Engine; +using Infrastructure.Sql; using Microsoft.Data.SqlClient; namespace Database.Migrations; @@ -96,12 +97,6 @@ public static class Program Console.WriteLine($"Database '{settings.DatabaseName}' is present."); } - /// - /// Opens a connection, executes a non-query batch parameterised by database name, and - /// disposes both the command and the connection. - /// - /// The connection string to open. - /// The Transact-SQL batch to execute. /// The value bound to the @databaseName parameter. private static async Task ExecuteNonQueryAsync( string connectionString, @@ -171,16 +166,11 @@ public static class Program /// public static DatabaseSettings FromEnvironment() { - string server = Required("DB_SERVER"); - string databaseName = Required("DB_NAME"); - string user = Required("DB_USER"); - string password = Required("DB_PASSWORD"); - - bool trustServerCertificate = - !bool.TryParse( - Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"), - out bool parsedTrust - ) || parsedTrust; + string databaseName = + Environment.GetEnvironmentVariable("DB_NAME") + ?? throw new InvalidOperationException( + "The DB_NAME environment variable is not set." + ); bool clearDatabase = string.Equals( Environment.GetEnvironmentVariable("CLEAR_DATABASE"), @@ -189,28 +179,11 @@ public static class Program ); return new DatabaseSettings( - Build(databaseName), - Build("master"), + SqlConnectionStringHelper.BuildConnectionString(databaseName), + SqlConnectionStringHelper.BuildMasterConnectionString(), databaseName, clearDatabase ); - - string Build(string initialCatalog) => - new SqlConnectionStringBuilder - { - DataSource = server, - InitialCatalog = initialCatalog, - UserID = user, - Password = password, - Encrypt = true, - TrustServerCertificate = trustServerCertificate, - }.ConnectionString; } - - private static string Required(string variableName) => - Environment.GetEnvironmentVariable(variableName) - ?? throw new InvalidOperationException( - $"The {variableName} environment variable is not set." - ); } } diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql index f3ffb1a7..f024b05a 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql @@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount UserAccountID UNIQUEIDENTIFIER CONSTRAINT DF_UserAccountID DEFAULT NEWID(), - Username VARCHAR(64) NOT NULL, + Username VARCHAR(64) NOT NULL, - FirstName NVARCHAR(128) NOT NULL, + FirstName NVARCHAR(128) NOT NULL, - LastName NVARCHAR(128) NOT NULL, + LastName NVARCHAR(128) NOT NULL, - Email VARCHAR(128) NOT NULL, + Email VARCHAR(128) NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME, + UpdatedAt DATETIME, - DateOfBirth DATE NOT NULL, + DateOfBirth DATE NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserAccount PRIMARY KEY (UserAccountID), @@ -56,18 +56,18 @@ CREATE TABLE dbo.UserAccount CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos ( - PhotoID UNIQUEIDENTIFIER + PhotoID UNIQUEIDENTIFIER CONSTRAINT DF_PhotoID DEFAULT NEWID(), - Hyperlink NVARCHAR(256), + Hyperlink NVARCHAR(256), -- storage is handled via filesystem or cloud service UploadedByID UNIQUEIDENTIFIER NOT NULL, - UploadedAt DATETIME NOT NULL + UploadedAt DATETIME NOT NULL CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_Photo PRIMARY KEY (PhotoID), @@ -87,14 +87,14 @@ NONCLUSTERED INDEX IX_Photo_UploadedByID CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted ( - UserAvatarID UNIQUEIDENTIFIER + UserAvatarID UNIQUEIDENTIFIER CONSTRAINT DF_UserAvatarID DEFAULT NEWID(), UserAccountID UNIQUEIDENTIFIER NOT NULL, - PhotoID UNIQUEIDENTIFIER NOT NULL, + PhotoID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID), @@ -120,15 +120,15 @@ NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE TABLE UserVerification -- delete verification data when user account is deleted ( - UserVerificationID UNIQUEIDENTIFIER + UserVerificationID UNIQUEIDENTIFIER CONSTRAINT DF_UserVerificationID DEFAULT NEWID(), - UserAccountID UNIQUEIDENTIFIER NOT NULL, + UserAccountID UNIQUEIDENTIFIER NOT NULL, - VerificationDateTime DATETIME NOT NULL + VerificationDateTime DATETIME NOT NULL CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserVerification PRIMARY KEY (UserVerificationID), @@ -154,23 +154,23 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted UserCredentialID UNIQUEIDENTIFIER CONSTRAINT DF_UserCredentialID DEFAULT NEWID(), - UserAccountID UNIQUEIDENTIFIER NOT NULL, + UserAccountID UNIQUEIDENTIFIER NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(), - Expiry DATETIME NOT NULL + Expiry DATETIME NOT NULL CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()), - Hash NVARCHAR(256) NOT NULL, + Hash NVARCHAR(256) NOT NULL, -- uses argon2 - IsRevoked BIT NOT NULL + IsRevoked BIT NOT NULL CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0, - RevokedAt DATETIME NULL, + RevokedAt DATETIME NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserCredential PRIMARY KEY (UserCredentialID), @@ -195,17 +195,17 @@ NONCLUSTERED INDEX IX_UserCredential_Account_Active CREATE TABLE UserFollow ( - UserFollowID UNIQUEIDENTIFIER + UserFollowID UNIQUEIDENTIFIER CONSTRAINT DF_UserFollowID DEFAULT NEWID(), UserAccountID UNIQUEIDENTIFIER NOT NULL, - FollowingID UNIQUEIDENTIFIER NOT NULL, + FollowingID UNIQUEIDENTIFIER NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserFollow PRIMARY KEY (UserFollowID), @@ -223,7 +223,7 @@ CREATE TABLE UserFollow CONSTRAINT CK_CannotFollowOwnAccount CHECK (UserAccountID != FollowingID ) - ); +); CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID @@ -238,14 +238,14 @@ NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount CREATE TABLE Country ( - CountryID UNIQUEIDENTIFIER + CountryID UNIQUEIDENTIFIER CONSTRAINT DF_CountryID DEFAULT NEWID(), CountryName NVARCHAR(100) NOT NULL, - ISO3166_1 CHAR(2) NOT NULL, + ISO3166_1 CHAR(2) NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_Country PRIMARY KEY (CountryID), @@ -259,17 +259,17 @@ CREATE TABLE Country CREATE TABLE StateProvince ( - StateProvinceID UNIQUEIDENTIFIER + StateProvinceID UNIQUEIDENTIFIER CONSTRAINT DF_StateProvinceID DEFAULT NEWID(), StateProvinceName NVARCHAR(100) NOT NULL, - ISO3166_2 CHAR(6) NOT NULL, + ISO3166_2 CHAR(6) NOT NULL, -- eg 'US-CA' for California, 'CA-ON' for Ontario - CountryID UNIQUEIDENTIFIER NOT NULL, + CountryID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_StateProvince PRIMARY KEY (StateProvinceID), @@ -291,14 +291,14 @@ NONCLUSTERED INDEX IX_StateProvince_Country CREATE TABLE City ( - CityID UNIQUEIDENTIFIER + CityID UNIQUEIDENTIFIER CONSTRAINT DF_CityID DEFAULT NEWID(), - CityName NVARCHAR(100) NOT NULL, + CityName NVARCHAR(100) NOT NULL, StateProvinceID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_City PRIMARY KEY (CityID), @@ -320,18 +320,18 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post BreweryPostID UNIQUEIDENTIFIER CONSTRAINT DF_BreweryPostID DEFAULT NEWID(), - BreweryName NVARCHAR(256) NOT NULL, + BreweryName NVARCHAR(256) NOT NULL, - PostedByID UNIQUEIDENTIFIER NOT NULL, + PostedByID UNIQUEIDENTIFIER NOT NULL, - Description NVARCHAR(512) NOT NULL, + Description NVARCHAR(MAX) NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME NULL, + UpdatedAt DATETIME NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BreweryPost PRIMARY KEY (BreweryPostID), @@ -354,19 +354,19 @@ CREATE TABLE BreweryPostLocation BreweryPostLocationID UNIQUEIDENTIFIER CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(), - BreweryPostID UNIQUEIDENTIFIER NOT NULL, + BreweryPostID UNIQUEIDENTIFIER NOT NULL, - AddressLine1 NVARCHAR(256) NOT NULL, + AddressLine1 NVARCHAR(256) NOT NULL, - AddressLine2 NVARCHAR(256), + AddressLine2 NVARCHAR(256), - PostalCode NVARCHAR(20) NOT NULL, + PostalCode NVARCHAR(20) NOT NULL, - CityID UNIQUEIDENTIFIER NOT NULL, + CityID UNIQUEIDENTIFIER NOT NULL, - Coordinates GEOGRAPHY NULL, + Coordinates GEOGRAPHY NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BreweryPostLocation PRIMARY KEY (BreweryPostLocationID), @@ -412,14 +412,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the BreweryPostPhotoID UNIQUEIDENTIFIER CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(), - BreweryPostID UNIQUEIDENTIFIER NOT NULL, + BreweryPostID UNIQUEIDENTIFIER NOT NULL, - PhotoID UNIQUEIDENTIFIER NOT NULL, + PhotoID UNIQUEIDENTIFIER NOT NULL, - LinkedAt DATETIME NOT NULL + LinkedAt DATETIME NOT NULL CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BreweryPostPhoto PRIMARY KEY (BreweryPostPhotoID), @@ -451,11 +451,11 @@ CREATE TABLE BeerStyle BeerStyleID UNIQUEIDENTIFIER CONSTRAINT DF_BeerStyleID DEFAULT NEWID(), - StyleName NVARCHAR(100) NOT NULL, + StyleName NVARCHAR(100) NOT NULL, Description NVARCHAR(MAX), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BeerStyle PRIMARY KEY (BeerStyleID), @@ -469,31 +469,31 @@ CREATE TABLE BeerStyle CREATE TABLE BeerPost ( - BeerPostID UNIQUEIDENTIFIER + BeerPostID UNIQUEIDENTIFIER CONSTRAINT DF_BeerPostID DEFAULT NEWID(), - Name NVARCHAR(100) NOT NULL, + Name NVARCHAR(100) NOT NULL, Description NVARCHAR(MAX) NOT NULL, - ABV DECIMAL(4, 2) NOT NULL, + ABV DECIMAL(4, 2) NOT NULL, -- Alcohol By Volume (typically 0-67%) - IBU INT NOT NULL, + IBU INT NOT NULL, -- International Bitterness Units (typically 0-120) - PostedByID UNIQUEIDENTIFIER NOT NULL, + PostedByID UNIQUEIDENTIFIER NOT NULL, BeerStyleID UNIQUEIDENTIFIER NOT NULL, - BrewedByID UNIQUEIDENTIFIER NOT NULL, + BrewedByID UNIQUEIDENTIFIER NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME, + UpdatedAt DATETIME, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BeerPost PRIMARY KEY (BeerPostID), @@ -538,14 +538,14 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th BeerPostPhotoID UNIQUEIDENTIFIER CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(), - BeerPostID UNIQUEIDENTIFIER NOT NULL, + BeerPostID UNIQUEIDENTIFIER NOT NULL, - PhotoID UNIQUEIDENTIFIER NOT NULL, + PhotoID UNIQUEIDENTIFIER NOT NULL, - LinkedAt DATETIME NOT NULL + LinkedAt DATETIME NOT NULL CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BeerPostPhoto PRIMARY KEY (BeerPostPhotoID), @@ -577,20 +577,20 @@ CREATE TABLE BeerPostComment BeerPostCommentID UNIQUEIDENTIFIER CONSTRAINT DF_BeerPostComment DEFAULT NEWID(), - Comment NVARCHAR(250) NOT NULL, + Comment NVARCHAR(250) NOT NULL, - BeerPostID UNIQUEIDENTIFIER NOT NULL, + BeerPostID UNIQUEIDENTIFIER NOT NULL, - CommentedByID UNIQUEIDENTIFIER NOT NULL, + CommentedByID UNIQUEIDENTIFIER NOT NULL, - Rating INT NOT NULL, + Rating INT NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME NULL, + UpdatedAt DATETIME NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BeerPostComment PRIMARY KEY (BeerPostCommentID), diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index 1235ce07..a043b08a 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -10,13 +10,8 @@ - - - - + + @@ -24,14 +19,9 @@ + - - - + diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs index 8a798d59..b95affa0 100644 --- a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs +++ b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs @@ -1,6 +1,6 @@ -#region +#region -using Microsoft.Data.SqlClient; +using Infrastructure.Sql; #endregion @@ -8,41 +8,7 @@ namespace Database.Seed.DatabaseHelpers; public class ConnectionStrings { - public static string SqlServerConnectionString => GetSqlServerConnectionString(); + public static string SqlServerConnectionString => SqlConnectionStringHelper.BuildConnectionString(); public static string SqliteConnectionString => "Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"; - - private static string GetSqlServerConnectionString() - { - string server = - Environment.GetEnvironmentVariable("DB_SERVER") - ?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); - - string dbName = - Environment.GetEnvironmentVariable("DB_NAME") - ?? throw new InvalidOperationException("DB_NAME environment variable is not set"); - - string user = - Environment.GetEnvironmentVariable("DB_USER") - ?? throw new InvalidOperationException("DB_USER environment variable is not set"); - - string password = - Environment.GetEnvironmentVariable("DB_PASSWORD") - ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); - - string trustServerCertificate = - Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True"; - - SqlConnectionStringBuilder builder = new() - { - DataSource = server, - InitialCatalog = dbName, - UserID = user, - Password = password, - TrustServerCertificate = bool.Parse(trustServerCertificate), - Encrypt = true, - }; - - return builder.ConnectionString; - } } diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index 51366c78..ee2fac56 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -7,112 +7,257 @@ using Features.Auth.Dtos; using Features.Auth.Repository; using Features.Breweries.DependencyInjection; using Features.Breweries.Repository; +using Features.Locations.DependencyInjection; +using Features.Locations.Dtos; +using Features.Locations.Repository; using idunno.Password; using Infrastructure.PasswordHashing; -using Infrastructure.Sql; -using Microsoft.Data.Sqlite; +using Infrastructure.Sql.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; -static async Task main() +return await RunAsync(); + +static async Task RunAsync() { - IReadOnlyList breweries; - IReadOnlyList users; try { - PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); - breweries = reader.ReadBreweryRecords(); - users = reader.ReadUserRecords(); + ServiceCollection services = []; + services.AddSingleton( + new ConfigurationBuilder().Build()); + services.AddInfrastructureSql(); + services.AddFeaturesBreweries(); + services.AddFeaturesLocations(); + services.AddFeaturesAuth(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + IBreweryRepository breweryRepository = + provider.GetRequiredService(); + ILocationRepository locationRepository = + provider.GetRequiredService(); + IAuthRepository authRepository = + provider.GetRequiredService(); + IPasswordInfrastructure passwordInfrastructure = + provider.GetRequiredService(); + + AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]") + .LeftJustified()); + AnsiConsole.MarkupLine( + "[grey]Connecting to SQLite source and loading seed data...[/]"); + AnsiConsole.WriteLine(); + + PipelineSeedDataReader reader = + new(ConnectionStrings.SqliteConnectionString); + + SeedData seedData = null!; + IReadOnlyList postedByIds = []; + + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading seed data...", + async ctx => + { + seedData = await reader.ReadSeedDataAsync(); + ctx.Status( + $"Loaded {seedData.Breweries.Count} breweries and {seedData.Users.Count} users." + ); + } + ); + + AnsiConsole.MarkupLine( + $"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries." + ); + AnsiConsole.Write(BuildBreweryTable(seedData.Breweries)); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine( + $"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users."); + AnsiConsole.Write(BuildUserTable(seedData.Users)); + AnsiConsole.WriteLine(); + + AnsiConsole.WriteLine("Seed data loaded successfully."); + + AnsiConsole.Write( + new Rule("[bold green]Loading seed data into target database.[/]") + .LeftJustified() + ); + + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading user data into target database...", + async ctx => + { + postedByIds = await LoadUsersIntoDatabaseAsync( + authRepository, + passwordInfrastructure, + seedData.Users + ); + ctx.Status("User data loaded into target database."); + } + ); + + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading brewery data into target database...", + async ctx => + { + await LoadBreweriesIntoDatabaseAsync( + breweryRepository, + locationRepository, + seedData.Breweries, + postedByIds + ); + ctx.Status("Brewery data loaded into target database."); + } + ); + + return 0; } - catch (SqliteException ex) + catch (Exception ex) { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error opening database connection: {ex.Message}"); - Console.ResetColor(); + AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); + AnsiConsole.WriteException(ex); return 1; } +} - ServiceCollection services = []; - services.AddSingleton(new ConfigurationBuilder().Build()); - services.AddSingleton(); - services.AddFeaturesBreweries(); - services.AddFeaturesAuth(); +static async Task> LoadUsersIntoDatabaseAsync( + IAuthRepository authRepository, + IPasswordInfrastructure passwordInfrastructure, + IReadOnlyList users +) +{ + List userAccountIds = []; - using ServiceProvider provider = services.BuildServiceProvider(); - - IBreweryRepository breweryRepository; - IAuthRepository authRepository; - IPasswordInfrastructure passwordInfrastructure; - try + foreach (UserRecord userRecord in users) { - breweryRepository = provider.GetRequiredService(); - authRepository = provider.GetRequiredService(); - passwordInfrastructure = provider.GetRequiredService(); - } - catch (InvalidOperationException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error configuring database connection: {ex.Message}"); - Console.ResetColor(); - return 1; + UserAccount userAccount = await authRepository.RegisterUserAsync( + new UserAccount() + { + FirstName = userRecord.User.FirstName, + LastName = userRecord.User.LastName, + DateOfBirth = DateTime.Parse(userRecord.DateOfBirth), + Username = userRecord.User.Username, + Email = userRecord.Email, + UserCredential = new UserCredential + { + Hash = passwordInfrastructure.Hash( + password: PasswordGenerator.Generate(64, 12, 12)) + } + } + ); + userAccountIds.Add(userAccount.UserAccountId); } - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"Loaded {breweries.Count} breweries."); - Console.ResetColor(); + return userAccountIds; +} + +/// +/// Persists each record, resolving (and creating, if needed) its +/// Country/StateProvince/City chain via . The pipeline +/// data has no street-level address or a specific user tied to each brewery, so a placeholder +/// address is used and PostedById is assigned round-robin from +/// to keep seeding deterministic across runs. +/// +static async Task LoadBreweriesIntoDatabaseAsync( + IBreweryRepository breweryRepository, + ILocationRepository locationRepository, + IReadOnlyList breweries, + IReadOnlyList posterUserIds +) +{ + if (posterUserIds.Count == 0) + throw new InvalidOperationException( + "Cannot load breweries without any registered users."); for (int i = 0; i < breweries.Count; i++) { - BreweryRecord brewery = breweries[i]; - Console.WriteLine( - $"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})" + BreweryRecord breweryRecord = breweries[i]; + + Guid cityId = await locationRepository.GetOrCreateCityIdAsync( + new CityLocation( + breweryRecord.Address.City.CityName, + breweryRecord.Address.City.StateProvince, + breweryRecord.Address.City.Iso31662, + breweryRecord.Address.City.Country, + breweryRecord.Address.City.Iso31661 + ) + ); + + await breweryRepository.CreateAsync( + new BreweryPost + { + BreweryPostId = Guid.NewGuid(), + BreweryName = breweryRecord.Brewery.NameEn, + Description = breweryRecord.Brewery.DescriptionEn, + PostedById = posterUserIds[i % posterUserIds.Count], + Location = new BreweryPostLocation + { + BreweryPostLocationId = Guid.NewGuid(), + CityId = cityId, + AddressLine1 = "Address unavailable", + PostalCode = breweryRecord.Address.PostalCode, + }, + } + ); + } +} + +static Table BuildBreweryTable(IReadOnlyList breweries) +{ + Table table = new Table() + .AddColumn("Brewery Name (EN)") + .AddColumn("Brewery Name (Local)") + .AddColumn("City") + .AddColumn("State/Province") + .AddColumn("Country") + .AddColumn("Postal Code"); + + foreach (BreweryRecord brewery in breweries) + { + table.AddRow( + brewery.Brewery.NameEn, + brewery.Brewery.NameLocal, + brewery.Address.City.CityName, + brewery.Address.City.StateProvince, + brewery.Address.City.Country, + brewery.Address.PostalCode ); } - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"Loaded {users.Count} users."); - Console.ResetColor(); + return table; +} + +static Table BuildUserTable(IReadOnlyList users) +{ + Table table = new Table() + .AddColumn("Username") + .AddColumn("First Name") + .AddColumn("Last Name") + .AddColumn("Email") + .AddColumn("Date of Birth"); foreach (UserRecord user in users) { - string username = user.User.Username; - string firstName = user.User.FirstName; - string lastName = user.User.LastName; - string email = user.Email; - DateTime dateOfBirth = DateTime.Parse(user.DateOfBirth); - - string generatedPassword = PasswordGenerator.Generate( - length: 64, - numberOfDigits: 10, - numberOfSymbols: 10 + table.AddRow( + user.User.Username, + user.User.FirstName, + user.User.LastName, + user.Email, + user.DateOfBirth.ToString() ); - string hashedPassword = passwordInfrastructure.Hash(generatedPassword); - - UserRegistrationDto registrationDto = new( - username, - firstName, - lastName, - email, - dateOfBirth, - hashedPassword - ); - - try - { - UserAccount createdUser = await authRepository.RegisterUserAsync(registrationDto); - Console.WriteLine( - $"Created user: {createdUser.Username} ({createdUser.UserAccountId})" - ); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error creating user {username}: {ex.Message}"); - Console.ResetColor(); - } } - return 0; -} - -return await main(); + return table; +} \ No newline at end of file diff --git a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs index 050e416b..7bf69964 100644 --- a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs +++ b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs @@ -1,13 +1,14 @@ -#region - using System.Text.Json; using Database.Seed.PipelineData; using Microsoft.Data.Sqlite; -#endregion - namespace Database.Seed.Sqlite; +public sealed record SeedData( + IReadOnlyList Breweries, + IReadOnlyList Users +); + public sealed class PipelineSeedDataReader { private readonly string _connectionString; @@ -17,13 +18,40 @@ public sealed class PipelineSeedDataReader _connectionString = connectionString; } - public IReadOnlyList ReadBreweryRecords() + /// + /// Reads breweries and users in a single connection, loading the cities + /// lookup only once and sharing it between both reads. + /// + public async Task ReadSeedDataAsync(CancellationToken cancellationToken = default) { - using SqliteConnection connection = new(_connectionString); - connection.Open(); + await using SqliteConnection connection = new(_connectionString); + await connection.OpenAsync(cancellationToken); - IReadOnlyDictionary cities = ReadCities(connection); + IReadOnlyDictionary cities = await ReadCitiesAsync( + connection, + cancellationToken + ); + IReadOnlyList breweries = await ReadBreweryRecordsAsync( + connection, + cities, + cancellationToken + ); + IReadOnlyList users = await ReadUserRecordsAsync( + connection, + cities, + cancellationToken + ); + + return new SeedData(breweries, users); + } + + private static async Task> ReadBreweryRecordsAsync( + SqliteConnection connection, + IReadOnlyDictionary cities, + CancellationToken cancellationToken + ) + { const string sql = """ SELECT b.name_en, b.description_en, b.name_local, b.description_local, ba.city_id, ba.postal_code @@ -31,25 +59,32 @@ public sealed class PipelineSeedDataReader JOIN brewery_addresses ba ON ba.brewery_id = b.id; """; - using SqliteCommand command = new(sql, connection); - using SqliteDataReader reader = command.ExecuteReader(); + await using SqliteCommand command = new(sql, connection); + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + + const int nameEnIndex = 0; + const int descriptionEnIndex = 1; + const int nameLocalIndex = 2; + const int descriptionLocalIndex = 3; + const int cityIdIndex = 4; + const int postalCodeIndex = 5; List records = []; - while (reader.Read()) + while (await reader.ReadAsync(cancellationToken)) records.Add( new BreweryRecord { Brewery = new BreweryResult { - NameEn = reader.GetString(0), - DescriptionEn = reader.GetString(1), - NameLocal = reader.GetString(2), - DescriptionLocal = reader.GetString(3), + NameEn = reader.GetString(nameEnIndex), + DescriptionEn = reader.GetString(descriptionEnIndex), + NameLocal = reader.GetString(nameLocalIndex), + DescriptionLocal = reader.GetString(descriptionLocalIndex), }, Address = new BreweryAddress { - City = cities[reader.GetInt64(4)], - PostalCode = reader.GetString(5), + City = cities[reader.GetInt64(cityIdIndex)], + PostalCode = reader.GetString(postalCodeIndex), }, } ); @@ -57,13 +92,12 @@ public sealed class PipelineSeedDataReader return records; } - public IReadOnlyList ReadUserRecords() + private static async Task> ReadUserRecordsAsync( + SqliteConnection connection, + IReadOnlyDictionary cities, + CancellationToken cancellationToken + ) { - using SqliteConnection connection = new(_connectionString); - connection.Open(); - - IReadOnlyDictionary cities = ReadCities(connection); - const string sql = """ SELECT u.first_name, u.last_name, u.gender, u.username, u.bio, u.activity_weight, u.email, u.date_of_birth, @@ -72,29 +106,40 @@ public sealed class PipelineSeedDataReader JOIN user_addresses ua ON ua.user_id = u.id; """; - using SqliteCommand command = new(sql, connection); - using SqliteDataReader reader = command.ExecuteReader(); + await using SqliteCommand command = new(sql, connection); + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + + const int firstNameIndex = 0; + const int lastNameIndex = 1; + const int genderIndex = 2; + const int usernameIndex = 3; + const int bioIndex = 4; + const int activityWeightIndex = 5; + const int emailIndex = 6; + const int dateOfBirthIndex = 7; + const int cityIdIndex = 8; + const int postalCodeIndex = 9; List records = []; - while (reader.Read()) + while (await reader.ReadAsync(cancellationToken)) records.Add( new UserRecord { User = new UserResult { - FirstName = reader.GetString(0), - LastName = reader.GetString(1), - Gender = reader.GetString(2), - Username = reader.GetString(3), - Bio = reader.GetString(4), - ActivityWeight = (float)reader.GetDouble(5), + FirstName = reader.GetString(firstNameIndex), + LastName = reader.GetString(lastNameIndex), + Gender = reader.GetString(genderIndex), + Username = reader.GetString(usernameIndex), + Bio = reader.GetString(bioIndex), + ActivityWeight = (float)reader.GetDouble(activityWeightIndex), }, - Email = reader.GetString(6), - DateOfBirth = reader.GetString(7), + Email = reader.GetString(emailIndex), + DateOfBirth = reader.GetString(dateOfBirthIndex), Address = new UserAddress { - City = cities[reader.GetInt64(8)], - PostalCode = reader.GetString(9), + City = cities[reader.GetInt64(cityIdIndex)], + PostalCode = reader.GetString(postalCodeIndex), }, } ); @@ -102,7 +147,10 @@ public sealed class PipelineSeedDataReader return records; } - private static IReadOnlyDictionary ReadCities(SqliteConnection connection) + private static async Task> ReadCitiesAsync( + SqliteConnection connection, + CancellationToken cancellationToken + ) { const string sql = """ SELECT @@ -118,33 +166,34 @@ public sealed class PipelineSeedDataReader FROM cities; """; - using SqliteCommand command = new(sql, connection); - using SqliteDataReader reader = command.ExecuteReader(); + await using SqliteCommand command = new(sql, connection); + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); Dictionary cities = []; - const int cityNameBindIndex = 1; - const int stateProvinceBindIndex = 2; - const int iso31662BindIndex = 3; - const int countryBindIndex = 4; - const int iso31661BindIndex = 5; - const int localLanguagesBindIndex = 6; - const int countryFormatRegexBindIndex = 7; - const int cityRegexesBindIndex = 8; + const int idIndex = 0; + const int cityNameIndex = 1; + const int stateProvinceIndex = 2; + const int iso31662Index = 3; + const int countryIndex = 4; + const int iso31661Index = 5; + const int localLanguagesIndex = 6; + const int countryFormatRegexIndex = 7; + const int cityRegexesIndex = 8; - while (reader.Read()) - cities[reader.GetInt64(0)] = new City + while (await reader.ReadAsync(cancellationToken)) + cities[reader.GetInt64(idIndex)] = new City { - CityName = reader.GetString(cityNameBindIndex), - StateProvince = reader.GetString(stateProvinceBindIndex), - Iso31662 = reader.GetString(iso31662BindIndex), - Country = reader.GetString(countryBindIndex), - Iso31661 = reader.GetString(iso31661BindIndex), - LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesBindIndex)), + CityName = reader.GetString(cityNameIndex), + StateProvince = reader.GetString(stateProvinceIndex), + Iso31662 = reader.GetString(iso31662Index), + Country = reader.GetString(countryIndex), + Iso31661 = reader.GetString(iso31661Index), + LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesIndex)), PostalCode = new PostalCodeSpec { - CountryFormatRegex = reader.GetString(countryFormatRegexBindIndex), - CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesBindIndex)), + CountryFormatRegex = reader.GetString(countryFormatRegexIndex), + CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)), }, }; diff --git a/web/backend/Database/Program.cs b/web/backend/Database/Program.cs deleted file mode 100644 index 1eff3e2c..00000000 --- a/web/backend/Database/Program.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Database.Seed; - -public interface IExportDatabase -{ - void SeedLocations(IEnumerable locations); - void SeedUsers(IEnumerable users); - - void SeedBreweries(IEnumerable breweries); -} - -public interface IImportDatabase -{ - IEnumerable GetLocations(); - IEnumerable GetUsers(); - - IEnumerable GetBreweries(); -} diff --git a/web/backend/Domain/Domain.Entities/Entities/BeerPost.cs b/web/backend/Domain/Domain.Entities/Entities/BeerPost.cs new file mode 100644 index 00000000..b8f74994 --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/BeerPost.cs @@ -0,0 +1,29 @@ +namespace Domain.Entities; + +public sealed class BeerPost +{ + // PK + public Guid BeerPostId { get; set; } = Guid.Empty; + + // FK References + public Guid PostedById { get; set; } = Guid.Empty; + public Guid BeerStyleId { get; set; } = Guid.Empty; + public Guid BrewedById { get; set; } = Guid.Empty; + + // Attributes + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal ABV { get; set; } + public int IBU { get; set; } + + // Audit Fields + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public byte[]? RowVersion { get; set; } + + // Navigation Properties + public UserAccount? PostedBy { get; set; } + public BreweryPost? BrewedBy { get; set; } + + public BeerStyle? BeerStyle { get; set; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/BeerStyle.cs b/web/backend/Domain/Domain.Entities/Entities/BeerStyle.cs new file mode 100644 index 00000000..1b10e4f9 --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/BeerStyle.cs @@ -0,0 +1,16 @@ +namespace Domain.Entities; + +public sealed class BeerStyle +{ + // PK + public Guid BeerStyleId { get; set; } = Guid.Empty; + + // Attributes + public string BeerStyleName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + // Audit Fields + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public byte[]? RowVersion { get; set; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs index 7d98541d..4812ffa1 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -1,14 +1,23 @@ namespace Domain.Entities; -public class BreweryPost +public sealed class BreweryPost { + // PK public Guid BreweryPostId { get; set; } + + // FK References public Guid PostedById { get; set; } + + // Attributes public string BreweryName { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; + + // Audit Fields public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } - public byte[]? Timer { get; set; } - public UserAccount PostedBy { get; set; } = null!; + public byte[]? RowVersion { get; set; } + + // Navigation Properties public BreweryPostLocation? Location { get; set; } + public List BeerPosts { get; set; } = []; } diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs index 0679047c..5f8936c2 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -1,14 +1,23 @@ namespace Domain.Entities; -public class BreweryPostLocation +public sealed class BreweryPostLocation { + // PK public Guid BreweryPostLocationId { get; set; } + + // FK References public Guid BreweryPostId { get; set; } + public Guid CityId { get; set; } + + // Attributes public string AddressLine1 { get; set; } = string.Empty; public string? AddressLine2 { get; set; } public string PostalCode { get; set; } = string.Empty; - public Guid CityId { get; set; } public byte[]? Coordinates { get; set; } - public byte[]? Timer { get; set; } - public BreweryPost BreweryPost { get; set; } = null!; + + // Audit Fields + public byte[]? RowVersion { get; set; } + + // Navigation Properties + public City? City { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/City.cs b/web/backend/Domain/Domain.Entities/Entities/City.cs new file mode 100644 index 00000000..714ca8b1 --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/City.cs @@ -0,0 +1,19 @@ +namespace Domain.Entities; + +public sealed class City +{ + // PK + public Guid CityId { get; set; } + + // FK References + public Guid StateProvinceId { get; set; } + + // Attributes + public string CityName { get; set; } = string.Empty; + + // Audit Fields + public byte[]? RowVersion { get; set; } + + // Navigation Properties + public StateProvince? StateProvince { get; set; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/Country.cs b/web/backend/Domain/Domain.Entities/Entities/Country.cs new file mode 100644 index 00000000..81a896a0 --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/Country.cs @@ -0,0 +1,15 @@ +namespace Domain.Entities; + +public sealed class Country +{ + // PK + public Guid CountryId { get; set; } + + // Attributes + public string CountryName { get; set; } = string.Empty; + + public string ISO3166_1 { get; set; } = string.Empty; // this is the iso 3166-1 alpha-2 code for the country, e.g. "US" for United States, "CA" for Canada, etc. + + // Audit Fields + public byte[]? RowVersion { get; set; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/StateProvince.cs b/web/backend/Domain/Domain.Entities/Entities/StateProvince.cs new file mode 100644 index 00000000..e248f438 --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/StateProvince.cs @@ -0,0 +1,23 @@ +namespace Domain.Entities; + +public sealed class StateProvince +{ + // PK + public Guid StateProvinceId { get; set; } + + // FK References + public Guid CountryId { get; set; } + + // Attributes + public string StateProvinceName { get; set; } = string.Empty; + + // this is the iso 3166-2 code for the state/province, e.g. "CA" for California, "ON" for Ontario, etc. + // and is rendered like US-CA for California, CA-ON for Ontario, etc. + public string ISO3166_2 { get; set; } = string.Empty; + + // Audit Fields + public byte[]? RowVersion { get; set; } + + // Navigation Properties + public Country? Country { get; set; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs index 2931c773..e4dab7c6 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs @@ -1,17 +1,23 @@ namespace Domain.Entities; -public class UserAccount +public sealed class UserAccount { + // PK public Guid UserAccountId { get; set; } + + // Attributes public string Username { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } + + // Audit Fields public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } - public DateTime DateOfBirth { get; set; } - public byte[]? Timer { get; set; } - public ICollection BreweryPosts { get; set; } = []; - public ICollection UserCredentials { get; set; } = []; - public UserVerification? Verification { get; set; } + public byte[]? RowVersion { get; set; } + + // Navigation properties + public UserCredential? UserCredential { get; set; } + } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs index 4e1b7b8d..19e5fb07 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs @@ -1,12 +1,20 @@ namespace Domain.Entities; -public class UserCredential +public sealed class UserCredential { + // PK public Guid UserCredentialId { get; set; } + + // FK References public Guid UserAccountId { get; set; } - public DateTime CreatedAt { get; set; } + + // Attributes public DateTime Expiry { get; set; } public string Hash { get; set; } = string.Empty; - public byte[]? Timer { get; set; } - public UserAccount UserAccount { get; set; } = null!; + public bool IsRevoked { get; set; } + public DateTime? RevokedAt { get; set; } + + // Audit Fields + public DateTime CreatedAt { get; set; } + public byte[]? RowVersion { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs index 651981c0..4fe229a1 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs @@ -1,10 +1,16 @@ namespace Domain.Entities; -public class UserVerification +public sealed class UserVerification { + // PK public Guid UserVerificationId { get; set; } + + // FK References public Guid UserAccountId { get; set; } + + // Attributes public DateTime VerificationDateTime { get; set; } - public byte[]? Timer { get; set; } - public UserAccount UserAccount { get; set; } = null!; + + // Audit Fields + public byte[]? RowVersion { get; set; } } diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs index 954c5374..d9bde15c 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs @@ -68,13 +68,13 @@ public class RegisterUserHandlerTests _authRepoMock .Setup(x => x.RegisterUserAsync( - new UserRegistrationDto( - command.Username, - command.FirstName, - command.LastName, - command.Email, - command.DateOfBirth, - hashedPassword + It.Is(ua => + ua.FirstName == command.FirstName + && ua.LastName == command.LastName + && ua.Username == command.Username + && ua.Email == command.Email + && ua.DateOfBirth == command.DateOfBirth + && ua.UserCredential!.Hash == hashedPassword ) ) ) @@ -141,7 +141,7 @@ public class RegisterUserHandlerTests .WithMessage("Username or email already exists"); _authRepoMock.Verify( - x => x.RegisterUserAsync(It.IsAny()), + x => x.RegisterUserAsync(It.IsAny()), Times.Never ); } @@ -186,7 +186,7 @@ public class RegisterUserHandlerTests _authRepoMock .Setup(x => x.RegisterUserAsync( - It.Is(dto => dto.PasswordHash == hashedPassword) + It.Is(ua => ua.UserCredential!.Hash == hashedPassword) ) ) .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); @@ -204,13 +204,13 @@ public class RegisterUserHandlerTests _authRepoMock.Verify( x => x.RegisterUserAsync( - new UserRegistrationDto( - command.Username, - command.FirstName, - command.LastName, - command.Email, - command.DateOfBirth, - hashedPassword + It.Is(ua => + ua.FirstName == command.FirstName + && ua.LastName == command.LastName + && ua.Username == command.Username + && ua.DateOfBirth == command.DateOfBirth + && ua.Email == command.Email + && ua.UserCredential!.Hash == hashedPassword ) ), Times.Once @@ -230,7 +230,7 @@ public class RegisterUserHandlerTests .ReturnsAsync((UserAccount?)null); _passwordInfraMock.Setup(x => x.Hash(It.IsAny())).Returns("hashed"); _authRepoMock - .Setup(x => x.RegisterUserAsync(It.IsAny())) + .Setup(x => x.RegisterUserAsync(It.IsAny())) .ReturnsAsync( new UserAccount { diff --git a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj index 7a858bbd..7c743e53 100644 --- a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj +++ b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj @@ -12,8 +12,7 @@ - - + diff --git a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs index 6fce931c..9121cdcf 100644 --- a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs @@ -1,7 +1,7 @@ using Domain.Entities; using Domain.Exceptions; +using Features.Auth.Commands.Login; using Features.Auth.Dtos; -using Features.Auth.Queries.Login; using Features.Auth.Repository; using Features.Auth.Services; using FluentAssertions; @@ -68,7 +68,7 @@ public class LoginHandlerTests .Returns("refresh-token"); LoginPayload result = await _handler.Handle( - new LoginQuery(username, "any-password"), + new LoginCommand(username, "any-password"), CancellationToken.None ); @@ -88,7 +88,7 @@ public class LoginHandlerTests .ReturnsAsync((UserAccount?)null); Func> act = async () => - await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); + await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); await act.Should().ThrowAsync(); _authRepoMock.Verify( @@ -110,7 +110,7 @@ public class LoginHandlerTests .ReturnsAsync((UserCredential?)null); Func> act = async () => - await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); + await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); await act.Should() .ThrowAsync() @@ -144,7 +144,7 @@ public class LoginHandlerTests Func> act = async () => await _handler.Handle( - new LoginQuery(username, "wrong-password"), + new LoginCommand(username, "wrong-password"), CancellationToken.None ); diff --git a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs deleted file mode 100644 index 297241c7..00000000 --- a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs +++ /dev/null @@ -1,246 +0,0 @@ -using Apps72.Dev.Data.DbMocker; -using Domain.Entities; -using Features.Auth.Dtos; -using Features.Auth.Repository; -using FluentAssertions; - -namespace Features.Auth.Tests.Repository; - -public class AuthRepositoryTests -{ - private static AuthRepository CreateRepo(MockDbConnection conn) - { - return new AuthRepository(new TestConnectionFactory(conn)); - } - - [Fact] - public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount() - { - Guid expectedUserId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId); - - // Mock the subsequent read for the newly created user by id - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById") - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow( - expectedUserId, - "testuser", - "Test", - "User", - "test@example.com", - DateTime.UtcNow, - null, - new DateTime(1990, 1, 1), - null - ) - ); - - AuthRepository repo = CreateRepo(conn); - UserAccount result = await repo.RegisterUserAsync( - new UserRegistrationDto( - "testuser", - "Test", - "User", - "test@example.com", - new DateTime(1990, 1, 1), - "hashedpassword123" - ) - ); - - result.Should().NotBeNull(); - result.UserAccountId.Should().Be(expectedUserId); - result.Username.Should().Be("testuser"); - result.FirstName.Should().Be("Test"); - result.LastName.Should().Be("User"); - result.Email.Should().Be("test@example.com"); - result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1)); - } - - [Fact] - public async Task GetUserByEmailAsync_ReturnsUser_WhenExists() - { - Guid userId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow( - userId, - "emailuser", - "Email", - "User", - "emailuser@example.com", - DateTime.UtcNow, - null, - new DateTime(1990, 5, 15), - null - ) - ); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com"); - - result.Should().NotBeNull(); - result!.UserAccountId.Should().Be(userId); - result.Username.Should().Be("emailuser"); - result.Email.Should().Be("emailuser@example.com"); - result.FirstName.Should().Be("Email"); - result.LastName.Should().Be("User"); - } - - [Fact] - public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists() - { - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") - .ReturnsTable(MockTable.Empty()); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com"); - - result.Should().BeNull(); - } - - [Fact] - public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists() - { - Guid userId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow( - userId, - "usernameuser", - "Username", - "User", - "username@example.com", - DateTime.UtcNow, - null, - new DateTime(1985, 8, 20), - null - ) - ); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser"); - - result.Should().NotBeNull(); - result!.UserAccountId.Should().Be(userId); - result.Username.Should().Be("usernameuser"); - result.Email.Should().Be("username@example.com"); - } - - [Fact] - public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists() - { - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") - .ReturnsTable(MockTable.Empty()); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent"); - - result.Should().BeNull(); - } - - [Fact] - public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists() - { - Guid userId = Guid.NewGuid(); - Guid credentialId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId") - .ReturnsTable( - MockTable - .WithColumns( - ("UserCredentialId", typeof(Guid)), - ("UserAccountId", typeof(Guid)), - ("Hash", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null) - ); - - AuthRepository repo = CreateRepo(conn); - UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); - - result.Should().NotBeNull(); - result!.UserCredentialId.Should().Be(credentialId); - result.UserAccountId.Should().Be(userId); - result.Hash.Should().Be("hashed_password_value"); - } - - [Fact] - public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists() - { - Guid userId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId") - .ReturnsTable(MockTable.Empty()); - - AuthRepository repo = CreateRepo(conn); - UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); - - result.Should().BeNull(); - } - - [Fact] - public async Task RotateCredentialAsync_ExecutesSuccessfully() - { - Guid userId = Guid.NewGuid(); - string newPasswordHash = "new_hashed_password"; - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1); - - AuthRepository repo = CreateRepo(conn); - - // Should not throw - Func act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash); - await act.Should().NotThrowAsync(); - } -} diff --git a/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs deleted file mode 100644 index 95e816df..00000000 --- a/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; -using Infrastructure.Sql; - -namespace Features.Auth.Tests.Repository; - -internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory -{ - private readonly DbConnection _conn = conn; - - public DbConnection CreateConnection() - { - return _conn; - } -} diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs b/web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs similarity index 64% rename from web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs rename to web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs index 081e6aa9..56132e14 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs +++ b/web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs @@ -1,10 +1,10 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Queries.Login; +namespace Features.Auth.Commands.Login; /// /// Authenticates a user using their username and password and issues new tokens. Bound directly /// from the request body of POST /api/auth/login. /// -public record LoginQuery(string Username, string Password) : IRequest; +public record LoginCommand(string Username, string Password) : IRequest; diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs b/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs similarity index 83% rename from web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs rename to web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs index 2cd27f59..9921610a 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs @@ -6,22 +6,22 @@ using Features.Auth.Services; using Infrastructure.PasswordHashing; using MediatR; -namespace Features.Auth.Queries.Login; +namespace Features.Auth.Commands.Login; /// -/// Handles by verifying credentials and issuing access/refresh tokens. +/// Handles by verifying credentials and issuing access/refresh tokens. /// public class LoginHandler( IAuthRepository authRepo, IPasswordInfrastructure passwordInfrastructure, ITokenService tokenService -) : IRequestHandler +) : IRequestHandler { /// /// Thrown when the username does not match any account, the account has no active credential, /// or the supplied password does not match the stored hash. /// - public async Task Handle(LoginQuery request, CancellationToken cancellationToken) + public async Task Handle(LoginCommand request, CancellationToken cancellationToken) { UserAccount user = await authRepo.GetUserByUsernameAsync(request.Username) diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs b/web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs similarity index 50% rename from web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs rename to web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs index c90e9fb7..ed0703a3 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs +++ b/web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs @@ -1,8 +1,9 @@ using FluentValidation; -namespace Features.Auth.Queries.Login; +namespace Features.Auth.Commands.Login; -public class LoginValidator : AbstractValidator +/// Validates that and are present. +public class LoginValidator : AbstractValidator { public LoginValidator() { diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs index 4b311b73..6f0a520b 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs @@ -11,6 +11,10 @@ namespace Features.Auth.Commands.RefreshToken; public class RefreshTokenHandler(ITokenService tokenService) : IRequestHandler { + /// + /// Thrown when the refresh token is invalid or expired, or when the user account it refers to no + /// longer exists. + /// public async Task Handle( RefreshTokenCommand request, CancellationToken cancellationToken diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs index 19489ddf..d25bcf68 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs @@ -2,6 +2,7 @@ using FluentValidation; namespace Features.Auth.Commands.RefreshToken; +/// Validates that is present. public class RefreshTokenValidator : AbstractValidator { public RefreshTokenValidator() diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs index cf475383..90c03a73 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -34,14 +34,18 @@ public class RegisterUserHandler( string hashed = passwordInfrastructure.Hash(request.Password); UserAccount createdUser = await authRepo.RegisterUserAsync( - new UserRegistrationDto( - request.Username, - request.FirstName, - request.LastName, - request.Email, - request.DateOfBirth, - hashed - ) + new UserAccount + { + Username = request.Username, + FirstName = request.FirstName, + LastName = request.LastName, + DateOfBirth = request.DateOfBirth, + Email = request.Email, + UserCredential = new UserCredential + { + Hash = hashed, + } + } ); string accessToken = tokenService.GenerateAccessToken(createdUser); diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs index de31bddf..10e1075a 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs @@ -2,6 +2,10 @@ using FluentValidation; namespace Features.Auth.Commands.RegisterUser; +/// +/// Validates the field-level constraints on : username format and +/// length, name lengths, email format, minimum age of 19, and password complexity. +/// public class RegisterUserValidator : AbstractValidator { public RegisterUserValidator() diff --git a/web/backend/Features/Features.Auth/Controllers/AuthController.cs b/web/backend/Features/Features.Auth/Controllers/AuthController.cs index 2e0aa868..92de4b50 100644 --- a/web/backend/Features/Features.Auth/Controllers/AuthController.cs +++ b/web/backend/Features/Features.Auth/Controllers/AuthController.cs @@ -1,9 +1,9 @@ using Features.Auth.Commands.ConfirmUser; +using Features.Auth.Commands.Login; using Features.Auth.Commands.RefreshToken; using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.ResendConfirmationEmail; using Features.Auth.Dtos; -using Features.Auth.Queries.Login; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -23,6 +23,11 @@ namespace Features.Auth.Controllers; [Authorize(AuthenticationSchemes = "JWT")] public class AuthController(IMediator mediator) : ControllerBase { + /// Registers a new user account. + /// + /// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens. + /// Returns 409 Conflict if the username or email is already taken. + /// [AllowAnonymous] [HttpPost("register")] public async Task>> Register( @@ -40,11 +45,15 @@ public class AuthController(IMediator mediator) : ControllerBase ); } + /// Authenticates a user by username and password and issues a new access/refresh token pair. + /// + /// Anonymous access. Returns 200 OK with the tokens, or 401 Unauthorized if the credentials are invalid. + /// [AllowAnonymous] [HttpPost("login")] - public async Task>> Login([FromBody] LoginQuery query) + public async Task>> Login([FromBody] LoginCommand command) { - LoginPayload payload = await mediator.Send(query); + LoginPayload payload = await mediator.Send(command); return Ok( new ResponseBody { @@ -54,6 +63,11 @@ public class AuthController(IMediator mediator) : ControllerBase ); } + /// Confirms a user account using a confirmation token. + /// + /// Requires a valid JWT bearer token (no [AllowAnonymous] override). Returns 200 OK on success, + /// or 401 Unauthorized if is invalid or expired. + /// [HttpPost("confirm")] public async Task>> Confirm( [FromQuery] string token @@ -69,6 +83,12 @@ public class AuthController(IMediator mediator) : ControllerBase ); } + /// Resends the account confirmation email for the given user, generating a fresh confirmation token. + /// + /// Requires a valid JWT bearer token (no [AllowAnonymous] override). Always returns 200 OK, even + /// if does not exist or the account is already confirmed, to avoid user + /// enumeration. + /// [HttpPost("confirm/resend")] public async Task> ResendConfirmation([FromQuery] Guid userId) { @@ -76,6 +96,11 @@ public class AuthController(IMediator mediator) : ControllerBase return Ok(new ResponseBody { Message = "confirmation email has been resent" }); } + /// Exchanges a valid refresh token for a new access/refresh token pair. + /// + /// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is + /// invalid or expired. + /// [AllowAnonymous] [HttpPost("refresh")] public async Task>> Refresh( diff --git a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs index c3181b51..d4a27b7d 100644 --- a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs @@ -5,8 +5,13 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Auth.DependencyInjection; +/// Registers the services required by Features.Auth with the dependency injection container. public static class FeaturesAuthServiceCollectionExtensions { + /// + /// Adds , , and the Argon2 password hashing + /// infrastructure as scoped services. + /// public static IServiceCollection AddFeaturesAuth(this IServiceCollection services) { services.AddScoped(); diff --git a/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs b/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs deleted file mode 100644 index 5a637567..00000000 --- a/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Features.Auth.Dtos; - -public record UserRegistrationDto( - string Username, - string FirstName, - string LastName, - string Email, - DateTime DateOfBirth, - string PasswordHash -); diff --git a/web/backend/Features/Features.Auth/Features.Auth.csproj b/web/backend/Features/Features.Auth/Features.Auth.csproj index 3710b91e..7a6ad02a 100644 --- a/web/backend/Features/Features.Auth/Features.Auth.csproj +++ b/web/backend/Features/Features.Auth/Features.Auth.csproj @@ -3,6 +3,7 @@ net10.0 enable enable + Features.Auth @@ -19,4 +20,8 @@ + + + + diff --git a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs index ba6a9725..e8149b2c 100644 --- a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs @@ -1,6 +1,7 @@ -using System.Data; using System.Data.Common; +using Dapper; using Domain.Entities; +using Domain.Exceptions; using Features.Auth.Dtos; using Infrastructure.Sql; using Microsoft.Data.SqlClient; @@ -8,155 +9,184 @@ using Microsoft.Data.SqlClient; namespace Features.Auth.Repository; /// -/// ADO.NET-based implementation of backed by SQL Server stored procedures, -/// handling user registration, credential lookup/rotation, and account verification. +/// Dapper-based implementation of , handling user registration, +/// credential lookup/rotation, and account verification. /// public class AuthRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IAuthRepository { - /// - /// Registers a new user account and initial credential using the USP_RegisterUser stored - /// procedure, then fetches and returns the newly created user. - /// - /// - /// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively: - /// it may be returned as a , a parseable , or a 16-byte array. - /// If the result cannot be interpreted, is used, which will cause the - /// subsequent lookup to fail. - /// - /// Thrown when the newly registered user cannot be retrieved after registration. - public async Task RegisterUserAsync(UserRegistrationDto userRegistrationDto) + /// + /// The account and credential are inserted within a single database transaction. + public async Task RegisterUserAsync(UserAccount ua) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); - command.CommandText = "USP_RegisterUser"; - command.CommandType = CommandType.StoredProcedure; - var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto; + Guid userAccountId; - AddParameter(command, "@Username", username); - AddParameter(command, "@FirstName", firstName); - AddParameter(command, "@LastName", lastName); - AddParameter(command, "@Email", email); - AddParameter(command, "@DateOfBirth", dateOfBirth); - AddParameter(command, "@Hash", passwordHash); - - object? result = await command.ExecuteScalarAsync(); - - Guid userAccountId = Guid.Empty; - if (result != null && result != DBNull.Value) + try { - if (result is Guid g) - userAccountId = g; - else if (result is string s && Guid.TryParse(s, out Guid parsed)) - userAccountId = parsed; - else if (result is byte[] bytes && bytes.Length == 16) - userAccountId = new Guid(bytes); - else - // Fallback: try to convert and parse string representation - try - { - string? str = result.ToString(); - if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p)) - userAccountId = p; - } - catch - { - userAccountId = Guid.Empty; - } + userAccountId = await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email) + OUTPUT INSERTED.UserAccountID + VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email); + """, + new { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email}, + transaction + ) + ); + + int credentialRows = await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserCredential (UserAccountId, Hash) + VALUES (@UserAccountId, @Hash); + """, + new { UserAccountId = userAccountId, Hash = ua.UserCredential?.Hash }, + transaction + ) + ); + + if (credentialRows == 0) + throw new InvalidOperationException("Failed to create user credential."); + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; } + // perform round trip to get new User entity return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user."); } - /// Retrieves a user account by email using the usp_GetUserAccountByEmail stored procedure. + /// public async Task GetUserByEmailAsync(string email) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_GetUserAccountByEmail"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@Email", email); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Email = @Email + """, + new { Email = email } + ); } - /// Retrieves a user account by username using the usp_GetUserAccountByUsername stored procedure. + /// public async Task GetUserByUsernameAsync(string username) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_GetUserAccountByUsername"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@Username", username); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Username = @Username + """, + new { Username = username } + ); } - /// - /// Retrieves the active (non-revoked) credential for a user account using the - /// USP_GetActiveUserCredentialByUserAccountId stored procedure. - /// + /// public async Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@UserAccountId", userAccountId); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null; + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, RowVersion + FROM dbo.UserCredential + WHERE UserAccountId = @UserAccountId AND IsRevoked = 0 + """, + new { UserAccountId = userAccountId } + ); } - /// - /// Rotates a user's credential by invalidating all existing credentials and creating a new one, - /// using the USP_RotateUserCredential stored procedure. - /// + /// public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "USP_RotateUserCredential"; - command.CommandType = CommandType.StoredProcedure; + await using DbTransaction transaction = await connection.BeginTransactionAsync(); - AddParameter(command, "@UserAccountId_", userAccountId); - AddParameter(command, "@Hash", newPasswordHash); + try + { + bool exists = + await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT 1 + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = userAccountId }, + transaction + ) + ) is not null; - await command.ExecuteNonQueryAsync(); + if (!exists) + throw new NotFoundException("User account not found."); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.UserCredential + SET IsRevoked = 1, RevokedAt = GETDATE() + WHERE UserAccountId = @UserAccountId + """, + new { UserAccountId = userAccountId }, + transaction + ) + ); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserCredential (UserAccountId, Hash) + VALUES (@UserAccountId, @Hash) + """, + new { UserAccountId = userAccountId, Hash = newPasswordHash }, + transaction + ) + ); + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } } - /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. + /// public async Task GetUserByIdAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_GetUserAccountById"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@UserAccountId", userAccountId); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = userAccountId } + ); } - /// - /// Marks a user account as confirmed by creating a verification record via the - /// USP_CreateUserVerification stored procedure. If the user is already verified, this is a - /// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user - /// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed. - /// + /// + /// + /// Confirmation is recorded by inserting a row into dbo.UserVerification. If a concurrent + /// request verifies the same user first, the resulting duplicate-key SQL exception (error 2601/2627) + /// is swallowed to keep the operation idempotent. + /// /// - /// Thrown when the database command fails for a reason other than - /// a duplicate verification record. + /// Thrown when the database command fails for a reason other than a duplicate verification record. /// public async Task ConfirmUserAccountAsync(Guid userAccountId) { @@ -169,15 +199,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return user; await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "USP_CreateUserVerification"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@UserAccountID_", userAccountId); try { - await command.ExecuteNonQueryAsync(); + await connection.ExecuteAsync( + """ + INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) + VALUES (@UserAccountId, GETDATE()) + """, + new { UserAccountId = userAccountId } + ); } catch (SqlException ex) when (IsDuplicateVerificationViolation(ex)) { @@ -188,19 +219,19 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await GetUserByIdAsync(userAccountId); } - /// Checks whether a user account has been verified by querying the dbo.UserVerification table. + /// public async Task IsUserVerifiedAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = - "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID"; - command.CommandType = CommandType.Text; - - AddParameter(command, "@UserAccountID", userAccountId); - - object? result = await command.ExecuteScalarAsync(); - return result != null && result != DBNull.Value; + int? result = await connection.ExecuteScalarAsync( + """ + SELECT TOP 1 1 + FROM dbo.UserVerification + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = userAccountId } + ); + return result.HasValue; } /// Determines whether a is a duplicate key violation (SQL Server error 2601 or 2627). @@ -209,67 +240,4 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) // 2601/2627 are duplicate key violations in SQL Server. return ex.Number == 2601 || ex.Number == 2627; } - - protected override UserAccount MapToEntity(DbDataReader reader) - { - return new UserAccount - { - UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), - Username = reader.GetString(reader.GetOrdinal("Username")), - FirstName = reader.GetString(reader.GetOrdinal("FirstName")), - LastName = reader.GetString(reader.GetOrdinal("LastName")), - Email = reader.GetString(reader.GetOrdinal("Email")), - CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), - UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt")) - ? null - : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), - DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), - Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"], - }; - } - - /// - /// Maps a data reader row to a UserCredential entity. The Timer column is mapped only if - /// present in the reader's schema, allowing this method to support result sets that omit it. - /// - private static UserCredential MapToCredentialEntity(DbDataReader reader) - { - UserCredential entity = new() - { - UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")), - UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), - Hash = reader.GetString(reader.GetOrdinal("Hash")), - CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), - }; - - // Optional columns - bool hasTimer = - reader - .GetSchemaTable() - ?.Rows.Cast() - .Any(r => - string.Equals( - r["ColumnName"]?.ToString(), - "Timer", - StringComparison.OrdinalIgnoreCase - ) - ) - ?? false; - - if (hasTimer) - entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) - ? null - : (byte[])reader["Timer"]; - - return entity; - } - - /// Adds a parameter to a database command, converting null values to . - private static void AddParameter(DbCommand command, string name, object? value) - { - DbParameter p = command.CreateParameter(); - p.ParameterName = name; - p.Value = value ?? DBNull.Value; - command.Parameters.Add(p); - } } diff --git a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs index c5ac13a0..240e0db9 100644 --- a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs @@ -8,21 +8,32 @@ namespace Features.Auth.Repository; /// public interface IAuthRepository { - Task RegisterUserAsync(UserRegistrationDto userRegistrationDto); + /// + /// Registers a new user account and its initial credential as a single atomic operation, then + /// returns the created account. + /// + /// Thrown when the newly registered user cannot be retrieved after registration. + Task RegisterUserAsync(UserAccount ua); + /// Retrieves a user account by email, or if none exists. Task GetUserByEmailAsync(string email); + /// Retrieves a user account by username, or if none exists. Task GetUserByUsernameAsync(string username); + /// Retrieves the active (non-revoked) credential for a user account, or if none exists. Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); /// Rotates a user's credential by invalidating all existing credentials and creating a new one. + /// Thrown when no account with exists. Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); /// Marks a user account as confirmed. Idempotent if the account is already verified. Task ConfirmUserAccountAsync(Guid userAccountId); + /// Retrieves a user account by ID, or if none exists. Task GetUserByIdAsync(Guid userAccountId); + /// Determines whether a user account has been verified. Task IsUserVerifiedAsync(Guid userAccountId); } diff --git a/web/backend/Features/Features.Auth/Services/ITokenService.cs b/web/backend/Features/Features.Auth/Services/ITokenService.cs index 5ac81ffc..d953d475 100644 --- a/web/backend/Features/Features.Auth/Services/ITokenService.cs +++ b/web/backend/Features/Features.Auth/Services/ITokenService.cs @@ -29,6 +29,7 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s /// public static class TokenServiceExpirationHours { + /// 1 hour. public const double AccessTokenHours = 1; /// 21 days. @@ -43,10 +44,22 @@ public static class TokenServiceExpirationHours /// public interface ITokenService { + /// + /// Generates a signed access token for the given user, expiring after + /// hours. + /// string GenerateAccessToken(UserAccount user); + /// + /// Generates a signed refresh token for the given user, expiring after + /// hours. + /// string GenerateRefreshToken(UserAccount user); + /// + /// Generates a signed confirmation token for the given user, expiring after + /// hours. + /// string GenerateConfirmationToken(UserAccount user); /// Generates a token of the type specified by . @@ -54,12 +67,28 @@ public interface ITokenService string GenerateToken(UserAccount user) where T : struct, Enum; + /// Validates an access token's signature and expiration and extracts the caller's identity. + /// + /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. + /// Task ValidateAccessTokenAsync(string token); + /// Validates a refresh token's signature and expiration and extracts the caller's identity. + /// + /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. + /// Task ValidateRefreshTokenAsync(string token); + /// Validates a confirmation token's signature and expiration and extracts the caller's identity. + /// + /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. + /// Task ValidateConfirmationTokenAsync(string token); /// Validates a refresh token and issues a new access/refresh token pair for the associated user. + /// + /// Thrown when the refresh token is invalid or expired, or when the user account it refers to no + /// longer exists. + /// Task RefreshTokenAsync(string refreshTokenString); } diff --git a/web/backend/Features/Features.Auth/Services/TokenService.cs b/web/backend/Features/Features.Auth/Services/TokenService.cs index 8d389bab..c3aa40a7 100644 --- a/web/backend/Features/Features.Auth/Services/TokenService.cs +++ b/web/backend/Features/Features.Auth/Services/TokenService.cs @@ -48,6 +48,7 @@ public class TokenService : ITokenService ); } + /// public string GenerateAccessToken(UserAccount user) { DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours); @@ -59,6 +60,7 @@ public class TokenService : ITokenService ); } + /// public string GenerateRefreshToken(UserAccount user) { DateTime expiresAt = DateTime.UtcNow.AddHours( @@ -72,6 +74,7 @@ public class TokenService : ITokenService ); } + /// public string GenerateConfirmationToken(UserAccount user) { DateTime expiresAt = DateTime.UtcNow.AddHours( @@ -85,12 +88,7 @@ public class TokenService : ITokenService ); } - /// - /// Generates a token of the kind specified by , dispatching to - /// , , or - /// . - /// - /// Must be . + /// /// /// Thrown when is not or does not resolve to a known token type. /// @@ -114,33 +112,25 @@ public class TokenService : ITokenService }; } - /// - /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. - /// + /// public async Task ValidateAccessTokenAsync(string token) { return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); } - /// - /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. - /// + /// public async Task ValidateRefreshTokenAsync(string token) { return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); } - /// - /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. - /// + /// public async Task ValidateConfirmationTokenAsync(string token) { return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); } - /// - /// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists. - /// + /// public async Task RefreshTokenAsync(string refreshTokenString) { ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString); diff --git a/web/backend/Features/Features.Beers/Features.Beers.csproj b/web/backend/Features/Features.Beers/Features.Beers.csproj new file mode 100644 index 00000000..d7c3fe27 --- /dev/null +++ b/web/backend/Features/Features.Beers/Features.Beers.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + Features.Breweries + + + + + + + + + + + + + + diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs index c0871984..ddbe0bc9 100644 --- a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs @@ -21,6 +21,7 @@ public class UpdateBreweryHandlerTests { UpdateBreweryCommand command = new( Guid.NewGuid(), + [0x01, 0x02], Guid.NewGuid(), "Renamed", "New description", @@ -31,7 +32,7 @@ public class UpdateBreweryHandlerTests _repoMock .Setup(r => r.UpdateAsync(It.IsAny())) .Callback(b => persisted = b) - .Returns(Task.CompletedTask); + .ReturnsAsync(() => persisted!); DateTime before = DateTime.UtcNow; await _handler.Handle(command, CancellationToken.None); @@ -42,6 +43,7 @@ public class UpdateBreweryHandlerTests persisted.PostedById.Should().Be(command.PostedById); persisted.BreweryName.Should().Be("Renamed"); persisted.Description.Should().Be("New description"); + persisted.RowVersion.Should().Equal(command.RowVersion); persisted.UpdatedAt.Should().NotBeNull(); persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); } @@ -51,6 +53,7 @@ public class UpdateBreweryHandlerTests { UpdateBreweryCommand command = new( Guid.NewGuid(), + [0x01, 0x02], Guid.NewGuid(), "Name", "Description", @@ -61,7 +64,7 @@ public class UpdateBreweryHandlerTests _repoMock .Setup(r => r.UpdateAsync(It.IsAny())) .Callback(b => persisted = b) - .Returns(Task.CompletedTask); + .ReturnsAsync(() => persisted!); await _handler.Handle(command, CancellationToken.None); @@ -81,6 +84,7 @@ public class UpdateBreweryHandlerTests ); UpdateBreweryCommand command = new( Guid.NewGuid(), + [0x01, 0x02], Guid.NewGuid(), "Name", "Description", @@ -91,7 +95,7 @@ public class UpdateBreweryHandlerTests _repoMock .Setup(r => r.UpdateAsync(It.IsAny())) .Callback(b => persisted = b) - .Returns(Task.CompletedTask); + .ReturnsAsync(() => persisted!); await _handler.Handle(command, CancellationToken.None); diff --git a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj index e97da606..4fbc5a99 100644 --- a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj +++ b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj @@ -12,8 +12,7 @@ - - + diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs deleted file mode 100644 index eaffef0c..00000000 --- a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Apps72.Dev.Data.DbMocker; -using Domain.Entities; -using Features.Breweries.Repository; -using FluentAssertions; - -namespace Features.Breweries.Tests.Repository; - -public class BreweryRepositoryTests -{ - private static BreweryRepository CreateRepo(MockDbConnection conn) - { - return new BreweryRepository(new TestConnectionFactory(conn)); - } - - [Fact] - public async Task GetByIdAsync_ReturnsBrewery_WhenExists() - { - Guid breweryId = Guid.NewGuid(); - MockDbConnection conn = new(); - - // Repository calls the stored procedure - const string getByIdSql = "USP_GetBreweryById"; - - Guid locationId = Guid.NewGuid(); - - conn.Mocks.When(cmd => cmd.CommandText == getByIdSql) - .ReturnsTable( - MockTable - .WithColumns( - ("BreweryPostId", typeof(Guid)), - ("PostedById", typeof(Guid)), - ("BreweryName", typeof(string)), - ("Description", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("Timer", typeof(byte[])), - ("BreweryPostLocationId", typeof(Guid)), - ("CityId", typeof(Guid)), - ("AddressLine1", typeof(string)), - ("AddressLine2", typeof(string)), - ("PostalCode", typeof(string)), - ("Coordinates", typeof(byte[])) - ) - .AddRow( - breweryId, - Guid.NewGuid(), - "Test Brewery", - "A test brewery description", - DateTime.UtcNow, - null, - null, - locationId, - Guid.NewGuid(), - "123 Main St", - null, - "12345", - null - ) - ); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost? result = await repo.GetByIdAsync(breweryId); - result.Should().NotBeNull(); - result!.BreweryPostId.Should().Be(breweryId); - result.Location.Should().NotBeNull(); - result.Location!.BreweryPostLocationId.Should().Be(locationId); - } - - [Fact] - public async Task GetByIdAsync_ReturnsNull_WhenNotExists() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById") - .ReturnsTable(MockTable.Empty()); - BreweryRepository repo = CreateRepo(conn); - BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid()); - result.Should().BeNull(); - } - - [Fact] - public async Task CreateAsync_ExecutesSuccessfully() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery").ReturnsScalar(1); - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() - { - BreweryPostId = Guid.NewGuid(), - PostedById = Guid.NewGuid(), - BreweryName = "Test Brewery", - Description = "A test brewery description", - CreatedAt = DateTime.UtcNow, - Location = new BreweryPostLocation - { - BreweryPostLocationId = Guid.NewGuid(), - CityId = Guid.NewGuid(), - AddressLine1 = "123 Main St", - PostalCode = "12345", - Coordinates = [0x00, 0x01], - }, - }; - - // Should not throw - Func act = async () => await repo.CreateAsync(brewery); - await act.Should().NotThrowAsync(); - } -} diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs deleted file mode 100644 index 8ab7808f..00000000 --- a/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; -using Infrastructure.Sql; - -namespace Features.Breweries.Tests.Repository; - -internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory -{ - private readonly DbConnection _conn = conn; - - public DbConnection CreateConnection() - { - return _conn; - } -} diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs index 19ad1406..6522accc 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs @@ -3,6 +3,8 @@ using MediatR; namespace Features.Breweries.Commands.CreateBrewery; +/// Location details for a brewery being created. +/// Raw binary representation of the brewery's geographic coordinates. public record CreateBreweryLocation( Guid CityId, string AddressLine1, diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs index 324eca28..83b3f285 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs @@ -5,10 +5,14 @@ using MediatR; namespace Features.Breweries.Commands.CreateBrewery; +/// Handles by persisting a new brewery post. public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler { /// Creates a new brewery post, generating new identifiers for the post and its location. + /// + /// Thrown when 's PostedById or Location.CityId does not exist. + /// public async Task Handle( CreateBreweryCommand request, CancellationToken cancellationToken diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs index 3f9e44f4..481e9393 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs @@ -2,6 +2,7 @@ using FluentValidation; namespace Features.Breweries.Commands.CreateBrewery; +/// Validates . public class CreateBreweryValidator : AbstractValidator { public CreateBreweryValidator() diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs index 5e939d16..97fe86f5 100644 --- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs @@ -2,4 +2,5 @@ using MediatR; namespace Features.Breweries.Commands.DeleteBrewery; +/// Deletes a brewery post. Bound directly from the route of DELETE /api/brewery/{id}. public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest; diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs index 7cf2dac9..e10e9185 100644 --- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs @@ -3,9 +3,13 @@ using MediatR; namespace Features.Breweries.Commands.DeleteBrewery; +/// Handles by deleting the corresponding brewery post. public class DeleteBreweryHandler(IBreweryRepository repository) : IRequestHandler { + /// + /// Thrown when no brewery exists with 's BreweryPostId. + /// public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) { return repository.DeleteAsync(request.BreweryPostId); diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs index 00377407..489de1d7 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs @@ -3,6 +3,11 @@ using MediatR; namespace Features.Breweries.Commands.UpdateBrewery; +/// Location details for a brewery being updated. +/// +/// Identifier of the existing location row, if any; a new row is created if none exists yet. +/// +/// Raw binary representation of the brewery's geographic coordinates. public record UpdateBreweryLocation( Guid BreweryPostLocationId, Guid CityId, @@ -14,10 +19,13 @@ public record UpdateBreweryLocation( /// /// Updates an existing brewery post. Bound directly from the request body of PUT /api/brewery/{id}. -/// A null clears the brewery's location. +/// A null clears the brewery's location. must be the +/// row-version last read for this brewery (e.g. from a prior GET); the update is rejected with a +/// 409 Conflict if the brewery was modified since then. /// public record UpdateBreweryCommand( Guid BreweryPostId, + byte[] RowVersion, Guid PostedById, string BreweryName, string Description, diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs index 5ba89607..21d4d606 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs @@ -5,6 +5,7 @@ using MediatR; namespace Features.Breweries.Commands.UpdateBrewery; +/// Handles by persisting changes to an existing brewery post. public class UpdateBreweryHandler(IBreweryRepository repository) : IRequestHandler { @@ -12,6 +13,14 @@ public class UpdateBreweryHandler(IBreweryRepository repository) /// Updates an existing brewery post. If has no Location, /// the brewery's location is cleared. /// + /// + /// Thrown when no brewery exists with 's BreweryPostId, or its + /// location's CityId does not exist. + /// + /// + /// Thrown when the brewery was modified by another request since . + /// RowVersion was read. + /// public async Task Handle( UpdateBreweryCommand request, CancellationToken cancellationToken @@ -23,6 +32,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository) PostedById = request.PostedById, BreweryName = request.BreweryName, Description = request.Description, + RowVersion = request.RowVersion, UpdatedAt = DateTime.UtcNow, Location = request.Location is null ? null @@ -38,7 +48,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository) }, }; - await repository.UpdateAsync(entity); - return entity.ToDto(); + BreweryPost updated = await repository.UpdateAsync(entity); + return updated.ToDto(); } } diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs new file mode 100644 index 00000000..a8757fcb --- /dev/null +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Features.Breweries.Commands.UpdateBrewery; + +/// Validates . +public class UpdateBreweryValidator : AbstractValidator +{ + public UpdateBreweryValidator() + { + RuleFor(x => x.RowVersion) + .NotEmpty() + .WithMessage("RowVersion is required to detect conflicting concurrent updates."); + } +} diff --git a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs index 115d6008..d53e4764 100644 --- a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs +++ b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs @@ -11,6 +11,7 @@ using Shared.Contracts; namespace Features.Breweries.Controllers; +/// Exposes CRUD operations for brewery posts. /// /// Requires JWT authentication by default; and opt out via /// [AllowAnonymous]. @@ -20,6 +21,7 @@ namespace Features.Breweries.Controllers; [Authorize(AuthenticationSchemes = "JWT")] public class BreweryController(IMediator mediator) : ControllerBase { + /// Retrieves a single brewery post by ID. /// Allows anonymous access. /// 200 OK with the brewery if found; otherwise 404 Not Found. [AllowAnonymous] @@ -39,6 +41,9 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } + /// Retrieves brewery posts, newest first. + /// Maximum number of breweries to return. Unbounded if . + /// Number of breweries to skip. Treated as zero if . /// Allows anonymous access. [AllowAnonymous] [HttpGet] @@ -59,6 +64,7 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } + /// Creates a new brewery post. /// 201 Created with the newly created brewery. [HttpPost] public async Task>> Create( @@ -76,8 +82,13 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } + /// Updates an existing brewery post. /// Must match 's BreweryPostId. - /// 200 OK with the updated brewery; or 400 Bad Request if the route ID does not match the payload ID. + /// + /// 200 OK with the updated brewery; 400 Bad Request if the route ID does not match the + /// payload ID; 404 Not Found if the brewery or its CityId does not exist; or + /// 409 Conflict if the brewery was modified since command.RowVersion was read. + /// [HttpPut("{id:guid}")] public async Task>> Update( Guid id, @@ -97,7 +108,8 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } - /// 200 OK confirming the deletion. + /// Deletes a brewery post. + /// 200 OK confirming the deletion; 404 Not Found if no brewery exists with . [HttpDelete("{id:guid}")] public async Task> Delete(Guid id) { diff --git a/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs index b4707c78..79770f37 100644 --- a/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs @@ -3,11 +3,11 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Breweries.DependencyInjection; -/// -/// Registers the services owned by the Breweries feature slice. -/// +/// Registers the services required by the Breweries feature. public static class FeaturesBreweriesServiceCollectionExtensions { + /// Registers and its implementation. + /// , for chaining. public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services) { services.AddScoped(); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index cbbfc2a3..621aceee 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -1,39 +1,32 @@ namespace Features.Breweries.Dtos; -public class BreweryLocationDto -{ - public Guid BreweryPostLocationId { get; set; } +/// Location details of a brewery, as returned by the API. +/// The brewery's geographic coordinates, in a raw binary representation. +public record BreweryLocationDto( + Guid BreweryPostLocationId, + Guid CityId, + string CityName, + string StateProvinceName, + string StateProvinceCode, + string CountryName, + string CountryCode, + string AddressLine1, + string? AddressLine2, + string PostalCode, + byte[]? Coordinates +); - public Guid BreweryPostId { get; set; } - - public Guid CityId { get; set; } - - public string AddressLine1 { get; set; } = string.Empty; - - public string? AddressLine2 { get; set; } - - public string PostalCode { get; set; } = string.Empty; - - /// Gets or sets the brewery's geographic coordinates, in a raw binary representation. - public byte[]? Coordinates { get; set; } -} - -public class BreweryDto -{ - public Guid BreweryPostId { get; set; } - - public Guid PostedById { get; set; } - - public string BreweryName { get; set; } = string.Empty; - - public string Description { get; set; } = string.Empty; - - public DateTime CreatedAt { get; set; } - - public DateTime? UpdatedAt { get; set; } - - /// Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates. - public byte[]? Timer { get; set; } - - public BreweryLocationDto? Location { get; set; } -} +/// A brewery post, as returned by the API. +/// The date and time of the last edit, or if never edited. +/// The row-version/concurrency token used to detect conflicting concurrent updates. +/// The brewery's location, or if none has been set. +public record BreweryDto( + Guid BreweryPostId, + Guid PostedById, + string BreweryName, + string Description, + DateTime CreatedAt, + DateTime? UpdatedAt, + byte[]? RowVersion, + BreweryLocationDto? Location +); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index 89ce7e54..fdcc3703 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -2,31 +2,42 @@ using Domain.Entities; namespace Features.Breweries.Dtos; +/// Maps domain entities to . public static class BreweryDtoMapper { + /// + /// Converts a to its DTO representation, including its + /// if the entity has one. + /// public static BreweryDto ToDto(this BreweryPost brewery) { - return new BreweryDto - { - BreweryPostId = brewery.BreweryPostId, - PostedById = brewery.PostedById, - BreweryName = brewery.BreweryName, - Description = brewery.Description, - CreatedAt = brewery.CreatedAt, - UpdatedAt = brewery.UpdatedAt, - Timer = brewery.Timer, - Location = brewery.Location is null + return new BreweryDto( + BreweryPostId: brewery.BreweryPostId, + PostedById: brewery.PostedById, + BreweryName: brewery.BreweryName, + Description: brewery.Description, + CreatedAt: brewery.CreatedAt, + UpdatedAt: brewery.UpdatedAt, + RowVersion: brewery.RowVersion, + Location: brewery.Location is null ? null - : new BreweryLocationDto - { - BreweryPostLocationId = brewery.Location.BreweryPostLocationId, - BreweryPostId = brewery.Location.BreweryPostId, - CityId = brewery.Location.CityId, - AddressLine1 = brewery.Location.AddressLine1, - AddressLine2 = brewery.Location.AddressLine2, - PostalCode = brewery.Location.PostalCode, - Coordinates = brewery.Location.Coordinates, - }, - }; + : new BreweryLocationDto( + BreweryPostLocationId: brewery.Location.BreweryPostLocationId, + CityId: brewery.Location.CityId, + CityName: brewery.Location.City?.CityName ?? string.Empty, + StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName + ?? string.Empty, + StateProvinceCode: brewery.Location.City?.StateProvince?.ISO3166_2 + ?? string.Empty, + CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName + ?? string.Empty, + CountryCode: brewery.Location.City?.StateProvince?.Country?.ISO3166_1 + ?? string.Empty, + AddressLine1: brewery.Location.AddressLine1, + AddressLine2: brewery.Location.AddressLine2, + PostalCode: brewery.Location.PostalCode, + Coordinates: brewery.Location.Coordinates + ) + ); } } diff --git a/web/backend/Features/Features.Breweries/Features.Breweries.csproj b/web/backend/Features/Features.Breweries/Features.Breweries.csproj index 93645162..d7c3fe27 100644 --- a/web/backend/Features/Features.Breweries/Features.Breweries.csproj +++ b/web/backend/Features/Features.Breweries/Features.Breweries.csproj @@ -12,6 +12,7 @@ + diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs index 6f06ada8..2b0308c7 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs @@ -5,9 +5,11 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweries; +/// Handles by retrieving brewery posts, newest first. public class GetAllBreweriesHandler(IBreweryRepository repository) : IRequestHandler> { + /// Retrieves brewery posts, newest first, applying 's paging. public async Task> Handle( GetAllBreweriesQuery request, CancellationToken cancellationToken diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs index 4fe7af88..96267d81 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs @@ -3,7 +3,7 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweries; -/// -/// Retrieves a paginated list of brewery posts. -/// +/// Retrieves brewery posts, newest first. Bound directly from the query string of GET /api/brewery. +/// Maximum number of breweries to return. Unbounded if . +/// Number of breweries to skip. Treated as zero if . public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest>; diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs index e80ec1c8..0dcef849 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs @@ -5,9 +5,11 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryById; +/// Handles by retrieving the matching brewery post. public class GetBreweryByIdHandler(IBreweryRepository repository) : IRequestHandler { + /// Retrieves the brewery post, or if none exists with the given ID. public async Task Handle( GetBreweryByIdQuery request, CancellationToken cancellationToken diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs index ece9133d..775edc92 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs @@ -3,4 +3,6 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryById; +/// Retrieves a brewery post by ID. Bound directly from the route of GET /api/brewery/{id}. +/// Yields if no brewery exists with the given BreweryPostId. public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest; diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 4baa9c43..ff975477 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -1,202 +1,458 @@ -using System.Data; using System.Data.Common; +using Dapper; using Domain.Entities; +using Domain.Exceptions; using Infrastructure.Sql; namespace Features.Breweries.Repository; /// -/// ADO.NET-based implementation of backed by SQL Server stored -/// procedures. +/// Dapper-based implementation of . /// +/// +/// BreweryPostLocation.Coordinates is a SQL Server GEOGRAPHY column. Dapper's generic +/// Query<T> deserializer reads columns via IDataRecord.GetValue, which for a UDT +/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to byte[]. +/// The read queries below therefore select CONVERT(varbinary(max), bpl.Coordinates), which +/// yields the UDT's serialized bytes as a plain varbinary value that Dapper can bind directly. +/// public class BreweryRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IBreweryRepository { - /// Retrieves a brewery post by ID using the USP_GetBreweryById stored procedure. + /// public async Task GetByIdAsync(Guid id) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandType = CommandType.StoredProcedure; + IEnumerable results = await connection.QueryAsync< + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + BreweryPost + >( + """ + SELECT + bp.BreweryPostID, + bp.PostedByID, + bp.BreweryName, + bp.Description, + bp.CreatedAt, + bp.UpdatedAt, + bp.RowVersion, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2, + co.CountryID, + co.CountryName, + co.ISO3166_1 + FROM dbo.BreweryPost bp + LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN dbo.City c ON bpl.CityID = c.CityID + LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID + WHERE bp.BreweryPostID = @BreweryPostId + """, + MapBreweryRow, + new { BreweryPostId = id }, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + ); - command.CommandText = "USP_GetBreweryById"; - AddParameter(command, "@BreweryPostID", id); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) - return MapToEntity(reader); - return null; + return results.SingleOrDefault(); } - /// - /// Retrieves all brewery posts, optionally paginated, using the USP_GetAllBreweries - /// stored procedure. The @Limit and @Offset parameters are only added when their - /// corresponding argument has a value. - /// - public async Task> GetAllAsync(int? limit, int? offset) + /// + public async Task> GetAllAsync(int? limit, + int? offset) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "USP_GetAllBreweries"; - command.CommandType = CommandType.StoredProcedure; - - if (limit.HasValue) - AddParameter(command, "@Limit", limit.Value); - - if (offset.HasValue) - AddParameter(command, "@Offset", offset.Value); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - List breweries = new(); - - while (await reader.ReadAsync()) - breweries.Add(MapToEntity(reader)); - - return breweries; + return await connection.QueryAsync< + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + BreweryPost + >( + """ + SELECT + bp.BreweryPostID, + bp.PostedByID, + bp.BreweryName, + bp.Description, + bp.CreatedAt, + bp.UpdatedAt, + bp.RowVersion, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2, + co.CountryID, + co.CountryName, + co.ISO3166_1 + FROM dbo.BreweryPost bp + LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN dbo.City c ON bpl.CityID = c.CityID + LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID + ORDER BY bp.CreatedAt DESC + OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY + """, + MapBreweryRow, + new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + ); } - /// - /// Updates a brewery post's name and description, and upserts or clears its location, using the - /// USP_UpdateBrewery stored procedure. When .Location is - /// null, any existing location for the brewery is removed. - /// - public async Task UpdateAsync(BreweryPost brewery) + /// + public async Task UpdateAsync(BreweryPost brewery) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "USP_UpdateBrewery"; - command.CommandType = CommandType.StoredProcedure; + await using DbTransaction transaction = + await connection.BeginTransactionAsync(); - AddParameter(command, "@BreweryPostID", brewery.BreweryPostId); - AddParameter(command, "@BreweryName", brewery.BreweryName); - AddParameter(command, "@Description", brewery.Description); - AddParameter(command, "@BreweryPostLocationID", brewery.Location?.BreweryPostLocationId); - AddParameter(command, "@CityID", brewery.Location?.CityId); - AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1); - AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2); - AddParameter(command, "@PostalCode", brewery.Location?.PostalCode); - AddParameter(command, "@Coordinates", brewery.Location?.Coordinates); + try + { + bool breweryExists = + await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT 1 FROM + dbo.BreweryPost + WHERE + BreweryPostID = @BreweryPostId + """, + new { brewery.BreweryPostId }, + transaction + ) + ) + is not null; - await command.ExecuteNonQueryAsync(); + if (!breweryExists) + throw new NotFoundException("Brewery not found."); + + if (brewery.Location is not null) + { + bool cityExists = + await connection.ExecuteScalarAsync( + new CommandDefinition( + "SELECT 1 FROM dbo.City WHERE CityID = @CityId", + new { brewery.Location.CityId }, + transaction + ) + ) + is not null; + + if (!cityExists) + throw new NotFoundException("City not found."); + } + + int updatedRows = await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.BreweryPost + SET BreweryName = @BreweryName, + Description = @Description, + UpdatedAt = GETDATE() + WHERE + BreweryPostID = @BreweryPostId + AND RowVersion = @RowVersion + """, + new + { + brewery.BreweryPostId, + brewery.BreweryName, + brewery.Description, + brewery.RowVersion, + }, + transaction + ) + ); + + if (updatedRows == 0) + throw new ConflictException( + "Brewery was modified by another request. Reload and try again." + ); + + if (brewery.Location is null) + { + // No location supplied: clear any existing location for this brewery. + await connection.ExecuteAsync( + new CommandDefinition( + """ + DELETE FROM + dbo.BreweryPostLocation + WHERE BreweryPostID = @BreweryPostId + """, + new { brewery.BreweryPostId }, + transaction + ) + ); + } + else + { + bool locationExists = + await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT 1 + FROM dbo.BreweryPostLocation + WHERE BreweryPostID = @BreweryPostId + """, + new { brewery.BreweryPostId }, + transaction + ) + ) + is not null; + + if (locationExists) + await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.BreweryPostLocation + SET CityID = @CityId, + AddressLine1 = @AddressLine1, + AddressLine2 = @AddressLine2, + PostalCode = @PostalCode, + Coordinates = @Coordinates + WHERE BreweryPostID = @BreweryPostId + """, + new + { + brewery.BreweryPostId, + brewery.Location.CityId, + brewery.Location.AddressLine1, + brewery.Location.AddressLine2, + brewery.Location.PostalCode, + brewery.Location.Coordinates, + }, + transaction + ) + ); + else + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.BreweryPostLocation + (BreweryPostLocationID, + BreweryPostID, + CityID, + AddressLine1, + AddressLine2, + PostalCode, + Coordinates) + VALUES + (@BreweryPostLocationId, + @BreweryPostId, + @CityId, + @AddressLine1, + @AddressLine2, + @PostalCode, + @Coordinates) + """, + new + { + brewery.Location.BreweryPostLocationId, + brewery.BreweryPostId, + brewery.Location.CityId, + brewery.Location.AddressLine1, + brewery.Location.AddressLine2, + brewery.Location.PostalCode, + brewery.Location.Coordinates, + }, + transaction + ) + ); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + + return await GetByIdAsync(brewery.BreweryPostId) + ?? throw new InvalidOperationException( + $"Brewery '{brewery.BreweryPostId}' was not found after a successful update." + ); } - /// - /// Deletes a brewery post by ID using the USP_DeleteBrewery stored procedure. Its location - /// and photos are removed via cascading foreign keys. - /// + /// public async Task DeleteAsync(Guid id) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "USP_DeleteBrewery"; - command.CommandType = CommandType.StoredProcedure; + int rows = await connection.ExecuteAsync( + """ + DELETE FROM + dbo.BreweryPost + WHERE + BreweryPostID = @BreweryPostId + """, + new { BreweryPostId = id } + ); - AddParameter(command, "@BreweryPostID", id); - await command.ExecuteNonQueryAsync(); + if (rows == 0) + throw new NotFoundException("Brewery not found."); } - /// Creates a new brewery post and its location using the USP_CreateBrewery stored procedure. - /// Thrown when .Location is null. + /// public async Task CreateAsync(BreweryPost brewery) { - await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - - command.CommandText = "USP_CreateBrewery"; - command.CommandType = CommandType.StoredProcedure; - if (brewery.Location is null) - throw new ArgumentException("Location must be provided when creating a brewery."); + throw new ArgumentException( + "Location must be provided when creating a brewery."); - AddParameter(command, "@BreweryName", brewery.BreweryName); - AddParameter(command, "@Description", brewery.Description); - AddParameter(command, "@PostedByID", brewery.PostedById); - AddParameter(command, "@CityID", brewery.Location?.CityId); - AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1); - AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2); - AddParameter(command, "@PostalCode", brewery.Location?.PostalCode); - AddParameter(command, "@Coordinates", brewery.Location?.Coordinates); - await command.ExecuteNonQueryAsync(); + await using DbConnection connection = await CreateConnection(); + await using DbTransaction transaction = + await connection.BeginTransactionAsync(); + + try + { + bool userExists = + await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT 1 + FROM + dbo.UserAccount + WHERE + UserAccountID = @PostedById + """, + new { brewery.PostedById }, + transaction + ) + ) + is not null; + + if (!userExists) + throw new NotFoundException("User not found."); + + bool cityExists = + await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT 1 + FROM + dbo.City + WHERE + CityID = @CityId + """, + new { brewery.Location.CityId }, + transaction + ) + ) + is not null; + + if (!cityExists) + throw new NotFoundException("City not found."); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.BreweryPost + (BreweryPostID, BreweryName, Description, PostedByID) + VALUES + (@BreweryPostId, @BreweryName, @Description, @PostedById) + """, + new + { + brewery.BreweryPostId, + brewery.BreweryName, + brewery.Description, + brewery.PostedById, + }, + transaction + ) + ); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.BreweryPostLocation + (BreweryPostLocationID, + BreweryPostID, + CityID, + AddressLine1, + AddressLine2, + PostalCode, + Coordinates) + VALUES (@BreweryPostLocationId, + @BreweryPostId, + @CityId, + @AddressLine1, + @AddressLine2, + @PostalCode, + @Coordinates) + """, + new + { + brewery.Location.BreweryPostLocationId, + brewery.BreweryPostId, + brewery.Location.CityId, + brewery.Location.AddressLine1, + brewery.Location.AddressLine2, + brewery.Location.PostalCode, + brewery.Location.Coordinates, + }, + transaction + ) + ); + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } } /// - /// Maps the current row of to a , including its - /// associated if location columns are present in the result set. + /// Composes a joined row's , , + /// , and fragments into a + /// single populated . When the row has no location (a left-joined + /// brewery with no address on file), is a blank instance produced + /// from all-NULL columns; the schema guarantees that whenever a location row was joined + /// in, its City/StateProvince/Country chain was too. /// - protected override BreweryPost MapToEntity(DbDataReader reader) + private static BreweryPost MapBreweryRow( + BreweryPost post, + BreweryPostLocation location, + City city, + StateProvince stateProvince, + Country country + ) { - BreweryPost brewery = new(); + if (location.BreweryPostLocationId == Guid.Empty) + return post; - int ordBreweryPostId = reader.GetOrdinal("BreweryPostId"); - int ordPostedById = reader.GetOrdinal("PostedById"); - int ordBreweryName = reader.GetOrdinal("BreweryName"); - int ordDescription = reader.GetOrdinal("Description"); - int ordCreatedAt = reader.GetOrdinal("CreatedAt"); - int ordUpdatedAt = reader.GetOrdinal("UpdatedAt"); - int ordTimer = reader.GetOrdinal("Timer"); + stateProvince.CountryId = country.CountryId; + stateProvince.Country = country; - brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId); - brewery.PostedById = reader.GetGuid(ordPostedById); - brewery.BreweryName = reader.GetString(ordBreweryName); - brewery.Description = reader.GetString(ordDescription); - brewery.CreatedAt = reader.GetDateTime(ordCreatedAt); + city.StateProvinceId = stateProvince.StateProvinceId; + city.StateProvince = stateProvince; - brewery.UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt); + location.BreweryPostId = post.BreweryPostId; + location.CityId = city.CityId; + location.City = city; - // Read timer (varbinary/rowversion) robustly - if (reader.IsDBNull(ordTimer)) - brewery.Timer = null; - else - try - { - brewery.Timer = reader.GetFieldValue(ordTimer); - } - catch - { - long length = reader.GetBytes(ordTimer, 0, null, 0, 0); - byte[] buffer = new byte[length]; - reader.GetBytes(ordTimer, 0, buffer, 0, (int)length); - brewery.Timer = buffer; - } - - // Map BreweryPostLocation if columns are present - try - { - int ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); - if (!reader.IsDBNull(ordLocationId)) - { - BreweryPostLocation location = new() - { - BreweryPostLocationId = reader.GetGuid(ordLocationId), - BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")), - CityId = reader.GetGuid(reader.GetOrdinal("CityId")), - AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")), - AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) - ? null - : reader.GetString(reader.GetOrdinal("AddressLine2")), - PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")), - Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) - ? null - : reader.GetFieldValue(reader.GetOrdinal("Coordinates")), - }; - brewery.Location = location; - } - } - catch (IndexOutOfRangeException) - { - // Location columns not present, skip mapping location - } - - return brewery; - } - - /// Adds a parameter to , converting null to . - private static void AddParameter(DbCommand command, string name, object? value) - { - DbParameter p = command.CreateParameter(); - p.ParameterName = name; - p.Value = value ?? DBNull.Value; - command.Parameters.Add(p); + post.Location = location; + return post; } } diff --git a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs index a23ab59e..e22c8520 100644 --- a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs @@ -7,16 +7,45 @@ namespace Features.Breweries.Repository; /// public interface IBreweryRepository { - /// Retrieves a brewery post by ID, or null if not found. + /// + /// Retrieves a brewery post by ID, joined to its location. Returns null if no brewery post + /// exists with the given ID, or if it has no associated location. + /// Task GetByIdAsync(Guid id); + /// + /// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts + /// without a location are included, with left null. + /// + /// Maximum number of rows to return. Unbounded if . + /// Number of rows to skip. Treated as zero if . Task> GetAllAsync(int? limit, int? offset); - Task UpdateAsync(BreweryPost brewery); + /// + /// Updates a brewery post's name and description, and upserts or clears its location, enforcing + /// optimistic concurrency via 's RowVersion: the update is rejected + /// if the row was modified since RowVersion was read. When 's + /// Location is null, any existing location for the brewery is removed. + /// Returns the freshly persisted brewery, including its new RowVersion. + /// + /// + /// Thrown when no brewery exists with the given ID, or the given location's CityId does not + /// exist. + /// + /// + /// Thrown when 's RowVersion no longer matches the stored row (it was + /// modified by another request since it was last read). + /// + Task UpdateAsync(BreweryPost brewery); + /// Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys. + /// Thrown when no brewery exists with the given . Task DeleteAsync(Guid id); /// Creates a new brewery post, including its location details. /// Thrown when has no Location. + /// + /// Thrown when 's PostedById or Location.CityId does not exist. + /// Task CreateAsync(BreweryPost brewery); } diff --git a/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj b/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj index 11921d56..5944af3e 100644 --- a/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj +++ b/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs b/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs index 5a64d9db..6459e773 100644 --- a/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs @@ -5,8 +5,15 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Emails.DependencyInjection; +/// +/// Registers the services used by the Features.Emails slice. +/// public static class FeaturesEmailsServiceCollectionExtensions { + /// + /// Registers the SMTP email provider, template renderer, and . + /// + /// The same instance, for chaining. public static IServiceCollection AddFeaturesEmails(this IServiceCollection services) { services.AddScoped(); diff --git a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs index 01c42c37..23bea801 100644 --- a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs +++ b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs @@ -22,6 +22,7 @@ public class EmailDispatcher( Environment.GetEnvironmentVariable("WEBSITE_BASE_URL") ?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set"); + /// public async Task SendRegistrationEmailAsync( string firstName, string email, @@ -38,6 +39,7 @@ public class EmailDispatcher( await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true); } + /// public async Task SendResendConfirmationEmailAsync( string firstName, string email, diff --git a/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs index 686a98a6..7116696a 100644 --- a/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs +++ b/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs @@ -8,10 +8,14 @@ public interface IEmailDispatcher /// /// Sends a welcome email containing an account confirmation link to a newly registered user. /// + /// The email is sent synchronously; the returned task completes once delivery to the mail server succeeds or fails. + /// Embedded in the confirmation link sent to . Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken); /// /// Sends an email containing a fresh account confirmation link to a user who requested a resend. /// + /// The email is sent synchronously; the returned task completes once delivery to the mail server succeeds or fails. + /// Embedded in the confirmation link sent to . Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken); } diff --git a/web/backend/Features/Features.Locations.Tests/Features.Locations.Tests.csproj b/web/backend/Features/Features.Locations.Tests/Features.Locations.Tests.csproj new file mode 100644 index 00000000..6d90e875 --- /dev/null +++ b/web/backend/Features/Features.Locations.Tests/Features.Locations.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + false + Features.Locations.Tests + + + + + + + + + + + + + + + + + + diff --git a/web/backend/Features/Features.Locations/DependencyInjection/FeaturesLocationsServiceCollectionExtensions.cs b/web/backend/Features/Features.Locations/DependencyInjection/FeaturesLocationsServiceCollectionExtensions.cs new file mode 100644 index 00000000..5d412a62 --- /dev/null +++ b/web/backend/Features/Features.Locations/DependencyInjection/FeaturesLocationsServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Features.Locations.Repository; +using Microsoft.Extensions.DependencyInjection; + +namespace Features.Locations.DependencyInjection; + +/// +/// Registers the services used by the Features.Locations slice. +/// +public static class FeaturesLocationsServiceCollectionExtensions +{ + /// + /// Registers and its Dapper-based implementation. + /// + /// The same instance, for chaining. + public static IServiceCollection AddFeaturesLocations(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/web/backend/Features/Features.Locations/Dtos/CityLocation.cs b/web/backend/Features/Features.Locations/Dtos/CityLocation.cs new file mode 100644 index 00000000..3139e951 --- /dev/null +++ b/web/backend/Features/Features.Locations/Dtos/CityLocation.cs @@ -0,0 +1,13 @@ +namespace Features.Locations.Dtos; + +/// +/// Describes a city's location for resolution by ILocationRepository, including the +/// state/province and country it belongs to. +/// +public sealed record CityLocation( + string CityName, + string StateProvinceName, + string StateProvinceIsoCode, + string CountryName, + string CountryIsoCode +); diff --git a/web/backend/Features/Features.Locations/Features.Locations.csproj b/web/backend/Features/Features.Locations/Features.Locations.csproj new file mode 100644 index 00000000..48c9c461 --- /dev/null +++ b/web/backend/Features/Features.Locations/Features.Locations.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + Features.Locations + + + + + + + + + + + + + + diff --git a/web/backend/Features/Features.Locations/Repository/ILocationRepository.cs b/web/backend/Features/Features.Locations/Repository/ILocationRepository.cs new file mode 100644 index 00000000..ee02ea77 --- /dev/null +++ b/web/backend/Features/Features.Locations/Repository/ILocationRepository.cs @@ -0,0 +1,16 @@ +using Features.Locations.Dtos; + +namespace Features.Locations.Repository; + +/// +/// Resolves the Country/StateProvince/City location hierarchy shared by features that attach a +/// location to a record (e.g. brewery posts, and eventually user accounts). +/// +public interface ILocationRepository +{ + /// + /// Resolves to a City ID, creating the Country, StateProvince, + /// and/or City rows if any part of the chain doesn't exist yet. + /// + Task GetOrCreateCityIdAsync(CityLocation location); +} diff --git a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs new file mode 100644 index 00000000..e716cc9b --- /dev/null +++ b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs @@ -0,0 +1,187 @@ +using System.Data.Common; +using Dapper; +using Domain.Entities; +using Domain.Exceptions; +using Features.Locations.Dtos; +using Infrastructure.Sql; + +namespace Features.Locations.Repository; + +/// +/// Dapper-based implementation of . +/// +public class LocationRepository(ISqlConnectionFactory connectionFactory) + : Repository(connectionFactory), + ILocationRepository +{ + /// + /// + /// Not fully race-safe under concurrent callers for the same new city (neither was the stored + /// procedure this replaces, since City has no unique constraint on name+state): a + /// concurrent duplicate insert can succeed rather than being rejected. Country/StateProvince + /// creation guards against this the same way the original stored procedures did, via an + /// existence pre-check immediately before the insert. + /// + public async Task GetOrCreateCityIdAsync(CityLocation location) + { + await EnsureCountryExistsAsync(location.CountryName, location.CountryIsoCode); + await EnsureStateProvinceExistsAsync( + location.StateProvinceName, + location.StateProvinceIsoCode, + location.CountryIsoCode + ); + + Guid? cityId = await GetCityIdAsync(location.CityName, location.StateProvinceIsoCode); + if (cityId is not null) + return cityId.Value; + + Guid stateProvinceId = + await GetStateProvinceIdAsync(location.StateProvinceIsoCode) + ?? throw new NotFoundException( + $"State/province '{location.StateProvinceIsoCode}' not found." + ); + + await using DbConnection connection = await CreateConnection(); + + bool cityExists = + await connection.ExecuteScalarAsync( + """ + SELECT 1 + FROM dbo.City + WHERE CityName = @CityName AND StateProvinceID = @StateProvinceId + """, + new { location.CityName, StateProvinceId = stateProvinceId } + ) is not null; + + if (!cityExists) + await connection.ExecuteAsync( + """ + INSERT INTO dbo.City (StateProvinceID, CityName) + VALUES (@StateProvinceId, @CityName) + """, + new { StateProvinceId = stateProvinceId, location.CityName } + ); + + return await GetCityIdAsync(location.CityName, location.StateProvinceIsoCode) + ?? throw new InvalidOperationException( + $"City '{location.CityName}' was not found after creation." + ); + } + + private async Task EnsureCountryExistsAsync(string countryName, string isoCode) + { + if (await GetCountryIdAsync(isoCode) is not null) + return; + + await using DbConnection connection = await CreateConnection(); + + bool exists = + await connection.ExecuteScalarAsync( + """ + SELECT 1 + FROM dbo.Country + WHERE ISO3166_1 = @ISO3166_1 + """, + new { ISO3166_1 = isoCode } + ) is not null; + + if (!exists) + // A concurrent caller may create this country between the check above and here; that + // race is tolerated (the resulting duplicate row, if any, is out of scope for this method). + await connection.ExecuteAsync( + """ + INSERT INTO dbo.Country (CountryName, ISO3166_1) + VALUES (@CountryName, @ISO3166_1) + """, + new { CountryName = countryName, ISO3166_1 = isoCode } + ); + } + + /// + /// Ensures a StateProvince row exists for . + /// + /// + /// Thrown when no Country exists for . Not expected in normal + /// operation, since callers ensure the Country exists first — retained as a defensive translation + /// in case of a race between concurrent callers. + /// + private async Task EnsureStateProvinceExistsAsync( + string stateProvinceName, + string isoCode, + string countryIsoCode + ) + { + if (await GetStateProvinceIdAsync(isoCode) is not null) + return; + + Guid countryId = + await GetCountryIdAsync(countryIsoCode) + ?? throw new NotFoundException($"Country '{countryIsoCode}' not found."); + + await using DbConnection connection = await CreateConnection(); + + bool exists = + await connection.ExecuteScalarAsync( + """ + SELECT 1 + FROM dbo.StateProvince + WHERE ISO3166_2 = @ISO3166_2 + """, + new { ISO3166_2 = isoCode } + ) is not null; + + if (!exists) + await connection.ExecuteAsync( + """ + INSERT INTO dbo.StateProvince (StateProvinceName, ISO3166_2, CountryID) + VALUES (@StateProvinceName, @ISO3166_2, @CountryId) + """, + new + { + StateProvinceName = stateProvinceName, + ISO3166_2 = isoCode, + CountryId = countryId, + } + ); + } + + private async Task GetCountryIdAsync(string isoCode) + { + await using DbConnection connection = await CreateConnection(); + return await connection.ExecuteScalarAsync( + """ + SELECT CountryID + FROM dbo.Country + WHERE ISO3166_1 = @ISO3166_1 + """, + new { ISO3166_1 = isoCode } + ); + } + + private async Task GetStateProvinceIdAsync(string isoCode) + { + await using DbConnection connection = await CreateConnection(); + return await connection.ExecuteScalarAsync( + """ + SELECT StateProvinceID + FROM dbo.StateProvince + WHERE ISO3166_2 = @ISO3166_2 + """, + new { ISO3166_2 = isoCode } + ); + } + + private async Task GetCityIdAsync(string cityName, string stateProvinceIsoCode) + { + await using DbConnection connection = await CreateConnection(); + return await connection.ExecuteScalarAsync( + """ + SELECT c.CityID + FROM dbo.City c + INNER JOIN dbo.StateProvince sp ON sp.StateProvinceID = c.StateProvinceID + WHERE c.CityName = @CityName AND sp.ISO3166_2 = @StateProvinceCode + """, + new { CityName = cityName, StateProvinceCode = stateProvinceIsoCode } + ); + } +} diff --git a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj b/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj index b5d6a61d..1777547c 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj +++ b/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj @@ -12,8 +12,7 @@ - - + diff --git a/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs deleted file mode 100644 index d0a4c5a7..00000000 --- a/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; -using Infrastructure.Sql; - -namespace Features.UserManagement.Tests.Repository; - -internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory -{ - private readonly DbConnection _conn = conn; - - public DbConnection CreateConnection() - { - return _conn; - } -} diff --git a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs b/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs deleted file mode 100644 index 5f26ec2f..00000000 --- a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs +++ /dev/null @@ -1,177 +0,0 @@ -using Apps72.Dev.Data.DbMocker; -using Domain.Entities; -using Features.UserManagement.Repository; -using FluentAssertions; - -namespace Features.UserManagement.Tests.Repository; - -public class UserAccountRepositoryTests -{ - private static UserAccountRepository CreateRepo(MockDbConnection conn) - { - return new UserAccountRepository(new TestConnectionFactory(conn)); - } - - [Fact] - public async Task GetByIdAsync_ReturnsRow_Mapped() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById") - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow( - Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - "yerb", - "Aaron", - "Po", - "aaronpo@example.com", - new DateTime(2020, 1, 1), - null, - new DateTime(1990, 1, 1), - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetByIdAsync( - Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - ); - - result.Should().NotBeNull(); - result!.Username.Should().Be("yerb"); - result.Email.Should().Be("aaronpo@example.com"); - } - - [Fact] - public async Task GetAllAsync_ReturnsMultipleRows() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts") - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow( - Guid.NewGuid(), - "a", - "A", - "A", - "a@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - .AddRow( - Guid.NewGuid(), - "b", - "B", - "B", - "b@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - List results = (await repo.GetAllAsync(null, null)).ToList(); - results.Should().HaveCount(2); - results.Select(r => r.Username).Should().BeEquivalentTo("a", "b"); - } - - [Fact] - public async Task GetByUsername_ReturnsRow() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow( - Guid.NewGuid(), - "lookupuser", - "L", - "U", - "lookup@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetByUsernameAsync("lookupuser"); - result.Should().NotBeNull(); - result!.Email.Should().Be("lookup@example.com"); - } - - [Fact] - public async Task GetByEmail_ReturnsRow() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("Timer", typeof(byte[])) - ) - .AddRow( - Guid.NewGuid(), - "byemail", - "B", - "E", - "byemail@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetByEmailAsync("byemail@example.com"); - result.Should().NotBeNull(); - result!.Username.Should().Be("byemail"); - } -} diff --git a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs index 4967b127..1daae7af 100644 --- a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs +++ b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs @@ -3,6 +3,9 @@ using MediatR; namespace Features.UserManagement.Commands.UpdateUser; +/// +/// Handles . +/// public class UpdateUserHandler(IUserAccountRepository repository) : IRequestHandler { diff --git a/web/backend/Features/Features.UserManagement/Controllers/UserController.cs b/web/backend/Features/Features.UserManagement/Controllers/UserController.cs index 1bd98ce7..669d4f5e 100644 --- a/web/backend/Features/Features.UserManagement/Controllers/UserController.cs +++ b/web/backend/Features/Features.UserManagement/Controllers/UserController.cs @@ -1,4 +1,5 @@ using Domain.Entities; +using Domain.Exceptions; using Features.UserManagement.Queries.GetAllUsers; using Features.UserManagement.Queries.GetUserById; using MediatR; @@ -6,10 +7,14 @@ using Microsoft.AspNetCore.Mvc; namespace Features.UserManagement.Controllers; +/// +/// Exposes read endpoints for user accounts. +/// [ApiController] [Route("api/[controller]")] public class UserController(IMediator mediator) : ControllerBase { + /// Gets a page of user accounts, ordered by creation date descending. /// null for no limit. /// null for no offset. [HttpGet] @@ -22,6 +27,10 @@ public class UserController(IMediator mediator) : ControllerBase return Ok(users); } + /// Gets the user account with the given ID. + /// + /// Thrown when no user account exists with the given ID, resulting in a 404 response. + /// [HttpGet("{id:guid}")] public async Task> GetById(Guid id) { diff --git a/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs b/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs index ebd3c081..caff349d 100644 --- a/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs @@ -3,8 +3,15 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.UserManagement.DependencyInjection; +/// +/// Registers the services used by the Features.UserManagement slice. +/// public static class FeaturesUserManagementServiceCollectionExtensions { + /// + /// Registers and its Dapper-based implementation. + /// + /// The same instance, for chaining. public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services) { services.AddScoped(); diff --git a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs index 4e8afe82..bc0822ec 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs @@ -4,6 +4,9 @@ using MediatR; namespace Features.UserManagement.Queries.GetAllUsers; +/// +/// Handles . +/// public class GetAllUsersHandler(IUserAccountRepository repository) : IRequestHandler> { diff --git a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs index 90adf926..54e8d4cf 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs @@ -3,4 +3,9 @@ using MediatR; namespace Features.UserManagement.Queries.GetAllUsers; +/// +/// Requests a page of user accounts, ordered by creation date descending. +/// +/// null for no limit. +/// null for no offset. public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest>; diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs index d664310e..bab1687e 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs @@ -5,6 +5,9 @@ using MediatR; namespace Features.UserManagement.Queries.GetUserById; +/// +/// Handles . +/// public class GetUserByIdHandler(IUserAccountRepository repository) : IRequestHandler { diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs index 8337b7a9..51d47c2f 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs @@ -3,4 +3,7 @@ using MediatR; namespace Features.UserManagement.Queries.GetUserById; +/// +/// Requests the user account with the given ID. +/// public record GetUserByIdQuery(Guid UserAccountId) : IRequest; diff --git a/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs index 7ba4c08d..25ebf13d 100644 --- a/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs @@ -1,4 +1,5 @@ using Domain.Entities; +using Domain.Exceptions; namespace Features.UserManagement.Repository; @@ -7,30 +8,26 @@ namespace Features.UserManagement.Repository; /// public interface IUserAccountRepository { - /// - /// Retrieves a user account by its unique identifier, or null if not found. - /// + /// Returns null if no user account exists with the given ID. Task GetByIdAsync(Guid id); - /// - /// Retrieves all user accounts, optionally paginated. - /// + /// Returns user accounts ordered by creation date descending. /// null for no limit. /// null for no offset. Task> GetAllAsync(int? limit, int? offset); + /// Updates the mutable fields of an existing user account. /// Must have a valid UserAccountId. + /// Thrown when no user account exists with the given ID. Task UpdateAsync(UserAccount userAccount); + /// Deletes the user account with the given ID. + /// Thrown when no user account exists with the given ID. Task DeleteAsync(Guid id); - /// - /// Retrieves a user account by username, or null if not found. - /// + /// Returns null if no user account exists with the given username. Task GetByUsernameAsync(string username); - /// - /// Retrieves a user account by email address, or null if not found. - /// + /// Returns null if no user account exists with the given email address. Task GetByEmailAsync(string email); } diff --git a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs index f03d49d6..279288c3 100644 --- a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs @@ -1,149 +1,118 @@ -using System.Data; using System.Data.Common; +using Dapper; using Domain.Entities; +using Domain.Exceptions; using Infrastructure.Sql; namespace Features.UserManagement.Repository; /// -/// ADO.NET-based implementation of backed by SQL Server -/// stored procedures. +/// Dapper-based implementation of . /// public class UserAccountRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IUserAccountRepository { - /// Uses the usp_GetUserAccountById stored procedure. /// public async Task GetByIdAsync(Guid id) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_GetUserAccountById"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@UserAccountId", id); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = id } + ); } - /// - /// Uses the usp_GetAllUserAccounts stored procedure. The @Limit and @Offset - /// parameters are only added when their corresponding argument has a value. - /// /// + /// + /// Always applies pagination via OFFSET/FETCH, ordered by creation date descending. + /// A null / is treated as "no limit"/"no offset". + /// public async Task> GetAllAsync(int? limit, int? offset) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_GetAllUserAccounts"; - command.CommandType = CommandType.StoredProcedure; - - if (limit.HasValue) - AddParameter(command, "@Limit", limit.Value); - - if (offset.HasValue) - AddParameter(command, "@Offset", offset.Value); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - List users = new(); - - while (await reader.ReadAsync()) - users.Add(MapToEntity(reader)); - - return users; + return await connection.QueryAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + ORDER BY CreatedAt DESC + OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY + """, + new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue } + ); } - /// - /// Updates a user account's username, first name, last name, email, and date of birth using - /// the usp_UpdateUserAccount stored procedure. - /// /// public async Task UpdateAsync(UserAccount userAccount) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_UpdateUserAccount"; - command.CommandType = CommandType.StoredProcedure; + int rows = await connection.ExecuteAsync( + """ + UPDATE dbo.UserAccount + SET Username = @Username, FirstName = @FirstName, LastName = @LastName, + DateOfBirth = @DateOfBirth, Email = @Email + WHERE UserAccountId = @UserAccountId + """, + new + { + UserAccountId = userAccount.UserAccountId, + userAccount.Username, + userAccount.FirstName, + userAccount.LastName, + userAccount.DateOfBirth, + userAccount.Email, + } + ); - AddParameter(command, "@UserAccountId", userAccount.UserAccountId); - AddParameter(command, "@Username", userAccount.Username); - AddParameter(command, "@FirstName", userAccount.FirstName); - AddParameter(command, "@LastName", userAccount.LastName); - AddParameter(command, "@Email", userAccount.Email); - AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth); - - await command.ExecuteNonQueryAsync(); + if (rows == 0) + throw new NotFoundException("User account not found."); } - /// Uses the usp_DeleteUserAccount stored procedure. /// public async Task DeleteAsync(Guid id) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_DeleteUserAccount"; - command.CommandType = CommandType.StoredProcedure; + int rows = await connection.ExecuteAsync( + """ + DELETE FROM dbo.UserAccount + WHERE UserAccountId = @UserAccountId + """, + new { UserAccountId = id } + ); - AddParameter(command, "@UserAccountId", id); - await command.ExecuteNonQueryAsync(); + if (rows == 0) + throw new NotFoundException("User account not found."); } - /// Uses the usp_GetUserAccountByUsername stored procedure. /// public async Task GetByUsernameAsync(string username) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_GetUserAccountByUsername"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@Username", username); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Username = @Username + """, + new { Username = username } + ); } - /// Uses the usp_GetUserAccountByEmail stored procedure. /// public async Task GetByEmailAsync(string email) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = "usp_GetUserAccountByEmail"; - command.CommandType = CommandType.StoredProcedure; - - AddParameter(command, "@Email", email); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; - } - - protected override UserAccount MapToEntity(DbDataReader reader) - { - return new UserAccount - { - UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), - Username = reader.GetString(reader.GetOrdinal("Username")), - FirstName = reader.GetString(reader.GetOrdinal("FirstName")), - LastName = reader.GetString(reader.GetOrdinal("LastName")), - Email = reader.GetString(reader.GetOrdinal("Email")), - CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), - UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt")) - ? null - : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), - DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), - Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"], - }; - } - - /// Converts null values to when adding the parameter. - private static void AddParameter(DbCommand command, string name, object? value) - { - DbParameter p = command.CreateParameter(); - p.ParameterName = name; - p.Value = value ?? DBNull.Value; - command.Parameters.Add(p); + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Email = @Email + """, + new { Email = email } + ); } } diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs index 332fc582..ed29bf30 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs @@ -12,6 +12,7 @@ namespace Infrastructure.Email.Templates.Rendering; public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) : IEmailTemplateProvider { + /// public async Task RenderUserRegisteredEmailAsync( string username, string confirmationLink @@ -26,6 +27,7 @@ public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFact return await RenderComponentAsync(parameters); } + /// public async Task RenderResendConfirmationEmailAsync( string username, string confirmationLink diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs index 97785db2..b3929973 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs @@ -5,19 +5,15 @@ namespace Infrastructure.Email.Templates.Rendering; /// public interface IEmailTemplateProvider { - /// - /// Renders the UserRegisteredEmail template with the specified parameters. - /// - /// The username to include in the email - /// The email confirmation link - /// The rendered HTML string + /// Renders the registration confirmation email template. + /// The recipient's username, shown in the greeting. + /// The URL the recipient follows to confirm their account. + /// A task whose result is the rendered HTML for the email body. Task RenderUserRegisteredEmailAsync(string username, string confirmationLink); - /// - /// Renders the ResendConfirmation template with the specified parameters. - /// - /// The username to include in the email - /// The new confirmation link - /// The rendered HTML string + /// Renders the resend-confirmation email template. + /// The recipient's username, shown in the greeting. + /// The URL the recipient follows to confirm their account. + /// A task whose result is the rendered HTML for the email body. Task RenderResendConfirmationEmailAsync(string username, string confirmationLink); } diff --git a/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs b/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs index 4bca5326..94641d2c 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs @@ -1,11 +1,21 @@ namespace Infrastructure.Email; /// -/// Service for sending emails via SMTP. +/// Service for sending emails. /// public interface IEmailProvider { + /// Sends an email to a single recipient. + /// The recipient's email address. + /// The email subject line. + /// The email body content. + /// Whether is HTML; when it is sent as plain text. Task SendAsync(string to, string subject, string body, bool isHtml = true); + /// Sends an email to multiple recipients. + /// The recipients' email addresses. + /// The email subject line. + /// The email body content. + /// Whether is HTML; when it is sent as plain text. Task SendAsync(IEnumerable to, string subject, string body, bool isHtml = true); } diff --git a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs index 71b67b34..4928ca16 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs @@ -51,15 +51,17 @@ public class SmtpEmailProvider : IEmailProvider _fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten"; } + /// public async Task SendAsync(string to, string subject, string body, bool isHtml = true) { await SendAsync([to], subject, body, isHtml); } - /// + /// + /// /// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates /// if credentials were configured, sends the message, and disconnects. - /// + /// /// /// Thrown when connecting, authenticating, or sending via SMTP fails, wrapping the original exception. /// diff --git a/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs index 7a9e0516..72815038 100644 --- a/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs @@ -12,14 +12,12 @@ namespace Infrastructure.Jwt; /// public class JwtInfrastructure : ITokenInfrastructure { - /// - /// Generates a signed JWT, signed using HMAC-SHA256 with the provided secret. - /// + /// /// /// Sets registered claims sub (userId), unique_name (username), iat (current UTC time), /// exp (expiry), and jti (a newly generated GUID). /// - /// The symmetric secret used to sign the token (encoded as UTF-8 bytes). + /// The symmetric secret used to sign the token, encoded as UTF-8 bytes. public string GenerateJwt(Guid userId, string username, DateTime expiry, string secret) { JsonWebTokenHandler handler = new(); @@ -52,11 +50,9 @@ public class JwtInfrastructure : ITokenInfrastructure return handler.CreateToken(tokenDescriptor); } - /// - /// Validates a JWT's signature and lifetime (issuer and audience validation are disabled), - /// using the provided secret as the HMAC-SHA256 symmetric signing key. - /// - /// The symmetric secret used to verify the token's signature (encoded as UTF-8 bytes). + /// + /// Issuer and audience validation are disabled; only the signature and lifetime are validated. + /// The symmetric secret used to verify the token's signature, encoded as UTF-8 bytes. /// /// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation. /// diff --git a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs index 9331e293..bda6f8ad 100644 --- a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs @@ -14,9 +14,7 @@ public class Argon2Infrastructure : IPasswordInfrastructure private const int ArgonIterations = 4; private const int ArgonMemoryKb = 65536; // 64MB - /// - /// Hashes a plaintext password using Argon2id with a fresh random salt. - /// + /// /// A string of the form "{base64Salt}:{base64Hash}", suitable for storage. public string Hash(string password) { @@ -33,10 +31,8 @@ public class Argon2Infrastructure : IPasswordInfrastructure return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; } - /// - /// Verifies a plaintext password against a stored salt/hash string, using a fixed-time comparison - /// to mitigate timing attacks. - /// + /// + /// Uses a fixed-time comparison to mitigate timing attacks. /// /// false if the password doesn't match, if is malformed, or if any /// other error occurs during verification. diff --git a/web/backend/Infrastructure/Infrastructure.Sql/DependencyInjection/InfrastructureSqlServiceCollectionExtensions.cs b/web/backend/Infrastructure/Infrastructure.Sql/DependencyInjection/InfrastructureSqlServiceCollectionExtensions.cs new file mode 100644 index 00000000..6573fe5b --- /dev/null +++ b/web/backend/Infrastructure/Infrastructure.Sql/DependencyInjection/InfrastructureSqlServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.Sql.DependencyInjection; + +/// Extension methods for registering SQL infrastructure services with dependency injection. +public static class InfrastructureSqlServiceCollectionExtensions +{ + /// Registers as a singleton . + /// The same instance, for chaining. + public static IServiceCollection AddInfrastructureSql(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj b/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj index dcc69bba..5bcf32c9 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj +++ b/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj @@ -6,12 +6,16 @@ Infrastructure.Sql + - + diff --git a/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs b/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs index 85e56701..8ea5e110 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs @@ -3,8 +3,8 @@ using System.Data.Common; namespace Infrastructure.Sql; /// -/// Base class for ADO.NET-based repositories, providing shared connection creation and -/// entity-mapping infrastructure for derived repository implementations. +/// Base class for Dapper-based repositories, providing shared connection creation for derived +/// repository implementations. /// public abstract class Repository(ISqlConnectionFactory connectionFactory) where T : class @@ -19,9 +19,4 @@ public abstract class Repository(ISqlConnectionFactory connectionFactory) await connection.OpenAsync(); return connection; } - - /// - /// Maps the current row of a data reader to an instance of . - /// - protected abstract T MapToEntity(DbDataReader reader); } diff --git a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs index ae78fb97..047f014b 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs @@ -14,22 +14,10 @@ public static class SqlConnectionStringHelper /// If null, falls back to the DB_NAME environment variable. public static string BuildConnectionString(string? databaseName = null) { - string server = - Environment.GetEnvironmentVariable("DB_SERVER") - ?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); - - string dbName = - databaseName - ?? Environment.GetEnvironmentVariable("DB_NAME") - ?? throw new InvalidOperationException("DB_NAME environment variable is not set"); - - string user = - Environment.GetEnvironmentVariable("DB_USER") - ?? throw new InvalidOperationException("DB_USER environment variable is not set"); - - string password = - Environment.GetEnvironmentVariable("DB_PASSWORD") - ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); + string server = Required("DB_SERVER"); + string dbName = databaseName ?? Required("DB_NAME"); + string user = Required("DB_USER"); + string password = Required("DB_PASSWORD"); SqlConnectionStringBuilder builder = new() { @@ -37,7 +25,7 @@ public static class SqlConnectionStringHelper InitialCatalog = dbName, UserID = user, Password = password, - TrustServerCertificate = true, + TrustServerCertificate = ResolveTrustServerCertificate(), Encrypt = true, }; @@ -51,4 +39,23 @@ public static class SqlConnectionStringHelper { return BuildConnectionString("master"); } + + /// + /// Resolves the DB_TRUST_SERVER_CERTIFICATE environment variable, defaulting to + /// true when it is unset or not a valid boolean. + /// + private static bool ResolveTrustServerCertificate() + { + return !bool.TryParse( + Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"), + out bool trustServerCertificate + ) + || trustServerCertificate; + } + + private static string Required(string variableName) => + Environment.GetEnvironmentVariable(variableName) + ?? throw new InvalidOperationException( + $"The {variableName} environment variable is not set." + ); } diff --git a/web/backend/Infrastructure/Infrastructure.Sql/SqlErrorCodes.cs b/web/backend/Infrastructure/Infrastructure.Sql/SqlErrorCodes.cs new file mode 100644 index 00000000..1dece02f --- /dev/null +++ b/web/backend/Infrastructure/Infrastructure.Sql/SqlErrorCodes.cs @@ -0,0 +1,44 @@ +namespace Infrastructure.Sql; + +/// +/// Custom SQL Server error numbers (50000+) that a stored procedure could use to signal +/// domain-level failures via THROW, for a repository to catch as +/// and translate via ex.Number into +/// the corresponding Domain.Exceptions type. +/// +/// +/// Currently unused: the schema has no stored procedures, and no repository throws or catches on +/// these numbers. Repositories instead run their own existence/concurrency checks and throw +/// Domain.Exceptions directly (see docs/website/database.md for the current +/// approach). This type is kept in case a future stored procedure reintroduces the pattern; each +/// number would +/// identify a category of error shared by every procedure that raises it, not a single call site. +/// +public static class SqlErrorCodes +{ + /// + /// A stored procedure's own input validation failed (e.g. a required parameter was NULL). + /// Maps to HTTP 400 Bad Request. + /// + public const int Validation = 50400; + + /// + /// A stored procedure could not find a row it required, by primary key or foreign key. Maps to + /// HTTP 404 Not Found. + /// + public const int NotFound = 50404; + + /// + /// A stored procedure detected a conflict: either a duplicate/already-exists row, or a stale + /// optimistic-concurrency token (row-version mismatch). Maps to HTTP 409 Conflict. + /// + public const int Conflict = 50409; + + /// + /// A defensive check inside a stored procedure failed under conditions that should be + /// unreachable in normal operation (e.g. an INSERT that should always affect one row + /// affected zero). Left uncaught by repositories so it falls through to the generic + /// handler. Maps to HTTP 500/503. + /// + public const int InternalFailure = 50500; +} diff --git a/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs b/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs index 05a21fcb..aabc964f 100644 --- a/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs +++ b/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs @@ -12,6 +12,7 @@ public class ValidationBehavior(IEnumerable where TRequest : IRequest { + /// public async Task Handle( TRequest request, RequestHandlerDelegate next, diff --git a/web/backend/Shared/Shared.Contracts/ResponseBody.cs b/web/backend/Shared/Shared.Contracts/ResponseBody.cs index 2326814a..d3f4e690 100644 --- a/web/backend/Shared/Shared.Contracts/ResponseBody.cs +++ b/web/backend/Shared/Shared.Contracts/ResponseBody.cs @@ -5,8 +5,10 @@ namespace Shared.Contracts; /// public record ResponseBody { + /// Gets the human-readable message describing the response. public required string Message { get; init; } + /// Gets the response's data payload. public required T Payload { get; init; } } @@ -15,5 +17,6 @@ public record ResponseBody /// public record ResponseBody { + /// Gets the human-readable message describing the response. public required string Message { get; init; } } diff --git a/web/docker-compose.db.yaml b/web/docker-compose.db.yaml index 2681819a..7653b7bf 100644 --- a/web/docker-compose.db.yaml +++ b/web/docker-compose.db.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.dev.yaml b/web/docker-compose.dev.yaml index 1882fd1a..760386e9 100644 --- a/web/docker-compose.dev.yaml +++ b/web/docker-compose.dev.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.min.yaml b/web/docker-compose.min.yaml index a8a56ac9..3e73c2a5 100644 --- a/web/docker-compose.min.yaml +++ b/web/docker-compose.min.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.prod.yaml b/web/docker-compose.prod.yaml index a2267a74..62c57608 100644 --- a/web/docker-compose.prod.yaml +++ b/web/docker-compose.prod.yaml @@ -31,8 +31,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.test.yaml b/web/docker-compose.test.yaml index 74a95db2..16dbe330 100644 --- a/web/docker-compose.test.yaml +++ b/web/docker-compose.test.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000