Replace stored-procedure repositories with Dapper-based repositories

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

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

View File

@@ -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)

View File

@@ -13,8 +13,8 @@ active website:
- **Architecture Style**: Vertical-slice backend plus server-rendered React
frontend
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
for reference only.
The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for
reference only.
## Diagrams
@@ -35,9 +35,9 @@ For visual representations, see:
The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
`Features.UserManagement`, `Features.Emails`) is a single project that owns
its own controller, MediatR commands/queries/handlers, validators, and
repository, end to end:
`Features.UserManagement`, `Features.Emails`) is a single project that owns its
own controller, MediatR commands/queries/handlers, validators, and repository,
end to end:
```
┌───────────────────────────────────────────────────────────────────────┐
@@ -74,18 +74,18 @@ repository, end to end:
└─────────────────────────────────────┘
```
Slices never reference each other's project. The one cross-slice
interaction (`Features.Auth` triggering a confirmation email handled by
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
whose contract lives in `Shared.Application`, so neither slice takes a
project reference on the other.
Slices never reference each other's project. The one cross-slice interaction
(`Features.Auth` triggering a confirmation email handled by `Features.Emails`)
goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
lives in `Shared.Application`, so neither slice takes a project reference on the
other.
### Layer responsibilities
#### API layer (`API.Core`)
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
its own
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
own
**Components**:
@@ -114,26 +114,25 @@ its own
**Components** (per slice):
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types
as the request contract
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per
operation, each containing the Command/Query record, its
`IRequestHandler`, and (for commands) a FluentValidation validator
- `Repository/`: the slice's own stored-procedure repository
implementation
- `Dtos/`: response shapes returned by query handlers (never the raw
domain entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering
the slice's repository/services
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the
request contract
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
each containing the Command/Query record, its `IRequestHandler`, and (for
commands) a FluentValidation validator
- `Repository/`: the slice's own stored-procedure repository implementation
- `Dtos/`: response shapes returned by query handlers (never the raw domain
entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
slice's repository/services
`Features.Emails` has no `Controllers/` folder. It's invoked only via
MediatR commands sent from other slices, never over HTTP.
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
commands sent from other slices, never over HTTP.
**Dependencies**:
- `Domain.Entities`, `Domain.Exceptions`
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
infrastructure project the slice needs (`Infrastructure.Jwt`/
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails)
- `Shared.Contracts`, `Shared.Application`
@@ -142,29 +141,27 @@ MediatR commands sent from other slices, never over HTTP.
**Rules**:
- All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through
MediatR
- No direct controller-to-repository calls; everything flows through MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
**Purpose**: The minimum cross-slice surface area required because every
slice needs it, or because duplicating it four times would be worse than
sharing it
**Purpose**: The minimum cross-slice surface area required because every slice
needs it, or because duplicating it four times would be worse than sharing it
**Components**:
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
envelope every controller returns
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
MediatR pipeline behavior that runs FluentValidation before a handler
executes) and the cross-slice email commands
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR
pipeline behavior that runs FluentValidation before a handler executes) and
the cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
**Rules**:
- Kept deliberately small: this is the exception to "no slice depends on
another slice," not a general-purpose dumping ground
- Kept deliberately small: this is the exception to "no slice depends on another
slice," not a general-purpose dumping ground
#### Infrastructure layer
@@ -218,16 +215,15 @@ whichever slices need them
#### Vertical slice + MediatR
**Purpose**: Organize code by feature instead of by technical layer, so
everything needed to understand or change one capability lives in one
project
everything needed to understand or change one capability lives in one project
**Implementation**:
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (for example, `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- Controllers bind directly to the Command/Query as the request body; there is
no separate request DTO + mapping step for writes
- A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes
@@ -250,14 +246,14 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
**Purpose**: Abstract database access behind interfaces
**Implementation**: each slice owns its own repository, scoped to that
feature only:
**Implementation**: each slice owns its own repository, scoped to that feature
only:
- `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
connection factory every slice's repository builds on
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection
factory every slice's repository builds on
**Benefits**:
@@ -279,9 +275,9 @@ public interface IAuthRepository
**Purpose**: Loose coupling and testability
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()`
extension method that registers its repository and slice-internal services
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
method that registers its repository and slice-internal services
**Lifetimes**:
@@ -351,8 +347,8 @@ All component styling should prefer semantic tokens such as `primary`,
### Legacy frontend
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
for reference only. Active product and engineering documentation should point
to `web/frontend`.
for reference only. Active product and engineering documentation should point to
`web/frontend`.
## Security architecture
@@ -411,6 +407,10 @@ to `web/frontend`.
## Database architecture
For the table-by-table schema, the stored procedure reference (parameters,
behavior, and exactly what each one throws), and the custom SQL error code
scheme, see [Database](website/database.md).
### SQL-first philosophy
**Principles**:

View File

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

View File

@@ -102,7 +102,7 @@ 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. |
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

@@ -166,8 +166,8 @@ NODE_ENV=development # Standard Node runtime mode
### SMTP configuration (backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
and account emails.
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
account emails.
```bash
SMTP_HOST=mailpit # Required, no default
@@ -183,8 +183,8 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
and so on)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
so on)
## Docker-specific variables
@@ -235,7 +235,7 @@ 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 |
@@ -276,9 +276,8 @@ Variables are validated at startup:
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
values) cause the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the
"minimum 32 characters" guidance above is a recommendation, not an
enforced check
- No minimum length is enforced on the token secrets in code; the "minimum 32
characters" guidance above is a recommendation, not an enforced check
### Frontend validation

View File

@@ -46,7 +46,8 @@ See [Environment Variables](environment-variables.md) for the full list.
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev SMTP).
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev
SMTP).
Available endpoints:

View File

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

View File

@@ -34,8 +34,8 @@ Low-level JWT operations.
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
Both token generation and validation live on the same slice-internal
service (there is no separate validation service/class).
Both token generation and validation live on the same slice-internal service
(there is no separate validation service/class).
**Generation methods:**
@@ -56,18 +56,18 @@ service (there is no separate validation service/class).
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
**Implementation:**
[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Auth`'s own
`AddFeaturesAuth()` extension method, except for the lower-level
`ITokenInfrastructure` (JWT signing/verification) it depends on, which is
registered by the host (`API.Core/Program.cs`) since
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
it directly.
`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()`
extension method, except for the lower-level `ITokenInfrastructure` (JWT
signing/verification) it depends on, which is registered by the host
(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
middleware) also depends on it directly.
### Integration points
@@ -97,9 +97,9 @@ it directly.
**Flow:**
1. Receives a refresh token via `RefreshTokenCommand`
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`,
and issues a new access/refresh token pair
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and
issues a new access/refresh token pair
3. Maps the result onto `LoginPayload`
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)

