mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Compare commits
3 Commits
cac6d7f193
...
693f74bb5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693f74bb5b | ||
|
|
6a53167cb8 | ||
|
|
998e5c478b |
@@ -4,7 +4,7 @@ The Biergarten App is a full-stack directory and discovery platform for
|
|||||||
breweries. It provides:
|
breweries. It provides:
|
||||||
|
|
||||||
- JWT-based user authentication, with email confirmation and refresh-token
|
- JWT-based user authentication, with email confirmation and refresh-token
|
||||||
rotation
|
reissue
|
||||||
- A searchable database of brewery locations
|
- A searchable database of brewery locations
|
||||||
- An offline data-generation pipeline that uses large language models (LLMs)
|
- An offline data-generation pipeline that uses large language models (LLMs)
|
||||||
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ For visual representations, see:
|
|||||||
|
|
||||||
The backend organizes business capabilities as feature slices instead of
|
The backend organizes business capabilities as feature slices instead of
|
||||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns its
|
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
|
||||||
own controller, MediatR commands/queries/handlers, validators, and repository,
|
project that owns its own MediatR commands/queries/handlers, validators, and
|
||||||
end to end:
|
repository end to end. `Features.Emails` and `Features.Locations` have no
|
||||||
|
`Controllers/` folder — both are invoked internally by other slices via
|
||||||
|
MediatR/DI, never over HTTP:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────────────────────────────────────────────┐
|
┌───────────────────────────────────────────────────────────────────────┐
|
||||||
@@ -46,15 +48,15 @@ end to end:
|
|||||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||||
└───────────────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────────────┘
|
||||||
↓ discovers controllers via AddApplicationPart
|
↓ discovers controllers via AddApplicationPart
|
||||||
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
|
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
|
||||||
│ Features.Auth │Features. │ Features. │ Features.Emails │
|
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │
|
||||||
│ │Breweries │ UserManagement │ (no controller, │
|
│ │Breweries │ UserManagement │ (no controller, │ Locations │
|
||||||
│ Controller │ Controller │ Controller │ internal only) │
|
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │
|
||||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
|
||||||
│ Queries + │ Queries + │ Queries + │ Handlers │
|
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
|
||||||
│ Handlers │ Handlers │ Handlers │ │
|
│ Handlers │ Handlers │ Handlers │ │ │
|
||||||
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
│ Repository │ Repository │ Repository │ EmailDispatcher │ │
|
||||||
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
|
||||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||||
@@ -108,7 +110,7 @@ own
|
|||||||
- No controllers, no business logic, no feature-specific contracts
|
- No controllers, no business logic, no feature-specific contracts
|
||||||
- Exists purely to host and wire up the feature slices
|
- Exists purely to host and wire up the feature slices
|
||||||
|
|
||||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
|
||||||
|
|
||||||
**Purpose**: Each slice is the complete vertical for one business capability
|
**Purpose**: Each slice is the complete vertical for one business capability
|
||||||
|
|
||||||
@@ -126,7 +128,12 @@ own
|
|||||||
slice's repository/services
|
slice's repository/services
|
||||||
|
|
||||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||||
commands sent from other slices, never over HTTP.
|
commands sent from other slices, never over HTTP. `Features.Locations` also has
|
||||||
|
no `Controllers/` folder and no MediatR handlers — it exposes
|
||||||
|
`ILocationRepository` directly as a plain service, currently consumed only by
|
||||||
|
`Database.Seed` to look up cities while seeding brewery locations. Other slices
|
||||||
|
(for example, `Features.Breweries`) do not yet depend on it; they run their own
|
||||||
|
`CityID` existence checks inline.
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -198,6 +205,9 @@ whichever slices need them
|
|||||||
- `UserAccount` - User profile data
|
- `UserAccount` - User profile data
|
||||||
- `UserCredential` - Authentication credentials
|
- `UserCredential` - Authentication credentials
|
||||||
- `UserVerification` - Account verification state
|
- `UserVerification` - Account verification state
|
||||||
|
- `BreweryPost` - A user-submitted brewery listing
|
||||||
|
- `BreweryPostLocation` - A brewery's address and coordinates
|
||||||
|
- `City` - A city referenced by a brewery location
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -258,7 +268,6 @@ only:
|
|||||||
**Benefits**:
|
**Benefits**:
|
||||||
|
|
||||||
- Testable (easy to mock)
|
- Testable (easy to mock)
|
||||||
- SQL-first approach (stored procedures)
|
|
||||||
- Each slice's data access logic is self-contained
|
- Each slice's data access logic is self-contained
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
@@ -285,22 +294,31 @@ method that registers its repository and slice-internal services
|
|||||||
- Singleton: `ISqlConnectionFactory`
|
- Singleton: `ISqlConnectionFactory`
|
||||||
- Transient: Utilities, helpers
|
- Transient: Utilities, helpers
|
||||||
|
|
||||||
#### SQL-first approach
|
#### Direct SQL via Dapper/ADO.NET
|
||||||
|
|
||||||
**Purpose**: Push complex logic into the database
|
**Purpose**: Keep data access explicit and colocated with the slice that owns
|
||||||
|
it, without an ORM's change-tracking or mapping magic
|
||||||
|
|
||||||
**Strategy**:
|
**Strategy**:
|
||||||
|
|
||||||
- All queries via stored procedures
|
- No ORM (Entity Framework not used); each
|
||||||
- No ORM (Entity Framework not used)
|
repository issues inline SQL (a mix of Dapper and raw
|
||||||
- Database handles complex logic
|
`DbCommand`/`DbDataReader` for the few reads that need manual column mapping,
|
||||||
- Application focuses on orchestration
|
such as the `GEOGRAPHY` column on `BreweryPostLocation`)
|
||||||
|
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||||
|
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
||||||
|
repository method, run inside the same transaction as the write
|
||||||
|
- Optimistic concurrency uses each table's `Timer` (`ROWVERSION`) column,
|
||||||
|
checked in the `UPDATE ... WHERE ... AND Timer = @Timer` clause
|
||||||
|
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
||||||
|
`ConflictException`, ...) directly when a check fails, rather than relying on
|
||||||
|
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
|
||||||
|
exception types to HTTP status codes
|
||||||
|
- Application focuses on orchestration; the database enforces integrity via
|
||||||
|
keys, `CHECK` constraints, and cascades
|
||||||
|
|
||||||
**Stored Procedure Examples**:
|
See [Database](website/database.md) for the schema and the app's current SQL
|
||||||
|
error-handling approach in more detail.
|
||||||
- `USP_RegisterUser` - User registration
|
|
||||||
- `USP_GetUserAccountByUsername` - User lookup
|
|
||||||
- `USP_RotateUserCredential` - Password update
|
|
||||||
|
|
||||||
## Frontend architecture
|
## Frontend architecture
|
||||||
|
|
||||||
@@ -357,18 +375,29 @@ for reference only. Active product and engineering documentation should point to
|
|||||||
1. **Registration**:
|
1. **Registration**:
|
||||||
- User submits credentials
|
- User submits credentials
|
||||||
- Password hashed with Argon2id
|
- Password hashed with Argon2id
|
||||||
- User account created
|
- User account created (unverified) and a confirmation email is dispatched
|
||||||
- JWT token issued
|
via `Features.Emails`
|
||||||
|
|
||||||
2. **Login**:
|
2. **Email confirmation**:
|
||||||
|
- User follows the confirmation link/token from the email
|
||||||
|
- `Features.Auth`'s `ConfirmUser` command marks the account verified
|
||||||
|
|
||||||
|
3. **Login**:
|
||||||
- User submits credentials
|
- User submits credentials
|
||||||
- Password verified against hash
|
- Password verified against hash
|
||||||
- JWT token issued
|
- Access and refresh JWTs are issued; the refresh token is stored server-side
|
||||||
- Token stored client-side
|
against the user's credential row
|
||||||
|
|
||||||
3. **API Requests**:
|
4. **Token refresh**:
|
||||||
- Client sends JWT in Authorization header
|
- Client exchanges a valid, unexpired refresh token for a new access/refresh
|
||||||
- Middleware validates token
|
pair via `Features.Auth`'s `RefreshToken` command
|
||||||
|
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
|
||||||
|
refresh token is not invalidated and remains usable until its own
|
||||||
|
expiration
|
||||||
|
|
||||||
|
5. **API Requests**:
|
||||||
|
- Client sends the access JWT in the `Authorization` header
|
||||||
|
- `JwtAuthenticationHandler` validates the token
|
||||||
- Request proceeds if valid
|
- Request proceeds if valid
|
||||||
|
|
||||||
### Password security
|
### Password security
|
||||||
@@ -433,32 +462,40 @@ scheme, see [Database](website/database.md).
|
|||||||
|
|
||||||
**Process**:
|
**Process**:
|
||||||
|
|
||||||
1. Write SQL migration script
|
1. Write a SQL script under `Database.Migrations/scripts/`
|
||||||
2. Embed in `Database.Migrations` project
|
2. Embed it in the `Database.Migrations` project
|
||||||
3. Run migrations on startup
|
3. `Database.Migrations` runs each script against the target database on
|
||||||
4. Idempotent and versioned
|
startup, tracked so a script never re-runs once applied
|
||||||
|
|
||||||
**Migration Files**:
|
**Migration Files**:
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts/
|
scripts/
|
||||||
├── 001-CreateUserTables.sql
|
└── 01-schema/
|
||||||
├── 002-CreateLocationTables.sql
|
└── schema.sql # full table/index/constraint definitions
|
||||||
├── 003-CreateBreweryTables.sql
|
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Currently the whole schema is a single versioned script. As the schema evolves,
|
||||||
|
new numbered scripts get added alongside it rather than editing `schema.sql` in
|
||||||
|
place, so DbUp's already-applied tracking stays valid.
|
||||||
|
|
||||||
### Data seeding
|
### Data seeding
|
||||||
|
|
||||||
**Purpose**: Populate development/test databases
|
**Purpose**: Populate development/test databases with realistic data
|
||||||
|
|
||||||
**Implementation**: `Database.Seed` project
|
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||||
|
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
||||||
|
|
||||||
**Seed Data**:
|
**Seed Data**:
|
||||||
|
|
||||||
- Countries, states/provinces, cities
|
- Cities (via `Features.Locations`' `ILocationRepository`)
|
||||||
- Test user accounts
|
- User accounts (via `Features.Auth`'s repository)
|
||||||
- Sample breweries (future)
|
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||||
|
|
||||||
|
Tables that exist in the schema but have no corresponding `Domain.Entities` type
|
||||||
|
or feature slice yet (`Photo`, `UserAvatar`, `UserFollow`, `Country`,
|
||||||
|
`StateProvince`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
|
||||||
|
`BreweryPostPhoto`) are not seeded.
|
||||||
|
|
||||||
## Deployment architecture
|
## Deployment architecture
|
||||||
|
|
||||||
@@ -470,6 +507,7 @@ scripts/
|
|||||||
- `database.migrations` - Schema migration runner
|
- `database.migrations` - Schema migration runner
|
||||||
- `database.seed` - Data seeder
|
- `database.seed` - Data seeder
|
||||||
- `api.core` - ASP.NET Core Web API
|
- `api.core` - ASP.NET Core Web API
|
||||||
|
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
|
||||||
|
|
||||||
**Environments**:
|
**Environments**:
|
||||||
|
|
||||||
|
|||||||
78
docs/website/database.md
Normal file
78
docs/website/database.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Database
|
||||||
|
|
||||||
|
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
|
||||||
|
convention. For the vertical-slice code that talks to this schema, see
|
||||||
|
[Architecture](../architecture.md).
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
The full schema lives in one script:
|
||||||
|
`web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql`, run by
|
||||||
|
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
|
||||||
|
owning feature slice's repository (see
|
||||||
|
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
|
||||||
|
|
||||||
|
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
||||||
|
most carry a `Timer ROWVERSION` column used for optimistic concurrency on
|
||||||
|
updates (`UPDATE ... WHERE ... AND Timer = @Timer`).
|
||||||
|
|
||||||
|
### Tables backed by the API today
|
||||||
|
|
||||||
|
| Table | Backed by | Notes |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
|
||||||
|
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||||
|
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
|
||||||
|
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||||
|
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read manually via `DbDataReader.GetFieldValue<byte[]>` because Dapper can't deserialize SQL Server UDTs |
|
||||||
|
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; looked up via `ILocationRepository`, currently consumed only by `Database.Seed` |
|
||||||
|
|
||||||
|
### Tables defined in the schema but not yet wired to a feature slice
|
||||||
|
|
||||||
|
These exist so the schema can support planned functionality, but have no
|
||||||
|
`Domain.Entities` type, repository, or seed data yet:
|
||||||
|
|
||||||
|
| Table | Purpose (from schema) |
|
||||||
|
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `Photo` | A photo uploaded by a user account |
|
||||||
|
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
|
||||||
|
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
||||||
|
| `StateProvince` | Referenced by `City.StateProvinceID` |
|
||||||
|
| `Country` | Referenced by `StateProvince.CountryID` |
|
||||||
|
| `BeerStyle` | A beer style/category |
|
||||||
|
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`) |
|
||||||
|
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
||||||
|
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
||||||
|
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
**Tool**: DbUp, run by the `Database.Migrations` project/container.
|
||||||
|
|
||||||
|
Scripts live under `scripts/`, applied in order and tracked so a script never
|
||||||
|
re-runs once it has succeeded. Today that's a single `01-schema/schema.sql`.
|
||||||
|
When the schema needs to change, add a new numbered script rather than editing
|
||||||
|
`schema.sql` in place — DbUp's tracking is per-script, so editing an
|
||||||
|
already-applied script means it won't re-run against existing databases.
|
||||||
|
|
||||||
|
## Data seeding
|
||||||
|
|
||||||
|
`Database.Seed` populates a target database from data produced by the C++
|
||||||
|
pipeline (see [Pipeline README](../pipeline/README.md)): cities, user accounts,
|
||||||
|
and brewery posts with locations. It only seeds tables that have a feature slice
|
||||||
|
to write through (see the table above).
|
||||||
|
|
||||||
|
## Database error handling
|
||||||
|
|
||||||
|
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
||||||
|
rows against the `Timer` column.
|
||||||
|
- On failure, the repository throws a `Domain.Exceptions` type directly
|
||||||
|
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
||||||
|
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
||||||
|
409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
|
||||||
|
constraint-violation failure) maps to 503.
|
||||||
|
|
||||||
|
- One repository-level exception: `Features.Auth`'s `AuthRepository` does
|
||||||
|
catch `SqlException` directly, to detect a duplicate-key race (SQL Server
|
||||||
|
error 2601 or 2627) when two requests try to verify the same account
|
||||||
|
concurrently — see `Auth Repository.IsDuplicateVerificationViolation`.
|
||||||
@@ -14,6 +14,9 @@ namespace API.Core.Controllers;
|
|||||||
[Route("error")] // required
|
[Route("error")] // required
|
||||||
public class NotFoundController : ControllerBase
|
public class NotFoundController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a 404 response for any request that did not match a route.
|
||||||
|
/// </summary>
|
||||||
[HttpGet("404")] //required
|
[HttpGet("404")] //required
|
||||||
public IActionResult Handle404()
|
public IActionResult Handle404()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ using Shared.Application.Behaviors;
|
|||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Global Exception Filter
|
// Add current controllers to the exception filter
|
||||||
builder
|
builder
|
||||||
.Services.AddControllers(options =>
|
.Services.AddControllers(options =>
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@ builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
|
|||||||
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
builder.Services.AddFluentValidationAutoValidation();
|
||||||
|
|
||||||
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
|
// Add MediatR.
|
||||||
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
||||||
builder.Services.AddMediatR(cfg =>
|
builder.Services.AddMediatR(cfg =>
|
||||||
{
|
{
|
||||||
@@ -67,8 +67,7 @@ builder.Services.AddFeaturesUserManagement();
|
|||||||
builder.Services.AddFeaturesAuth();
|
builder.Services.AddFeaturesAuth();
|
||||||
builder.Services.AddFeaturesEmails();
|
builder.Services.AddFeaturesEmails();
|
||||||
|
|
||||||
// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because
|
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
||||||
// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly.
|
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||||
|
|
||||||
// Register the exception filter
|
// Register the exception filter
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
|
|
||||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ namespace Features.Auth.Commands.RefreshToken;
|
|||||||
public class RefreshTokenHandler(ITokenService tokenService)
|
public class RefreshTokenHandler(ITokenService tokenService)
|
||||||
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||||
{
|
{
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the refresh token is invalid or expired, or when the user account it refers to no
|
||||||
|
/// longer exists.
|
||||||
|
/// </exception>
|
||||||
public async Task<LoginPayload> Handle(
|
public async Task<LoginPayload> Handle(
|
||||||
RefreshTokenCommand request,
|
RefreshTokenCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.RefreshToken;
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
|
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
|
||||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||||
{
|
{
|
||||||
public RefreshTokenValidator()
|
public RefreshTokenValidator()
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.RegisterUser;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the field-level constraints on <see cref="RegisterUserCommand" />: username format and
|
||||||
|
/// length, name lengths, email format, minimum age of 19, and password complexity.
|
||||||
|
/// </summary>
|
||||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||||
{
|
{
|
||||||
public RegisterUserValidator()
|
public RegisterUserValidator()
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ namespace Features.Auth.Controllers;
|
|||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class AuthController(IMediator mediator) : ControllerBase
|
public class AuthController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>Registers a new user account.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
|
||||||
|
/// Returns 409 Conflict if the username or email is already taken.
|
||||||
|
/// </remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
||||||
@@ -40,6 +45,10 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Authenticates a user by username and password and issues a new access/refresh token pair.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Anonymous access. Returns 200 OK with the tokens, or 401 Unauthorized if the credentials are invalid.
|
||||||
|
/// </remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
||||||
@@ -54,6 +63,11 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Confirms a user account using a confirmation token.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Returns 200 OK on success,
|
||||||
|
/// or 401 Unauthorized if <paramref name="token" /> is invalid or expired.
|
||||||
|
/// </remarks>
|
||||||
[HttpPost("confirm")]
|
[HttpPost("confirm")]
|
||||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
||||||
[FromQuery] string token
|
[FromQuery] string token
|
||||||
@@ -69,6 +83,12 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Resends the account confirmation email for the given user, generating a fresh confirmation token.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Always returns 200 OK, even
|
||||||
|
/// if <paramref name="userId" /> does not exist or the account is already confirmed, to avoid user
|
||||||
|
/// enumeration.
|
||||||
|
/// </remarks>
|
||||||
[HttpPost("confirm/resend")]
|
[HttpPost("confirm/resend")]
|
||||||
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
||||||
{
|
{
|
||||||
@@ -76,6 +96,11 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is
|
||||||
|
/// invalid or expired.
|
||||||
|
/// </remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.Auth.DependencyInjection;
|
namespace Features.Auth.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>Registers the services required by Features.Auth with the dependency injection container.</summary>
|
||||||
public static class FeaturesAuthServiceCollectionExtensions
|
public static class FeaturesAuthServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, and the Argon2 password hashing
|
||||||
|
/// infrastructure as scoped services.
|
||||||
|
/// </summary>
|
||||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IAuthRepository, AuthRepository>();
|
services.AddScoped<IAuthRepository, AuthRepository>();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
namespace Features.Auth.Dtos;
|
namespace Features.Auth.Dtos;
|
||||||
|
|
||||||
|
/// <summary>Data required to persist a new user account and its initial credential.</summary>
|
||||||
|
/// <param name="PasswordHash">The already-hashed password; never a plaintext value.</param>
|
||||||
public record UserRegistrationDto(
|
public record UserRegistrationDto(
|
||||||
string Username,
|
string Username,
|
||||||
string FirstName,
|
string FirstName,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Queries.Login;
|
||||||
|
|
||||||
|
/// <summary>Validates that <see cref="LoginQuery.Username" /> and <see cref="LoginQuery.Password" /> are present.</summary>
|
||||||
public class LoginValidator : AbstractValidator<LoginQuery>
|
public class LoginValidator : AbstractValidator<LoginQuery>
|
||||||
{
|
{
|
||||||
public LoginValidator()
|
public LoginValidator()
|
||||||
|
|||||||
@@ -19,11 +19,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
private const string SelectColumns =
|
private const string SelectColumns =
|
||||||
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer";
|
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer";
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Registers a new user account and its initial credential in a single transaction, then fetches
|
/// <remarks>The account and credential are inserted within a single database transaction.</remarks>
|
||||||
/// and returns the newly created user.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
|
||||||
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
|
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -69,7 +66,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves a user account by email.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -79,7 +76,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves a user account by username.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -89,7 +86,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves the active (non-revoked) credential for a user account.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -103,10 +100,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// 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)
|
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -151,7 +145,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves a user account by ID.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -161,11 +155,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Marks a user account as confirmed by inserting a verification record. If the user is already
|
/// <remarks>
|
||||||
/// verified, this is a no-op and the existing user is returned (idempotent). If a concurrent request
|
/// Confirmation is recorded by inserting a row into <c>dbo.UserVerification</c>. If a concurrent
|
||||||
/// verifies the user first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
/// request verifies the same user first, the resulting duplicate-key SQL exception (error 2601/2627)
|
||||||
/// </summary>
|
/// is swallowed to keep the operation idempotent.
|
||||||
|
/// </remarks>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
/// <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>
|
/// </exception>
|
||||||
@@ -197,7 +192,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId);
|
return await GetUserByIdAsync(userAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
|
|||||||
@@ -8,21 +8,32 @@ namespace Features.Auth.Repository;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAuthRepository
|
public interface IAuthRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account and its initial credential as a single atomic operation, then
|
||||||
|
/// returns the created account.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||||
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
|
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
|
||||||
|
|
||||||
|
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||||
|
|
||||||
|
/// <summary>Retrieves a user account by username, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||||
|
|
||||||
|
/// <summary>Retrieves the active (non-revoked) credential for a user account, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
|
||||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||||
|
|
||||||
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>Retrieves a user account by ID, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>Determines whether a user account has been verified.</summary>
|
||||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TokenServiceExpirationHours
|
public static class TokenServiceExpirationHours
|
||||||
{
|
{
|
||||||
|
/// <summary>1 hour.</summary>
|
||||||
public const double AccessTokenHours = 1;
|
public const double AccessTokenHours = 1;
|
||||||
|
|
||||||
/// <summary>21 days.</summary>
|
/// <summary>21 days.</summary>
|
||||||
@@ -43,10 +44,22 @@ public static class TokenServiceExpirationHours
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed access token for the given user, expiring after
|
||||||
|
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||||
|
/// </summary>
|
||||||
string GenerateAccessToken(UserAccount user);
|
string GenerateAccessToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed refresh token for the given user, expiring after
|
||||||
|
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||||
|
/// </summary>
|
||||||
string GenerateRefreshToken(UserAccount user);
|
string GenerateRefreshToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed confirmation token for the given user, expiring after
|
||||||
|
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||||
|
/// </summary>
|
||||||
string GenerateConfirmationToken(UserAccount user);
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
|
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
|
||||||
@@ -54,12 +67,28 @@ public interface ITokenService
|
|||||||
string GenerateToken<T>(UserAccount user)
|
string GenerateToken<T>(UserAccount user)
|
||||||
where T : struct, Enum;
|
where T : struct, Enum;
|
||||||
|
|
||||||
|
/// <summary>Validates an access token's signature and expiration and extracts the caller's identity.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>Validates a refresh token's signature and expiration and extracts the caller's identity.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>Validates a confirmation token's signature and expiration and extracts the caller's identity.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
|
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the refresh token is invalid or expired, or when the user account it refers to no
|
||||||
|
/// longer exists.
|
||||||
|
/// </exception>
|
||||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string GenerateAccessToken(UserAccount user)
|
public string GenerateAccessToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
@@ -59,6 +60,7 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string GenerateRefreshToken(UserAccount user)
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
@@ -72,6 +74,7 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string GenerateConfirmationToken(UserAccount user)
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
@@ -109,33 +112,25 @@ public class TokenService : ITokenService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||||
{
|
{
|
||||||
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||||
{
|
{
|
||||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||||
{
|
{
|
||||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
{
|
{
|
||||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Location details for a brewery being created.</summary>
|
||||||
|
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
|
||||||
public record CreateBreweryLocation(
|
public record CreateBreweryLocation(
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
string AddressLine1,
|
string AddressLine1,
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post.</summary>
|
||||||
public class CreateBreweryHandler(IBreweryRepository repository)
|
public class CreateBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||||
{
|
{
|
||||||
/// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary>
|
/// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
|
/// Thrown when <paramref name="request" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
|
||||||
|
/// </exception>
|
||||||
public async Task<BreweryDto> Handle(
|
public async Task<BreweryDto> Handle(
|
||||||
CreateBreweryCommand request,
|
CreateBreweryCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Validates <see cref="CreateBreweryCommand" />.</summary>
|
||||||
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||||
{
|
{
|
||||||
public CreateBreweryValidator()
|
public CreateBreweryValidator()
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
|
/// <summary>Deletes a brewery post. Bound directly from the route of <c>DELETE /api/brewery/{id}</c>.</summary>
|
||||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="DeleteBreweryCommand" /> by deleting the corresponding brewery post.</summary>
|
||||||
public class DeleteBreweryHandler(IBreweryRepository repository)
|
public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<DeleteBreweryCommand>
|
: IRequestHandler<DeleteBreweryCommand>
|
||||||
{
|
{
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
|
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>.
|
||||||
|
/// </exception>
|
||||||
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
|
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return repository.DeleteAsync(request.BreweryPostId);
|
return repository.DeleteAsync(request.BreweryPostId);
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Location details for a brewery being updated.</summary>
|
||||||
|
/// <param name="BreweryPostLocationId">
|
||||||
|
/// Identifier of the existing location row, if any; a new row is created if none exists yet.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
|
||||||
public record UpdateBreweryLocation(
|
public record UpdateBreweryLocation(
|
||||||
Guid BreweryPostLocationId,
|
Guid BreweryPostLocationId,
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.</summary>
|
||||||
public class UpdateBreweryHandler(IBreweryRepository repository)
|
public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Validates <see cref="UpdateBreweryCommand" />.</summary>
|
||||||
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
|
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
|
||||||
{
|
{
|
||||||
public UpdateBreweryValidator()
|
public UpdateBreweryValidator()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Shared.Contracts;
|
|||||||
|
|
||||||
namespace Features.Breweries.Controllers;
|
namespace Features.Breweries.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Exposes CRUD operations for brewery posts.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
|
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
|
||||||
/// <c>[AllowAnonymous]</c>.
|
/// <c>[AllowAnonymous]</c>.
|
||||||
@@ -20,6 +21,7 @@ namespace Features.Breweries.Controllers;
|
|||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class BreweryController(IMediator mediator) : ControllerBase
|
public class BreweryController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>Retrieves a single brewery post by ID.</summary>
|
||||||
/// <remarks>Allows anonymous access.</remarks>
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
|
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -39,6 +41,9 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Retrieves brewery posts, newest first.</summary>
|
||||||
|
/// <param name="limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param>
|
||||||
|
/// <param name="offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param>
|
||||||
/// <remarks>Allows anonymous access.</remarks>
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -59,6 +64,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new brewery post.</summary>
|
||||||
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
||||||
@@ -76,6 +82,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates an existing brewery post.</summary>
|
||||||
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
|
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
|
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
|
||||||
@@ -101,7 +108,8 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <returns><c>200 OK</c> confirming the deletion.</returns>
|
/// <summary>Deletes a brewery post.</summary>
|
||||||
|
/// <returns><c>200 OK</c> confirming the deletion; <c>404 Not Found</c> if no brewery exists with <paramref name="id" />.</returns>
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.Breweries.DependencyInjection;
|
namespace Features.Breweries.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>Registers the services required by the Breweries feature.</summary>
|
||||||
public static class FeaturesBreweriesServiceCollectionExtensions
|
public static class FeaturesBreweriesServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>Registers <see cref="IBreweryRepository" /> and its implementation.</summary>
|
||||||
|
/// <returns><paramref name="services" />, for chaining.</returns>
|
||||||
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IBreweryRepository, BreweryRepository>();
|
services.AddScoped<IBreweryRepository, BreweryRepository>();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
namespace Features.Breweries.Dtos;
|
namespace Features.Breweries.Dtos;
|
||||||
|
|
||||||
|
/// <summary>Location details of a brewery, as returned by the API.</summary>
|
||||||
public class BreweryLocationDto
|
public class BreweryLocationDto
|
||||||
{
|
{
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
@@ -18,6 +19,7 @@ public class BreweryLocationDto
|
|||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A brewery post, as returned by the API.</summary>
|
||||||
public class BreweryDto
|
public class BreweryDto
|
||||||
{
|
{
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
@@ -30,10 +32,12 @@ public class BreweryDto
|
|||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the date and time of the last edit, or <see langword="null"/> if never edited.</summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
|
/// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the brewery's location, or <see langword="null"/> if none has been set.</summary>
|
||||||
public BreweryLocationDto? Location { get; set; }
|
public BreweryLocationDto? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Features.Breweries.Dtos;
|
namespace Features.Breweries.Dtos;
|
||||||
|
|
||||||
|
/// <summary>Maps <see cref="BreweryPost" /> domain entities to <see cref="BreweryDto" />.</summary>
|
||||||
public static class BreweryDtoMapper
|
public static class BreweryDtoMapper
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a <see cref="BreweryPost" /> to its DTO representation, including its
|
||||||
|
/// <see cref="BreweryDto.Location" /> if the entity has one.
|
||||||
|
/// </summary>
|
||||||
public static BreweryDto ToDto(this BreweryPost brewery)
|
public static BreweryDto ToDto(this BreweryPost brewery)
|
||||||
{
|
{
|
||||||
return new BreweryDto
|
return new BreweryDto
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Queries.GetAllBreweries;
|
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="GetAllBreweriesQuery" /> by retrieving brewery posts, newest first.</summary>
|
||||||
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
||||||
{
|
{
|
||||||
|
/// <summary>Retrieves brewery posts, newest first, applying <see cref="GetAllBreweriesQuery" />'s paging.</summary>
|
||||||
public async Task<IEnumerable<BreweryDto>> Handle(
|
public async Task<IEnumerable<BreweryDto>> Handle(
|
||||||
GetAllBreweriesQuery request,
|
GetAllBreweriesQuery request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Queries.GetAllBreweries;
|
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
|
||||||
|
/// <summary>Retrieves brewery posts, newest first. Bound directly from the query string of <c>GET /api/brewery</c>.</summary>
|
||||||
|
/// <param name="Limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param>
|
||||||
|
/// <param name="Offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param>
|
||||||
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;
|
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Queries.GetBreweryById;
|
namespace Features.Breweries.Queries.GetBreweryById;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="GetBreweryByIdQuery" /> by retrieving the matching brewery post.</summary>
|
||||||
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
||||||
{
|
{
|
||||||
|
/// <summary>Retrieves the brewery post, or <see langword="null"/> if none exists with the given ID.</summary>
|
||||||
public async Task<BreweryDto?> Handle(
|
public async Task<BreweryDto?> Handle(
|
||||||
GetBreweryByIdQuery request,
|
GetBreweryByIdQuery request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Queries.GetBreweryById;
|
namespace Features.Breweries.Queries.GetBreweryById;
|
||||||
|
|
||||||
|
/// <summary>Retrieves a brewery post by ID. Bound directly from the route of <c>GET /api/brewery/{id}</c>.</summary>
|
||||||
|
/// <remarks>Yields <see langword="null"/> if no brewery exists with the given <c>BreweryPostId</c>.</remarks>
|
||||||
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
"bp.BreweryPostID, bp.PostedByID, bp.BreweryName, bp.Description, bp.CreatedAt, bp.UpdatedAt, bp.Timer, "
|
"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";
|
+ "bpl.BreweryPostLocationID, bpl.CityID, bpl.AddressLine1, bpl.AddressLine2, bpl.PostalCode, bpl.Coordinates";
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc/>
|
||||||
/// Retrieves a brewery post by ID, joined to its location. A brewery with no location row
|
/// <remarks>
|
||||||
/// returns <c>null</c> (matches the previous stored procedure's <c>INNER JOIN</c> behavior).
|
/// Joins to the location table with an <c>INNER JOIN</c> (matching the previous stored procedure's
|
||||||
/// </summary>
|
/// behavior), which is why a brewery with no location row is treated as not found.
|
||||||
|
/// </remarks>
|
||||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -47,10 +48,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc/>
|
||||||
/// 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)
|
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -75,15 +73,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return breweries;
|
return breweries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc/>
|
||||||
/// 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>
|
|
||||||
/// <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)
|
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -221,10 +211,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc/>
|
||||||
/// 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)
|
public async Task DeleteAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -237,11 +224,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
throw new NotFoundException("Brewery not found.");
|
throw new NotFoundException("Brewery not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new brewery post, including its location details.</summary>
|
/// <inheritdoc/>
|
||||||
/// <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)
|
public async Task CreateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
if (brewery.Location is null)
|
if (brewery.Location is null)
|
||||||
|
|||||||
@@ -7,14 +7,25 @@ namespace Features.Breweries.Repository;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBreweryRepository
|
public interface IBreweryRepository
|
||||||
{
|
{
|
||||||
/// <summary>Returns <c>null</c> if no brewery post exists with the given ID.</summary>
|
/// <summary>
|
||||||
|
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> if no brewery post
|
||||||
|
/// exists with the given ID, or if it has no associated location.
|
||||||
|
/// </summary>
|
||||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
Task<BreweryPost?> GetByIdAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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>
|
||||||
|
/// <param name="limit">Maximum number of rows to return. Unbounded if <see langword="null" />.</param>
|
||||||
|
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param>
|
||||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates a brewery post, enforcing optimistic concurrency via <paramref name="brewery" />'s
|
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
|
||||||
/// <c>Timer</c>: the update is rejected if the row was modified since <c>Timer</c> was read.
|
/// 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. When <paramref name="brewery" />'s
|
||||||
|
/// <c>Location</c> is <c>null</c>, any existing location for the brewery is removed.
|
||||||
/// Returns the freshly persisted brewery, including its new <c>Timer</c>.
|
/// Returns the freshly persisted brewery, including its new <c>Timer</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="Domain.Exceptions.NotFoundException">
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
@@ -27,9 +38,14 @@ public interface IBreweryRepository
|
|||||||
/// </exception>
|
/// </exception>
|
||||||
Task<BreweryPost> UpdateAsync(BreweryPost brewery);
|
Task<BreweryPost> UpdateAsync(BreweryPost brewery);
|
||||||
|
|
||||||
|
/// <summary>Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception>
|
||||||
Task DeleteAsync(Guid id);
|
Task DeleteAsync(Guid id);
|
||||||
|
|
||||||
/// <summary>Creates a new brewery post, including its location details.</summary>
|
/// <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="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception>
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
|
/// Thrown when <paramref name="brewery" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
|
||||||
|
/// </exception>
|
||||||
Task CreateAsync(BreweryPost brewery);
|
Task CreateAsync(BreweryPost brewery);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.Emails.DependencyInjection;
|
namespace Features.Emails.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the services used by the Features.Emails slice.
|
||||||
|
/// </summary>
|
||||||
public static class FeaturesEmailsServiceCollectionExtensions
|
public static class FeaturesEmailsServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the SMTP email provider, template renderer, and <see cref="IEmailDispatcher" />.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
|
||||||
public static IServiceCollection AddFeaturesEmails(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesEmails(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class EmailDispatcher(
|
|||||||
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
||||||
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task SendRegistrationEmailAsync(
|
public async Task SendRegistrationEmailAsync(
|
||||||
string firstName,
|
string firstName,
|
||||||
string email,
|
string email,
|
||||||
@@ -38,6 +39,7 @@ public class EmailDispatcher(
|
|||||||
await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
|
await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task SendResendConfirmationEmailAsync(
|
public async Task SendResendConfirmationEmailAsync(
|
||||||
string firstName,
|
string firstName,
|
||||||
string email,
|
string email,
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ public interface IEmailDispatcher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a welcome email containing an account confirmation link to a newly registered user.
|
/// Sends a welcome email containing an account confirmation link to a newly registered user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>The email is sent synchronously; the returned task completes once delivery to the mail server succeeds or fails.</remarks>
|
||||||
|
/// <param name="confirmationToken">Embedded in the confirmation link sent to <paramref name="email" />.</param>
|
||||||
Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken);
|
Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends an email containing a fresh account confirmation link to a user who requested a resend.
|
/// Sends an email containing a fresh account confirmation link to a user who requested a resend.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>The email is sent synchronously; the returned task completes once delivery to the mail server succeeds or fails.</remarks>
|
||||||
|
/// <param name="confirmationToken">Embedded in the confirmation link sent to <paramref name="email" />.</param>
|
||||||
Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken);
|
Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.Locations.DependencyInjection;
|
namespace Features.Locations.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the services used by the Features.Locations slice.
|
||||||
|
/// </summary>
|
||||||
public static class FeaturesLocationsServiceCollectionExtensions
|
public static class FeaturesLocationsServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="ILocationRepository" /> and its Dapper-based implementation.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
|
||||||
public static IServiceCollection AddFeaturesLocations(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesLocations(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<ILocationRepository, LocationRepository>();
|
services.AddScoped<ILocationRepository, LocationRepository>();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Commands.UpdateUser;
|
namespace Features.UserManagement.Commands.UpdateUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="UpdateUserCommand" />.
|
||||||
|
/// </summary>
|
||||||
public class UpdateUserHandler(IUserAccountRepository repository)
|
public class UpdateUserHandler(IUserAccountRepository repository)
|
||||||
: IRequestHandler<UpdateUserCommand>
|
: IRequestHandler<UpdateUserCommand>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
using Features.UserManagement.Queries.GetAllUsers;
|
using Features.UserManagement.Queries.GetAllUsers;
|
||||||
using Features.UserManagement.Queries.GetUserById;
|
using Features.UserManagement.Queries.GetUserById;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -6,10 +7,14 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Controllers;
|
namespace Features.UserManagement.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes read endpoints for user accounts.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class UserController(IMediator mediator) : ControllerBase
|
public class UserController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets a page of user accounts, ordered by creation date descending.</summary>
|
||||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -22,6 +27,10 @@ public class UserController(IMediator mediator) : ControllerBase
|
|||||||
return Ok(users);
|
return Ok(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the user account with the given ID.</summary>
|
||||||
|
/// <exception cref="NotFoundException">
|
||||||
|
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
|
||||||
|
/// </exception>
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.UserManagement.DependencyInjection;
|
namespace Features.UserManagement.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the services used by the Features.UserManagement slice.
|
||||||
|
/// </summary>
|
||||||
public static class FeaturesUserManagementServiceCollectionExtensions
|
public static class FeaturesUserManagementServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="IUserAccountRepository" /> and its Dapper-based implementation.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
|
||||||
public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Queries.GetAllUsers;
|
namespace Features.UserManagement.Queries.GetAllUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetAllUsersQuery" />.
|
||||||
|
/// </summary>
|
||||||
public class GetAllUsersHandler(IUserAccountRepository repository)
|
public class GetAllUsersHandler(IUserAccountRepository repository)
|
||||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,4 +3,9 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Queries.GetAllUsers;
|
namespace Features.UserManagement.Queries.GetAllUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests a page of user accounts, ordered by creation date descending.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Limit"><c>null</c> for no limit.</param>
|
||||||
|
/// <param name="Offset"><c>null</c> for no offset.</param>
|
||||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
|
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Queries.GetUserById;
|
namespace Features.UserManagement.Queries.GetUserById;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetUserByIdQuery" />.
|
||||||
|
/// </summary>
|
||||||
public class GetUserByIdHandler(IUserAccountRepository repository)
|
public class GetUserByIdHandler(IUserAccountRepository repository)
|
||||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Queries.GetUserById;
|
namespace Features.UserManagement.Queries.GetUserById;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests the user account with the given ID.
|
||||||
|
/// </summary>
|
||||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
|
||||||
namespace Features.UserManagement.Repository;
|
namespace Features.UserManagement.Repository;
|
||||||
|
|
||||||
@@ -10,13 +11,18 @@ public interface IUserAccountRepository
|
|||||||
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
|
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
|
||||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>Returns user accounts ordered by creation date descending.</summary>
|
||||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||||
|
|
||||||
|
/// <summary>Updates the mutable fields of an existing user account.</summary>
|
||||||
/// <param name="userAccount">Must have a valid <c>UserAccountId</c>.</param>
|
/// <param name="userAccount">Must have a valid <c>UserAccountId</c>.</param>
|
||||||
|
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||||
Task UpdateAsync(UserAccount userAccount);
|
Task UpdateAsync(UserAccount userAccount);
|
||||||
|
|
||||||
|
/// <summary>Deletes the user account with the given ID.</summary>
|
||||||
|
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||||
Task DeleteAsync(Guid id);
|
Task DeleteAsync(Guid id);
|
||||||
|
|
||||||
/// <summary>Returns <c>null</c> if no user account exists with the given username.</summary>
|
/// <summary>Returns <c>null</c> if no user account exists with the given username.</summary>
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
|
||||||
public async Task UpdateAsync(UserAccount userAccount)
|
public async Task UpdateAsync(UserAccount userAccount)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -73,7 +72,6 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given <paramref name="id" />.</exception>
|
|
||||||
public async Task DeleteAsync(Guid id)
|
public async Task DeleteAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace Infrastructure.Email.Templates.Rendering;
|
|||||||
public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
|
public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
|
||||||
: IEmailTemplateProvider
|
: IEmailTemplateProvider
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
public async Task<string> RenderUserRegisteredEmailAsync(
|
public async Task<string> RenderUserRegisteredEmailAsync(
|
||||||
string username,
|
string username,
|
||||||
string confirmationLink
|
string confirmationLink
|
||||||
@@ -26,6 +27,7 @@ public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFact
|
|||||||
return await RenderComponentAsync<UserRegistration>(parameters);
|
return await RenderComponentAsync<UserRegistration>(parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public async Task<string> RenderResendConfirmationEmailAsync(
|
public async Task<string> RenderResendConfirmationEmailAsync(
|
||||||
string username,
|
string username,
|
||||||
string confirmationLink
|
string confirmationLink
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ namespace Infrastructure.Email.Templates.Rendering;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IEmailTemplateProvider
|
public interface IEmailTemplateProvider
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>Renders the registration confirmation email template.</summary>
|
||||||
/// Renders the UserRegisteredEmail template.
|
/// <param name="username">The recipient's username, shown in the greeting.</param>
|
||||||
/// </summary>
|
/// <param name="confirmationLink">The URL the recipient follows to confirm their account.</param>
|
||||||
|
/// <returns>A task whose result is the rendered HTML for the email body.</returns>
|
||||||
Task<string> RenderUserRegisteredEmailAsync(string username, string confirmationLink);
|
Task<string> RenderUserRegisteredEmailAsync(string username, string confirmationLink);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Renders the resend-confirmation email template.</summary>
|
||||||
/// Renders the ResendConfirmation template.
|
/// <param name="username">The recipient's username, shown in the greeting.</param>
|
||||||
/// </summary>
|
/// <param name="confirmationLink">The URL the recipient follows to confirm their account.</param>
|
||||||
|
/// <returns>A task whose result is the rendered HTML for the email body.</returns>
|
||||||
Task<string> RenderResendConfirmationEmailAsync(string username, string confirmationLink);
|
Task<string> RenderResendConfirmationEmailAsync(string username, string confirmationLink);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,17 @@ namespace Infrastructure.Email;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IEmailProvider
|
public interface IEmailProvider
|
||||||
{
|
{
|
||||||
|
/// <summary>Sends an email to a single recipient.</summary>
|
||||||
|
/// <param name="to">The recipient's email address.</param>
|
||||||
|
/// <param name="subject">The email subject line.</param>
|
||||||
|
/// <param name="body">The email body content.</param>
|
||||||
|
/// <param name="isHtml">Whether <paramref name="body"/> is HTML; when <see langword="false"/> it is sent as plain text.</param>
|
||||||
Task SendAsync(string to, string subject, string body, bool isHtml = true);
|
Task SendAsync(string to, string subject, string body, bool isHtml = true);
|
||||||
|
|
||||||
|
/// <summary>Sends an email to multiple recipients.</summary>
|
||||||
|
/// <param name="to">The recipients' email addresses.</param>
|
||||||
|
/// <param name="subject">The email subject line.</param>
|
||||||
|
/// <param name="body">The email body content.</param>
|
||||||
|
/// <param name="isHtml">Whether <paramref name="body"/> is HTML; when <see langword="false"/> it is sent as plain text.</param>
|
||||||
Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true);
|
Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,15 +51,17 @@ public class SmtpEmailProvider : IEmailProvider
|
|||||||
_fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
|
_fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public async Task SendAsync(string to, string subject, string body, bool isHtml = true)
|
public async Task SendAsync(string to, string subject, string body, bool isHtml = true)
|
||||||
{
|
{
|
||||||
await SendAsync([to], subject, body, isHtml);
|
await SendAsync([to], subject, body, isHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
/// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates
|
/// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates
|
||||||
/// if credentials were configured, sends the message, and disconnects.
|
/// if credentials were configured, sends the message, and disconnects.
|
||||||
/// </summary>
|
/// </remarks>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when connecting, authenticating, or sending via SMTP fails, wrapping the original exception.
|
/// Thrown when connecting, authenticating, or sending via SMTP fails, wrapping the original exception.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Infrastructure.Sql.DependencyInjection;
|
namespace Infrastructure.Sql.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>Extension methods for registering SQL infrastructure services with dependency injection.</summary>
|
||||||
public static class InfrastructureSqlServiceCollectionExtensions
|
public static class InfrastructureSqlServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>Registers <see cref="DefaultSqlConnectionFactory" /> as a singleton <see cref="ISqlConnectionFactory" />.</summary>
|
||||||
|
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
|
||||||
public static IServiceCollection AddInfrastructureSql(this IServiceCollection services)
|
public static IServiceCollection AddInfrastructureSql(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
namespace Infrastructure.Sql;
|
namespace Infrastructure.Sql;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Custom SQL Server error numbers (50000+) that this application's stored procedures use to
|
/// Custom SQL Server error numbers (50000+) that a stored procedure could use to signal
|
||||||
/// signal domain-level failures via <c>THROW</c>. Repositories catch
|
/// domain-level failures via <c>THROW</c>, for a repository to catch as
|
||||||
/// <see cref="Microsoft.Data.SqlClient.SqlException" /> and switch on <c>ex.Number</c> against
|
/// <see cref="Microsoft.Data.SqlClient.SqlException" /> and translate via <c>ex.Number</c> into
|
||||||
/// these constants to translate the failure into the corresponding <c>Domain.Exceptions</c> type.
|
/// the corresponding <c>Domain.Exceptions</c> type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Each number is shared by every procedure that raises that category of error — it identifies
|
/// Currently unused: the schema has no stored procedures, and no repository throws or catches on
|
||||||
/// the category, not a single call site. The procedure's own error message (<c>ex.Message</c>)
|
/// these numbers. Repositories instead run their own existence/concurrency checks and throw
|
||||||
/// already says which entity or rule was involved, so it is safe to surface directly to callers.
|
/// <c>Domain.Exceptions</c> directly (see <c>docs/website/database.md</c> for the current
|
||||||
|
/// approach). This type is kept in case a future stored procedure reintroduces the pattern; each
|
||||||
|
/// number would
|
||||||
|
/// identify a category of error shared by every procedure that raises it, not a single call site.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class SqlErrorCodes
|
public static class SqlErrorCodes
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TReq
|
|||||||
: IPipelineBehavior<TRequest, TResponse>
|
: IPipelineBehavior<TRequest, TResponse>
|
||||||
where TRequest : IRequest<TResponse>
|
where TRequest : IRequest<TResponse>
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
public async Task<TResponse> Handle(
|
public async Task<TResponse> Handle(
|
||||||
TRequest request,
|
TRequest request,
|
||||||
RequestHandlerDelegate<TResponse> next,
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ namespace Shared.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ResponseBody<T>
|
public record ResponseBody<T>
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the human-readable message describing the response.</summary>
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the response's data payload.</summary>
|
||||||
public required T Payload { get; init; }
|
public required T Payload { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,5 +17,6 @@ public record ResponseBody<T>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ResponseBody
|
public record ResponseBody
|
||||||
{
|
{
|
||||||
|
/// <summary>Gets the human-readable message describing the response.</summary>
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user