View File

@@ -11,7 +11,7 @@ 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);
@@ -60,7 +60,7 @@ if (!builder.Environment.IsProduction())
// Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
builder.Services.AddInfrastructureSql();
builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();

View File

@@ -12,7 +12,7 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": ""
"Default": ""
},
"Jwt": {
"SecretKey": "",

View File

@@ -27,6 +27,8 @@
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
<Project Path="Features/Features.Emails/Features.Emails.csproj" />
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
<Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
</Folder>
<Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />

View File

@@ -10,7 +10,10 @@
<ItemGroup>
<PackageReference Include="dbup" Version="5.0.41" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -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.");
}
/// <summary>
/// Opens a connection, executes a non-query batch parameterised by database name, and
/// disposes both the command and the connection.
/// </summary>
/// <param name="connectionString">The connection string to open.</param>
/// <param name="sql">The Transact-SQL batch to execute.</param>
/// <param name="databaseName">The value bound to the <c>@databaseName</c> parameter.</param>
private static async Task ExecuteNonQueryAsync(
string connectionString,
@@ -171,16 +166,11 @@ public static class Program
/// </exception>
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."
);
}
}
}

View File

@@ -1,15 +0,0 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode(
@CountryCode NVARCHAR(2)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId;
END;

View File

@@ -1,13 +0,0 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode(
@StateProvinceCode NVARCHAR(6)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;
RETURN @StateProvinceId;
END;

View File

@@ -1,31 +0,0 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
DECLARE
@Inserted TABLE
(
UserAccountID UNIQUEIDENTIFIER
);
INSERT INTO UserAccount
(Username,
FirstName,
LastName,
DateOfBirth,
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
RETURN
END
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -1,15 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Email = @Email;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId;
END

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR(64)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Username = @Username;
END;

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
UPDATE UserAccount
SET Username = @Username,
FirstName = @FirstName,
LastName = @LastName,
DateOfBirth = @DateOfBirth,
Email = @Email
WHERE UserAccountId = @UserAccountId;
IF
@@ROWCOUNT = 0
BEGIN
THROW
50001, 'UserAccount with the specified ID does not exist.', 1;
END
END;

View File

@@ -1,18 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1;
COMMIT TRANSACTION;
END;

View File

@@ -1,50 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT,
@Username = @Username,
@FirstName = @FirstName,
@LastName = @LastName,
@DateOfBirth = @DateOfBirth,
@Email = @Email;
IF
@UserAccountId_ IS NULL
BEGIN
THROW
50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF
@@ROWCOUNT = 0
BEGIN
THROW
50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
END

View File

@@ -1,31 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
IF @@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials -- set them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_;
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
END;

View File

@@ -1,27 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE();
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification
(UserAccountId, VerificationDateTime)
VALUES (@UserAccountID_, @VerificationDateTime);
COMMIT TRANSACTION;
END

View File

@@ -1,38 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City
(StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION
END;

View File

@@ -1,24 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF
EXISTS (SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES (@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
END;

View File

@@ -1,32 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -1,59 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@CityID UNIQUEIDENTIFIER,
@AddressLine1 NVARCHAR(256),
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID;
END

View File

@@ -1,17 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET
NOCOUNT ON;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.Timer,
bpl.BreweryPostLocationID,
bpl.CityID,
bpl.AddressLine1,
bpl.AddressLine2,
bpl.PostalCode,
bpl.Coordinates
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -1,9 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *
FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID;
END

View File

@@ -1,77 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -29,6 +29,10 @@
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
</ItemGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
ConnectionStrings.SqliteConnectionString at runtime. -->

View File

@@ -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;
}
}

View File

@@ -12,7 +12,7 @@ using Features.Locations.Dtos;
using Features.Locations.Repository;
using idunno.Password;
using Infrastructure.PasswordHashing;
using Infrastructure.Sql;
using Infrastructure.Sql.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
@@ -25,7 +25,7 @@ static async Task<int> RunAsync()
{
ServiceCollection services = [];
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
services.AddInfrastructureSql();
services.AddFeaturesBreweries();
services.AddFeaturesLocations();
services.AddFeaturesAuth();

View File

@@ -9,6 +9,5 @@ public class BreweryPost
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public byte[]? Timer { get; set; }
public UserAccount PostedBy { get; set; } = null!;
public BreweryPostLocation? Location { get; set; }
}

View File

@@ -10,5 +10,4 @@ public class BreweryPostLocation
public Guid CityId { get; set; }
public byte[]? Coordinates { get; set; }
public byte[]? Timer { get; set; }
public BreweryPost BreweryPost { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Entities;
public class City
{
public Guid CityId { get; set; }
public string CityName { get; set; } = string.Empty;
public Guid StateProvinceId { get; set; }
}

View File

@@ -11,7 +11,4 @@ public class UserAccount
public DateTime? UpdatedAt { get; set; }
public DateTime DateOfBirth { get; set; }
public byte[]? Timer { get; set; }
public ICollection<BreweryPost> BreweryPosts { get; set; } = [];
public ICollection<UserCredential> UserCredentials { get; set; } = [];
public UserVerification? Verification { get; set; }
}

View File

@@ -8,5 +8,4 @@ public class UserCredential
public DateTime Expiry { get; set; }
public string Hash { get; set; } = string.Empty;
public byte[]? Timer { get; set; }
public UserAccount UserAccount { get; set; } = null!;
}

View File

@@ -6,5 +6,4 @@ public class UserVerification
public Guid UserAccountId { get; set; }
public DateTime VerificationDateTime { get; set; }
public byte[]? Timer { get; set; }
public UserAccount UserAccount { get; set; } = null!;
}

View File

@@ -19,10 +19,21 @@ public class AuthRepositoryTests
Guid expectedUserId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
// DbMocker's ReturnsScalar(Guid) doesn't round-trip correctly, so a single-row/single-column
// ReturnsTable is used to fake the scalar OUTPUT INSERTED.UserAccountID read instead.
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.UserAccount"))
.ReturnsTable(
MockTable.WithColumns(("UserAccountID", typeof(Guid))).AddRow(expectedUserId)
);
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.UserCredential"))
.ReturnsScalar(1);
// Mock the subsequent read for the newly created user by id
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
conn.Mocks.When(cmd =>
cmd.CommandText.Contains("FROM dbo.UserAccount")
&& cmd.CommandText.Contains("WHERE UserAccountID")
)
.ReturnsTable(
MockTable
.WithColumns(
@@ -76,7 +87,7 @@ public class AuthRepositoryTests
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Email = @Email"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -119,7 +130,7 @@ public class AuthRepositoryTests
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Email = @Email"))
.ReturnsTable(MockTable.Empty());
AuthRepository repo = CreateRepo(conn);
@@ -134,7 +145,7 @@ public class AuthRepositoryTests
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Username = @Username"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -175,7 +186,7 @@ public class AuthRepositoryTests
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Username = @Username"))
.ReturnsTable(MockTable.Empty());
AuthRepository repo = CreateRepo(conn);
@@ -191,7 +202,7 @@ public class AuthRepositoryTests
Guid credentialId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.UserCredential"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -219,7 +230,7 @@ public class AuthRepositoryTests
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.UserCredential"))
.ReturnsTable(MockTable.Empty());
AuthRepository repo = CreateRepo(conn);
@@ -235,7 +246,11 @@ public class AuthRepositoryTests
string newPasswordHash = "new_hashed_password";
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount"))
.ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("UPDATE dbo.UserCredential")).ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.UserCredential"))
.ReturnsScalar(1);
AuthRepository repo = CreateRepo(conn);
@@ -243,4 +258,18 @@ public class AuthRepositoryTests
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync();
}
[Fact]
public async Task RotateCredentialAsync_ThrowsNotFound_WhenUserAccountMissing()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount"))
.ReturnsTable(MockTable.Empty());
AuthRepository repo = CreateRepo(conn);
Func<Task> act = async () => await repo.RotateCredentialAsync(Guid.NewGuid(), "hash");
await act.Should().ThrowAsync<Domain.Exceptions.NotFoundException>();
}
}

View File

@@ -3,6 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.Auth</RootNamespace>
</PropertyGroup>

View File

@@ -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,165 @@ using Microsoft.Data.SqlClient;
namespace Features.Auth.Repository;
/// <summary>
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification.
/// Dapper-based implementation of <see cref="IAuthRepository" />, handling user registration,
/// credential lookup/rotation, and account verification.
/// </summary>
public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory),
IAuthRepository
{
private const string SelectColumns =
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer";
/// <summary>
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
/// procedure, then fetches and returns the newly created user.
/// Registers a new user account and its initial credential in a single transaction, then fetches
/// and returns the newly created user.
/// </summary>
/// <remarks>
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
/// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail.
/// </remarks>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
await using DbTransaction transaction = await connection.BeginTransactionAsync();
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)
{
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;
userAccountId = await connection.ExecuteScalarAsync<Guid>(
new CommandDefinition(
"""
INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
OUTPUT INSERTED.UserAccountID
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
""",
new { username, firstName, lastName, dateOfBirth, email },
transaction
)
);
int credentialRows = await connection.ExecuteAsync(
new CommandDefinition(
"INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash);",
new { UserAccountId = userAccountId, Hash = passwordHash },
transaction
)
);
if (credentialRows == 0)
throw new InvalidOperationException("Failed to create user credential.");
await transaction.CommitAsync();
}
catch
{
userAccountId = Guid.Empty;
}
await transaction.RollbackAsync();
throw;
}
return await GetUserByIdAsync(userAccountId)
?? throw new Exception("Failed to retrieve newly registered user.");
}
/// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
/// <summary>Retrieves a user account by email.</summary>
public async Task<UserAccount?> 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<UserAccount>(
$"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Email = @Email",
new { Email = email }
);
}
/// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
/// <summary>Retrieves a user account by username.</summary>
public async Task<UserAccount?> 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<UserAccount>(
$"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Username = @Username",
new { Username = username }
);
}
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account using the
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
/// </summary>
/// <summary>Retrieves the active (non-revoked) credential for a user account.</summary>
public async Task<UserCredential?> 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<UserCredential>(
"""
SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, Timer
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
""",
new { UserAccountId = userAccountId }
);
}
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
/// using the <c>USP_RotateUserCredential</c> stored procedure.
/// Rotates a user's credential by revoking all existing credentials and creating a new one.
/// </summary>
/// <exception cref="NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
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<int?>(
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;
}
}
/// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
/// <summary>Retrieves a user account by ID.</summary>
public async Task<UserAccount?> 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<UserAccount>(
$"SELECT {SelectColumns} FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId",
new { UserAccountId = userAccountId }
);
}
/// <summary>
/// Marks a user account as confirmed by creating a verification record via the
/// <c>USP_CreateUserVerification</c> 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.
/// Marks a user account as confirmed by inserting a verification record. 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.
/// </summary>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
/// 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.
/// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
{
@@ -169,15 +180,13 @@ 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))
{
@@ -192,15 +201,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
public async Task<bool> 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<int?>(
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId",
new { UserAccountId = userAccountId }
);
return result.HasValue;
}
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
@@ -209,67 +214,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"],
};
}
/// <summary>
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
/// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary>
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<DataRow>()
.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;
}
/// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
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);
}
}

View File

@@ -85,12 +85,7 @@ public class TokenService : ITokenService
);
}
/// <summary>
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken" />.
/// </summary>
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
/// </exception>

View File

@@ -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<BreweryPost>()))
.Callback<BreweryPost>(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.Timer.Should().Equal(command.Timer);
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<BreweryPost>()))
.Callback<BreweryPost>(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<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
.ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None);

View File

@@ -1,5 +1,6 @@
using Apps72.Dev.Data.DbMocker;
using Domain.Entities;
using Domain.Exceptions;
using Features.Breweries.Repository;
using FluentAssertions;
@@ -18,12 +19,9 @@ public class BreweryRepositoryTests
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)
conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -70,7 +68,7 @@ public class BreweryRepositoryTests
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation"))
.ReturnsTable(MockTable.Empty());
BreweryRepository repo = CreateRepo(conn);
BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
@@ -81,7 +79,12 @@ public class BreweryRepositoryTests
public async Task CreateAsync_ExecutesSuccessfully()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery").ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount")).ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")).ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPost (")).ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPostLocation"))
.ReturnsScalar(1);
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
@@ -104,4 +107,223 @@ public class BreweryRepositoryTests
Func<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().NotThrowAsync();
}
[Fact]
public async Task CreateAsync_ThrowsArgumentException_WhenLocationMissing()
{
MockDbConnection conn = new();
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
BreweryPostId = Guid.NewGuid(),
PostedById = Guid.NewGuid(),
BreweryName = "Test Brewery",
Description = "A test brewery description",
};
Func<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task CreateAsync_ThrowsNotFound_WhenUserMissing()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount"))
.ReturnsScalar((int?)null);
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
BreweryPostId = Guid.NewGuid(),
PostedById = Guid.NewGuid(),
BreweryName = "Test Brewery",
Description = "A test brewery description",
Location = new BreweryPostLocation
{
BreweryPostLocationId = Guid.NewGuid(),
CityId = Guid.NewGuid(),
AddressLine1 = "123 Main St",
PostalCode = "12345",
},
};
Func<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task CreateAsync_UsesClientProvidedIds()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount")).ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")).ReturnsScalar(1);
Guid? capturedBreweryPostId = null;
Guid? capturedLocationId = null;
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPost ("))
.ReturnsScalar(cmd =>
{
capturedBreweryPostId = (Guid?)
cmd.Parameters.First(p => p.ParameterName == "BreweryPostId").Value;
return 1;
});
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPostLocation"))
.ReturnsScalar(cmd =>
{
capturedLocationId = (Guid?)
cmd.Parameters.First(p => p.ParameterName == "BreweryPostLocationId").Value;
return 1;
});
Guid expectedBreweryId = Guid.NewGuid();
Guid expectedLocationId = Guid.NewGuid();
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
BreweryPostId = expectedBreweryId,
PostedById = Guid.NewGuid(),
BreweryName = "Test Brewery",
Description = "A test brewery description",
Location = new BreweryPostLocation
{
BreweryPostLocationId = expectedLocationId,
CityId = Guid.NewGuid(),
AddressLine1 = "123 Main St",
PostalCode = "12345",
},
};
await repo.CreateAsync(brewery);
capturedBreweryPostId.Should().Be(expectedBreweryId);
capturedLocationId.Should().Be(expectedLocationId);
}
[Fact]
public async Task UpdateAsync_ReturnsFreshlyPersistedBrewery_WhenSuccessful()
{
Guid breweryId = Guid.NewGuid();
byte[] newTimer = [0x00, 0x02];
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPost WHERE BreweryPostID")
)
.ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")).ReturnsScalar(1);
conn.Mocks.When(cmd =>
cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND Timer = @Timer")
)
.ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPostLocation"))
.ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("UPDATE dbo.BreweryPostLocation"))
.ReturnsScalar(1);
// Repository re-fetches the row after a successful update to return the new Timer.
conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation"))
.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(),
"Renamed Brewery",
"Updated description",
DateTime.UtcNow,
DateTime.UtcNow,
newTimer,
Guid.NewGuid(),
Guid.NewGuid(),
"123 Main St",
null,
"12345",
null
)
);
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
BreweryPostId = breweryId,
PostedById = Guid.NewGuid(),
BreweryName = "Renamed Brewery",
Description = "Updated description",
Timer = [0x00, 0x01],
Location = new BreweryPostLocation
{
CityId = Guid.NewGuid(),
AddressLine1 = "123 Main St",
PostalCode = "12345",
},
};
BreweryPost result = await repo.UpdateAsync(brewery);
result.BreweryPostId.Should().Be(breweryId);
result.Timer.Should().Equal(newTimer);
}
[Fact]
public async Task UpdateAsync_ThrowsNotFound_WhenBreweryMissing()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPost WHERE BreweryPostID")
)
.ReturnsScalar((int?)null);
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), Timer = [0x00, 0x01] };
Func<Task> act = async () => await repo.UpdateAsync(brewery);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task UpdateAsync_ThrowsConflict_WhenTimerStale()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPost WHERE BreweryPostID")
)
.ReturnsScalar(1);
// Brewery exists but the conditional UPDATE (WHERE ... AND Timer = @Timer) matches no rows,
// since the caller's Timer no longer matches the stored row.
conn.Mocks.When(cmd =>
cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND Timer = @Timer")
)
.ReturnsScalar(0);
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
BreweryPostId = Guid.NewGuid(),
BreweryName = "Renamed Brewery",
Description = "Updated description",
Timer = [0x00, 0x01],
};
Func<Task> act = async () => await repo.UpdateAsync(brewery);
await act.Should().ThrowAsync<ConflictException>();
}
}

View File

@@ -14,10 +14,13 @@ public record UpdateBreweryLocation(
/// <summary>
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
/// A <c>null</c> <see cref="Location" /> clears the brewery's location.
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="Timer" /> must be the
/// row-version last read for this brewery (e.g. from a prior <c>GET</c>); the update is rejected with a
/// <c>409 Conflict</c> if the brewery was modified since then.
/// </summary>
public record UpdateBreweryCommand(
Guid BreweryPostId,
byte[] Timer,
Guid PostedById,
string BreweryName,
string Description,

View File

@@ -12,6 +12,14 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
/// the brewery's location is cleared.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>, or its
/// location's <c>CityId</c> does not exist.
/// </exception>
/// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when the brewery was modified by another request since <paramref name="request" />.
/// <c>Timer</c> was read.
/// </exception>
public async Task<BreweryDto> Handle(
UpdateBreweryCommand request,
CancellationToken cancellationToken
@@ -23,6 +31,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
Timer = request.Timer,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null
? null
@@ -38,7 +47,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
},
};
await repository.UpdateAsync(entity);
return entity.ToDto();
BreweryPost updated = await repository.UpdateAsync(entity);
return updated.ToDto();
}
}

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace Features.Breweries.Commands.UpdateBrewery;
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
{
public UpdateBreweryValidator()
{
RuleFor(x => x.Timer)
.NotEmpty()
.WithMessage("Timer is required to detect conflicting concurrent updates.");
}
}

View File

@@ -77,7 +77,11 @@ public class BreweryController(IMediator mediator) : ControllerBase
}
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
/// <returns><c>200 OK</c> with the updated brewery; or <c>400 Bad Request</c> if the route ID does not match the payload ID.</returns>
/// <returns>
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or
/// <c>409 Conflict</c> if the brewery was modified since <c>command.Timer</c> was read.
/// </returns>
[HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id,

View File

@@ -3,9 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Breweries.DependencyInjection;
/// <summary>
/// Registers the services owned by the Breweries feature slice.
/// </summary>
public static class FeaturesBreweriesServiceCollectionExtensions
{
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)

View File

@@ -12,6 +12,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />

View File

@@ -3,7 +3,4 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>
/// Retrieves a paginated list of brewery posts.
/// </summary>
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;

View File

@@ -1,54 +1,73 @@
using System.Data;
using System.Data.Common;
using Dapper;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Sql;
namespace Features.Breweries.Repository;
/// <summary>
/// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
/// procedures.
/// Dapper-based implementation of <see cref="IBreweryRepository" />.
/// </summary>
/// <remarks>
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic
/// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT
/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to <c>byte[]</c>.
/// The two read methods below therefore read rows manually via <see cref="DbDataReader.GetFieldValue{T}" />
/// (the same approach the previous ADO.NET implementation used, which SqlClient supports specifically
/// for UDT columns). All write methods use Dapper, since they only bind parameters and never
/// deserialize <c>Coordinates</c> back out of a result set.
/// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory),
IBreweryRepository
{
/// <summary>Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.</summary>
private const string SelectColumns =
"bp.BreweryPostID, bp.PostedByID, bp.BreweryName, bp.Description, bp.CreatedAt, bp.UpdatedAt, bp.Timer, "
+ "bpl.BreweryPostLocationID, bpl.CityID, bpl.AddressLine1, bpl.AddressLine2, bpl.PostalCode, bpl.Coordinates";
/// <summary>
/// Retrieves a brewery post by ID, joined to its location. A brewery with no location row
/// returns <c>null</c> (matches the previous stored procedure's <c>INNER JOIN</c> behavior).
/// </summary>
public async Task<BreweryPost?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "USP_GetBreweryById";
AddParameter(command, "@BreweryPostID", id);
command.CommandText =
$"""
SELECT {SelectColumns}
FROM dbo.BreweryPost bp
INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostId
""";
AddParameter(command, "@BreweryPostId", id);
await using DbDataReader reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
return MapToEntity(reader);
return null;
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
/// <summary>
/// Retrieves all brewery posts, optionally paginated, using the <c>USP_GetAllBreweries</c>
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
/// corresponding argument has a value.
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts
/// without a location are included, with <see cref="BreweryPost.Location" /> left <c>null</c>.
/// </summary>
public async Task<IEnumerable<BreweryPost>> 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);
command.CommandText =
$"""
SELECT {SelectColumns}
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""";
AddParameter(command, "@Offset", offset ?? 0);
AddParameter(command, "@Limit", limit ?? int.MaxValue);
await using DbDataReader reader = await command.ExecuteReaderAsync();
List<BreweryPost> breweries = new();
List<BreweryPost> breweries = [];
while (await reader.ReadAsync())
breweries.Add(MapToEntity(reader));
@@ -57,77 +76,256 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
/// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, using the
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is
/// <c>null</c>, any existing location for the brewery is removed.
/// Updates a brewery post's name and description, and upserts or clears its location. When
/// <paramref name="brewery" />.<c>Location</c> is <c>null</c>, any existing location for the
/// brewery is removed.
/// </summary>
public async Task UpdateAsync(BreweryPost brewery)
/// <exception cref="NotFoundException">Thrown when the brewery, or the given city, does not exist.</exception>
/// <exception cref="ConflictException">
/// Thrown when <paramref name="brewery" />'s <c>Timer</c> no longer matches the stored row.
/// </exception>
public async Task<BreweryPost> 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<int?>(
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<int?>(
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 Timer = @Timer
""",
new
{
brewery.BreweryPostId,
brewery.BreweryName,
brewery.Description,
brewery.Timer,
},
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<int?>(
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."
);
}
/// <summary>
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
/// and photos are removed via cascading foreign keys.
/// Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.
/// </summary>
/// <exception cref="NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception>
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.");
}
/// <summary>Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.</summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception>
/// <summary>Creates a new brewery post, including its location details.</summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception>
/// <exception cref="NotFoundException">
/// Thrown when <paramref name="brewery" />.<c>PostedById</c> or <c>Location.CityId</c> does not exist.
/// </exception>
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.");
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<int?>(
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<int?>(
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;
}
}
/// <summary>
/// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
/// associated <see cref="BreweryPostLocation" /> if location columns are present in the result set.
/// associated <see cref="BreweryPostLocation" /> if a location row was joined in.
/// </summary>
protected override BreweryPost MapToEntity(DbDataReader reader)
private static BreweryPost MapToEntity(DbDataReader reader)
{
BreweryPost brewery = new();
int ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
int ordPostedById = reader.GetOrdinal("PostedById");
int ordBreweryName = reader.GetOrdinal("BreweryName");
@@ -136,62 +334,42 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
int ordTimer = reader.GetOrdinal("Timer");
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);
brewery.UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt);
// Read timer (varbinary/rowversion) robustly
if (reader.IsDBNull(ordTimer))
brewery.Timer = null;
else
try
BreweryPost brewery = new()
{
brewery.Timer = reader.GetFieldValue<byte[]>(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;
}
BreweryPostId = reader.GetGuid(ordBreweryPostId),
PostedById = reader.GetGuid(ordPostedById),
BreweryName = reader.GetString(ordBreweryName),
Description = reader.GetString(ordDescription),
CreatedAt = reader.GetDateTime(ordCreatedAt),
UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt),
Timer = reader.IsDBNull(ordTimer) ? null : reader.GetFieldValue<byte[]>(ordTimer),
};
// Map BreweryPostLocation if columns are present
try
{
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
if (!reader.IsDBNull(ordLocationId))
{
BreweryPostLocation location = new()
int ordCoordinates = reader.GetOrdinal("Coordinates");
int ordAddressLine2 = reader.GetOrdinal("AddressLine2");
brewery.Location = new BreweryPostLocation
{
BreweryPostLocationId = reader.GetGuid(ordLocationId),
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
BreweryPostId = brewery.BreweryPostId,
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2"))
AddressLine2 = reader.IsDBNull(ordAddressLine2)
? null
: reader.GetString(reader.GetOrdinal("AddressLine2")),
: reader.GetString(ordAddressLine2),
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates"))
Coordinates = reader.IsDBNull(ordCoordinates)
? null
: reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates")),
: reader.GetFieldValue<byte[]>(ordCoordinates),
};
brewery.Location = location;
}
}
catch (IndexOutOfRangeException)
{
// Location columns not present, skip mapping location
}
return brewery;
}
/// <summary>Adds a parameter to <paramref name="command" />, converting <c>null</c> to <see cref="DBNull.Value" />.</summary>
private static void AddParameter(DbCommand command, string name, object? value)
{
DbParameter p = command.CreateParameter();

View File

@@ -7,12 +7,25 @@ namespace Features.Breweries.Repository;
/// </summary>
public interface IBreweryRepository
{
/// <summary>Retrieves a brewery post by ID, or <c>null</c> if not found.</summary>
/// <summary>Returns <c>null</c> if no brewery post exists with the given ID.</summary>
Task<BreweryPost?> GetByIdAsync(Guid id);
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
Task UpdateAsync(BreweryPost brewery);
/// <summary>
/// Updates a brewery post, enforcing optimistic concurrency via <paramref name="brewery" />'s
/// <c>Timer</c>: the update is rejected if the row was modified since <c>Timer</c> was read.
/// Returns the freshly persisted brewery, including its new <c>Timer</c>.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with the given ID, or the given location's <c>CityId</c> does not
/// exist.
/// </exception>
/// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when <paramref name="brewery" />'s <c>Timer</c> no longer matches the stored row (it was
/// modified by another request since it was last read).
/// </exception>
Task<BreweryPost> UpdateAsync(BreweryPost brewery);
Task DeleteAsync(Guid id);

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.Locations.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.Locations\Features.Locations.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,114 @@
using Apps72.Dev.Data.DbMocker;
using Domain.Exceptions;
using Features.Locations.Dtos;
using Features.Locations.Repository;
using FluentAssertions;
namespace Features.Locations.Tests.Repository;
public class LocationRepositoryTests
{
private static readonly CityLocation Location = new(
"London",
"Ontario",
"CA-ON",
"Canada",
"CA"
);
private static LocationRepository CreateRepo(MockDbConnection conn)
{
return new LocationRepository(new TestConnectionFactory(conn));
}
// DbMocker's ReturnsScalar(Guid) doesn't round-trip correctly (it returns an unrelated value
// instead of the Guid), so every scalar Guid lookup here is faked via a single-row/single-column
// ReturnsTable instead -- ExecuteScalar reads a mocked table's first cell correctly.
private static MockTable GuidTable(Guid id)
{
return MockTable.WithColumns(("Value", typeof(Guid))).AddRow(id);
}
[Fact]
public async Task GetOrCreateCityIdAsync_ReturnsExistingCityId_WhenFullChainAlreadyExists()
{
Guid countryId = Guid.NewGuid();
Guid stateProvinceId = Guid.NewGuid();
Guid cityId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.Country"))
.ReturnsTable(GuidTable(countryId));
conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.StateProvince"))
.ReturnsTable(GuidTable(stateProvinceId));
conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.City c"))
.ReturnsTable(GuidTable(cityId));
LocationRepository repo = CreateRepo(conn);
// No INSERT is mocked; if the repository tried to create anything despite the chain
// already existing, this would fail with a MockException.
Guid result = await repo.GetOrCreateCityIdAsync(Location);
result.Should().Be(cityId);
}
[Fact]
public async Task GetOrCreateCityIdAsync_CreatesFullChain_WhenNothingExists()
{
Guid countryId = Guid.NewGuid();
Guid stateProvinceId = Guid.NewGuid();
Guid cityId = Guid.NewGuid();
MockDbConnection conn = new();
// First lookup (before creation) returns no rows; second lookup (after insert) returns the ID.
int countryLookups = 0;
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT CountryID FROM dbo.Country"))
.ReturnsTable(_ => ++countryLookups == 1 ? MockTable.Empty() : GuidTable(countryId));
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.Country"))
.ReturnsTable(MockTable.Empty());
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.Country")).ReturnsScalar(1);
int stateLookups = 0;
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT StateProvinceID FROM dbo.StateProvince"))
.ReturnsTable(_ => ++stateLookups == 1 ? MockTable.Empty() : GuidTable(stateProvinceId));
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.StateProvince"))
.ReturnsTable(MockTable.Empty());
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.StateProvince"))
.ReturnsScalar(1);
int cityLookups = 0;
conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.City c"))
.ReturnsTable(_ => ++cityLookups == 1 ? MockTable.Empty() : GuidTable(cityId));
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City"))
.ReturnsTable(MockTable.Empty());
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.City")).ReturnsScalar(1);
LocationRepository repo = CreateRepo(conn);
Guid result = await repo.GetOrCreateCityIdAsync(Location);
result.Should().Be(cityId);
}
[Fact]
public async Task GetOrCreateCityIdAsync_ThrowsNotFound_WhenCountryMissingForNewStateProvince()
{
MockDbConnection conn = new();
// Country never resolves, even after the (mocked) insert -- simulates a race where the
// country is missing by the time the state/province lookup runs.
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT CountryID FROM dbo.Country"))
.ReturnsTable(MockTable.Empty());
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.Country"))
.ReturnsTable(MockTable.Empty());
conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.Country")).ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT StateProvinceID FROM dbo.StateProvince"))
.ReturnsTable(MockTable.Empty());
LocationRepository repo = CreateRepo(conn);
Func<Task> act = async () => await repo.GetOrCreateCityIdAsync(Location);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -0,0 +1,14 @@
using System.Data.Common;
using Infrastructure.Sql;
namespace Features.Locations.Tests.Repository;
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection()
{
return _conn;
}
}

View File

@@ -0,0 +1,13 @@
using Features.Locations.Repository;
using Microsoft.Extensions.DependencyInjection;
namespace Features.Locations.DependencyInjection;
public static class FeaturesLocationsServiceCollectionExtensions
{
public static IServiceCollection AddFeaturesLocations(this IServiceCollection services)
{
services.AddScoped<ILocationRepository, LocationRepository>();
return services;
}
}

View File

@@ -0,0 +1,13 @@
namespace Features.Locations.Dtos;
/// <summary>
/// Describes a city's location for resolution by <c>ILocationRepository</c>, including the
/// state/province and country it belongs to.
/// </summary>
public sealed record CityLocation(
string CityName,
string StateProvinceName,
string StateProvinceIsoCode,
string CountryName,
string CountryIsoCode
);

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.Locations</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using Features.Locations.Dtos;
namespace Features.Locations.Repository;
/// <summary>
/// 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).
/// </summary>
public interface ILocationRepository
{
/// <summary>
/// Resolves <paramref name="location" /> to a City ID, creating the Country, StateProvince,
/// and/or City rows if any part of the chain doesn't exist yet.
/// </summary>
Task<Guid> GetOrCreateCityIdAsync(CityLocation location);
}

View File

@@ -0,0 +1,158 @@
using System.Data.Common;
using Dapper;
using Domain.Entities;
using Domain.Exceptions;
using Features.Locations.Dtos;
using Infrastructure.Sql;
namespace Features.Locations.Repository;
/// <summary>
/// Dapper-based implementation of <see cref="ILocationRepository" />.
/// </summary>
public class LocationRepository(ISqlConnectionFactory connectionFactory)
: Repository<City>(connectionFactory),
ILocationRepository
{
/// <inheritdoc />
/// <remarks>
/// Not fully race-safe under concurrent callers for the same new city (neither was the stored
/// procedure this replaces, since <c>City</c> 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.
/// </remarks>
public async Task<Guid> 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<int?>(
"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<int?>(
"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 }
);
}
/// <summary>
/// Ensures a StateProvince row exists for <paramref name="isoCode" />.
/// </summary>
/// <exception cref="NotFoundException">
/// Thrown when no Country exists for <paramref name="countryIsoCode" />. 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.
/// </exception>
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<int?>(
"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<Guid?> GetCountryIdAsync(string isoCode)
{
await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>(
"SELECT CountryID FROM dbo.Country WHERE ISO3166_1 = @ISO3166_1",
new { ISO3166_1 = isoCode }
);
}
private async Task<Guid?> GetStateProvinceIdAsync(string isoCode)
{
await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>(
"SELECT StateProvinceID FROM dbo.StateProvince WHERE ISO3166_2 = @ISO3166_2",
new { ISO3166_2 = isoCode }
);
}
private async Task<Guid?> GetCityIdAsync(string cityName, string stateProvinceIsoCode)
{
await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>(
"""
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 }
);
}
}

View File

@@ -1,5 +1,6 @@
using Apps72.Dev.Data.DbMocker;
using Domain.Entities;
using Domain.Exceptions;
using Features.UserManagement.Repository;
using FluentAssertions;
@@ -16,7 +17,7 @@ public class UserAccountRepositoryTests
public async Task GetByIdAsync_ReturnsRow_Mapped()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE UserAccountID = @UserAccountId"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -57,7 +58,7 @@ public class UserAccountRepositoryTests
public async Task GetAllAsync_ReturnsMultipleRows()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
conn.Mocks.When(cmd => cmd.CommandText.Contains("OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -105,7 +106,7 @@ public class UserAccountRepositoryTests
public async Task GetByUsername_ReturnsRow()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Username = @Username"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -142,7 +143,7 @@ public class UserAccountRepositoryTests
public async Task GetByEmail_ReturnsRow()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Email = @Email"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -174,4 +175,46 @@ public class UserAccountRepositoryTests
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}
[Fact]
public async Task GetAllAsync_AppliesLimitAndOffset()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText.Contains("OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY")
)
.ReturnsTable(MockTable.Empty());
UserAccountRepository repo = CreateRepo(conn);
// Fails with a MockException if the repository doesn't emit OFFSET/FETCH pagination SQL.
await repo.GetAllAsync(10, 20);
}
[Fact]
public async Task UpdateAsync_ThrowsNotFound_WhenAccountMissing()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText.Contains("UPDATE dbo.UserAccount"))
.ReturnsTable(MockTable.Empty());
UserAccountRepository repo = CreateRepo(conn);
UserAccount account = new() { UserAccountId = Guid.NewGuid(), Username = "ghost" };
Func<Task> act = async () => await repo.UpdateAsync(account);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task DeleteAsync_ThrowsNotFound_WhenAccountMissing()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText.Contains("DELETE FROM dbo.UserAccount"))
.ReturnsTable(MockTable.Empty());
UserAccountRepository repo = CreateRepo(conn);
Func<Task> act = async () => await repo.DeleteAsync(Guid.NewGuid());
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -7,14 +7,9 @@ namespace Features.UserManagement.Repository;
/// </summary>
public interface IUserAccountRepository
{
/// <summary>
/// Retrieves a user account by its unique identifier, or <c>null</c> if not found.
/// </summary>
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
Task<UserAccount?> GetByIdAsync(Guid id);
/// <summary>
/// Retrieves all user accounts, optionally paginated.
/// </summary>
/// <param name="limit"><c>null</c> for no limit.</param>
/// <param name="offset"><c>null</c> for no offset.</param>
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
@@ -24,13 +19,9 @@ public interface IUserAccountRepository
Task DeleteAsync(Guid id);
/// <summary>
/// Retrieves a user account by username, or <c>null</c> if not found.
/// </summary>
/// <summary>Returns <c>null</c> if no user account exists with the given username.</summary>
Task<UserAccount?> GetByUsernameAsync(string username);
/// <summary>
/// Retrieves a user account by email address, or <c>null</c> if not found.
/// </summary>
/// <summary>Returns <c>null</c> if no user account exists with the given email address.</summary>
Task<UserAccount?> GetByEmailAsync(string email);
}

View File

@@ -1,149 +1,108 @@
using System.Data;
using System.Data.Common;
using Dapper;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Sql;
namespace Features.UserManagement.Repository;
/// <summary>
/// ADO.NET-based implementation of <see cref="IUserAccountRepository" /> backed by SQL Server
/// stored procedures.
/// Dapper-based implementation of <see cref="IUserAccountRepository" />.
/// </summary>
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory),
IUserAccountRepository
{
/// <summary>Uses the <c>usp_GetUserAccountById</c> stored procedure.</summary>
private const string SelectColumns =
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer";
/// <inheritdoc />
public async Task<UserAccount?> 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<UserAccount>(
$"SELECT {SelectColumns} FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId",
new { UserAccountId = id }
);
}
/// <summary>
/// Uses the <c>usp_GetAllUserAccounts</c> stored procedure. The <c>@Limit</c> and <c>@Offset</c>
/// parameters are only added when their corresponding argument has a value.
/// </summary>
/// <inheritdoc />
/// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// </remarks>
public async Task<IEnumerable<UserAccount>> 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<UserAccount> users = new();
while (await reader.ReadAsync())
users.Add(MapToEntity(reader));
return users;
return await connection.QueryAsync<UserAccount>(
$"""
SELECT {SelectColumns}
FROM dbo.UserAccount
ORDER BY CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""",
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
);
}
/// <summary>
/// Updates a user account's username, first name, last name, email, and date of birth using
/// the <c>usp_UpdateUserAccount</c> stored procedure.
/// </summary>
/// <inheritdoc />
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
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.");
}
/// <summary>Uses the <c>usp_DeleteUserAccount</c> stored procedure.</summary>
/// <inheritdoc />
/// <exception cref="NotFoundException">Thrown when no user account exists with the given <paramref name="id" />.</exception>
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.");
}
/// <summary>Uses the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
/// <inheritdoc />
public async Task<UserAccount?> 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<UserAccount>(
$"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Username = @Username",
new { Username = username }
);
}
/// <summary>Uses the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
/// <inheritdoc />
public async Task<UserAccount?> 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"],
};
}
/// <summary>Converts <c>null</c> values to <see cref="DBNull.Value" /> when adding the parameter.</summary>
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<UserAccount>(
$"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Email = @Email",
new { Email = email }
);
}
}

View File

@@ -6,18 +6,12 @@ namespace Infrastructure.Email.Templates.Rendering;
public interface IEmailTemplateProvider
{
/// <summary>
/// Renders the UserRegisteredEmail template with the specified parameters.
/// Renders the UserRegisteredEmail template.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The email confirmation link</param>
/// <returns>The rendered HTML string</returns>
Task<string> RenderUserRegisteredEmailAsync(string username, string confirmationLink);
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// Renders the ResendConfirmation template.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The new confirmation link</param>
/// <returns>The rendered HTML string</returns>
Task<string> RenderResendConfirmationEmailAsync(string username, string confirmationLink);
}

View File

@@ -1,7 +1,7 @@
namespace Infrastructure.Email;
/// <summary>
/// Service for sending emails via SMTP.
/// Service for sending emails.
/// </summary>
public interface IEmailProvider
{

View File

@@ -12,14 +12,12 @@ namespace Infrastructure.Jwt;
/// </summary>
public class JwtInfrastructure : ITokenInfrastructure
{
/// <summary>
/// Generates a signed JWT, signed using HMAC-SHA256 with the provided secret.
/// </summary>
/// <inheritdoc />
/// <remarks>
/// Sets registered claims <c>sub</c> (userId), <c>unique_name</c> (username), <c>iat</c> (current UTC time),
/// <c>exp</c> (expiry), and <c>jti</c> (a newly generated GUID).
/// </remarks>
/// <param name="secret">The symmetric secret used to sign the token (encoded as UTF-8 bytes).</param>
/// <param name="secret">The symmetric secret used to sign the token, encoded as UTF-8 bytes.</param>
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);
}
/// <summary>
/// Validates a JWT's signature and lifetime (issuer and audience validation are disabled),
/// using the provided secret as the HMAC-SHA256 symmetric signing key.
/// </summary>
/// <param name="secret">The symmetric secret used to verify the token's signature (encoded as UTF-8 bytes).</param>
/// <inheritdoc />
/// <remarks>Issuer and audience validation are disabled; only the signature and lifetime are validated.</remarks>
/// <param name="secret">The symmetric secret used to verify the token's signature, encoded as UTF-8 bytes.</param>
/// <exception cref="UnauthorizedException">
/// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation.
/// </exception>

View File

@@ -14,9 +14,7 @@ public class Argon2Infrastructure : IPasswordInfrastructure
private const int ArgonIterations = 4;
private const int ArgonMemoryKb = 65536; // 64MB
/// <summary>
/// Hashes a plaintext password using Argon2id with a fresh random salt.
/// </summary>
/// <inheritdoc />
/// <returns>A string of the form <c>"{base64Salt}:{base64Hash}"</c>, suitable for storage.</returns>
public string Hash(string password)
{
@@ -33,10 +31,8 @@ public class Argon2Infrastructure : IPasswordInfrastructure
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
/// <summary>
/// Verifies a plaintext password against a stored salt/hash string, using a fixed-time comparison
/// to mitigate timing attacks.
/// </summary>
/// <inheritdoc />
/// <remarks>Uses a fixed-time comparison to mitigate timing attacks.</remarks>
/// <returns>
/// <c>false</c> if the password doesn't match, if <paramref name="stored" /> is malformed, or if any
/// other error occurs during verification.

View File

@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
namespace Infrastructure.Sql.DependencyInjection;
public static class InfrastructureSqlServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructureSql(this IServiceCollection services)
{
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
return services;
}
}

View File

@@ -6,12 +6,16 @@
<RootNamespace>Infrastructure.Sql</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.SqlServer.Types" Version="160.1000.6" />
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="9.0.0"
/>
<PackageReference
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.0"
/>
</ItemGroup>
</Project>

View File

@@ -3,8 +3,8 @@ using System.Data.Common;
namespace Infrastructure.Sql;
/// <summary>
/// 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.
/// </summary>
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
@@ -19,9 +19,4 @@ public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
await connection.OpenAsync();
return connection;
}
/// <summary>
/// Maps the current row of a data reader to an instance of <typeparamref name="T" />.
/// </summary>
protected abstract T MapToEntity(DbDataReader reader);
}

View File

@@ -14,22 +14,10 @@ public static class SqlConnectionStringHelper
/// <param name="databaseName">If null, falls back to the DB_NAME environment variable.</param>
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");
}
/// <summary>
/// Resolves the <c>DB_TRUST_SERVER_CERTIFICATE</c> environment variable, defaulting to
/// <c>true</c> when it is unset or not a valid boolean.
/// </summary>
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."
);
}

View File

@@ -0,0 +1,41 @@
namespace Infrastructure.Sql;
/// <summary>
/// Custom SQL Server error numbers (50000+) that this application's stored procedures use to
/// signal domain-level failures via <c>THROW</c>. Repositories catch
/// <see cref="Microsoft.Data.SqlClient.SqlException" /> and switch on <c>ex.Number</c> against
/// these constants to translate the failure into the corresponding <c>Domain.Exceptions</c> type.
/// </summary>
/// <remarks>
/// Each number is shared by every procedure that raises that category of error — it identifies
/// the category, not a single call site. The procedure's own error message (<c>ex.Message</c>)
/// already says which entity or rule was involved, so it is safe to surface directly to callers.
/// </remarks>
public static class SqlErrorCodes
{
/// <summary>
/// A stored procedure's own input validation failed (e.g. a required parameter was <c>NULL</c>).
/// Maps to HTTP 400 Bad Request.
/// </summary>
public const int Validation = 50400;
/// <summary>
/// A stored procedure could not find a row it required, by primary key or foreign key. Maps to
/// HTTP 404 Not Found.
/// </summary>
public const int NotFound = 50404;
/// <summary>
/// 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.
/// </summary>
public const int Conflict = 50409;
/// <summary>
/// A defensive check inside a stored procedure failed under conditions that should be
/// unreachable in normal operation (e.g. an <c>INSERT</c> that should always affect one row
/// affected zero). Left uncaught by repositories so it falls through to the generic
/// <see cref="Microsoft.Data.SqlClient.SqlException" /> handler. Maps to HTTP 500/503.
/// </summary>
public const int InternalFailure = 50500;
}