mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +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:
|
||||
|
||||
- JWT-based user authentication, with email confirmation and refresh-token
|
||||
rotation
|
||||
reissue
|
||||
- A searchable database of brewery locations
|
||||
- An offline data-generation pipeline that uses large language models (LLMs)
|
||||
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
||||
|
||||
@@ -35,9 +35,11 @@ For visual representations, see:
|
||||
|
||||
The backend organizes business capabilities as feature slices instead of
|
||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns its
|
||||
own controller, MediatR commands/queries/handlers, validators, and repository,
|
||||
end to end:
|
||||
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
|
||||
project that owns its own MediatR commands/queries/handlers, validators, and
|
||||
repository end to end. `Features.Emails` and `Features.Locations` have no
|
||||
`Controllers/` folder — both are invoked internally by other slices via
|
||||
MediatR/DI, never over HTTP:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
@@ -46,15 +48,15 @@ end to end:
|
||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
↓ discovers controllers via AddApplicationPart
|
||||
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
|
||||
│ Features.Auth │Features. │ Features. │ Features.Emails │
|
||||
│ │Breweries │ UserManagement │ (no controller, │
|
||||
│ Controller │ Controller │ Controller │ internal only) │
|
||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
||||
│ Queries + │ Queries + │ Queries + │ Handlers │
|
||||
│ Handlers │ Handlers │ Handlers │ │
|
||||
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
||||
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
||||
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
|
||||
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │
|
||||
│ │Breweries │ UserManagement │ (no controller, │ Locations │
|
||||
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │
|
||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
|
||||
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
|
||||
│ Handlers │ Handlers │ Handlers │ │ │
|
||||
│ Repository │ Repository │ Repository │ EmailDispatcher │ │
|
||||
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
|
||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||
@@ -108,7 +110,7 @@ own
|
||||
- No controllers, no business logic, no feature-specific contracts
|
||||
- Exists purely to host and wire up the feature slices
|
||||
|
||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
|
||||
|
||||
**Purpose**: Each slice is the complete vertical for one business capability
|
||||
|
||||
@@ -126,7 +128,12 @@ own
|
||||
slice's repository/services
|
||||
|
||||
`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**:
|
||||
|
||||
@@ -198,6 +205,9 @@ whichever slices need them
|
||||
- `UserAccount` - User profile data
|
||||
- `UserCredential` - Authentication credentials
|
||||
- `UserVerification` - Account verification state
|
||||
- `BreweryPost` - A user-submitted brewery listing
|
||||
- `BreweryPostLocation` - A brewery's address and coordinates
|
||||
- `City` - A city referenced by a brewery location
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
@@ -258,7 +268,6 @@ only:
|
||||
**Benefits**:
|
||||
|
||||
- Testable (easy to mock)
|
||||
- SQL-first approach (stored procedures)
|
||||
- Each slice's data access logic is self-contained
|
||||
|
||||
**Example**:
|
||||
@@ -285,22 +294,31 @@ method that registers its repository and slice-internal services
|
||||
- Singleton: `ISqlConnectionFactory`
|
||||
- Transient: Utilities, helpers
|
||||
|
||||
#### SQL-first approach
|
||||
#### Direct SQL via Dapper/ADO.NET
|
||||
|
||||
**Purpose**: Push complex logic into the database
|
||||
**Purpose**: Keep data access explicit and colocated with the slice that owns
|
||||
it, without an ORM's change-tracking or mapping magic
|
||||
|
||||
**Strategy**:
|
||||
|
||||
- All queries via stored procedures
|
||||
- No ORM (Entity Framework not used)
|
||||
- Database handles complex logic
|
||||
- Application focuses on orchestration
|
||||
- No ORM (Entity Framework not used); each
|
||||
repository issues inline SQL (a mix of Dapper and raw
|
||||
`DbCommand`/`DbDataReader` for the few reads that need manual column mapping,
|
||||
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**:
|
||||
|
||||
- `USP_RegisterUser` - User registration
|
||||
- `USP_GetUserAccountByUsername` - User lookup
|
||||
- `USP_RotateUserCredential` - Password update
|
||||
See [Database](website/database.md) for the schema and the app's current SQL
|
||||
error-handling approach in more detail.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
@@ -357,18 +375,29 @@ for reference only. Active product and engineering documentation should point to
|
||||
1. **Registration**:
|
||||
- User submits credentials
|
||||
- Password hashed with Argon2id
|
||||
- User account created
|
||||
- JWT token issued
|
||||
- User account created (unverified) and a confirmation email is dispatched
|
||||
via `Features.Emails`
|
||||
|
||||
2. **Login**:
|
||||
2. **Email confirmation**:
|
||||
- User follows the confirmation link/token from the email
|
||||
- `Features.Auth`'s `ConfirmUser` command marks the account verified
|
||||
|
||||
3. **Login**:
|
||||
- User submits credentials
|
||||
- Password verified against hash
|
||||
- JWT token issued
|
||||
- Token stored client-side
|
||||
- Access and refresh JWTs are issued; the refresh token is stored server-side
|
||||
against the user's credential row
|
||||
|
||||
3. **API Requests**:
|
||||
- Client sends JWT in Authorization header
|
||||
- Middleware validates token
|
||||
4. **Token refresh**:
|
||||
- Client exchanges a valid, unexpired refresh token for a new access/refresh
|
||||
pair via `Features.Auth`'s `RefreshToken` command
|
||||
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
|
||||
refresh token is not invalidated and remains usable until its own
|
||||
expiration
|
||||
|
||||
5. **API Requests**:
|
||||
- Client sends the access JWT in the `Authorization` header
|
||||
- `JwtAuthenticationHandler` validates the token
|
||||
- Request proceeds if valid
|
||||
|
||||
### Password security
|
||||
@@ -433,32 +462,40 @@ scheme, see [Database](website/database.md).
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Write SQL migration script
|
||||
2. Embed in `Database.Migrations` project
|
||||
3. Run migrations on startup
|
||||
4. Idempotent and versioned
|
||||
1. Write a SQL script under `Database.Migrations/scripts/`
|
||||
2. Embed it in the `Database.Migrations` project
|
||||
3. `Database.Migrations` runs each script against the target database on
|
||||
startup, tracked so a script never re-runs once applied
|
||||
|
||||
**Migration Files**:
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── 001-CreateUserTables.sql
|
||||
├── 002-CreateLocationTables.sql
|
||||
├── 003-CreateBreweryTables.sql
|
||||
└── ...
|
||||
└── 01-schema/
|
||||
└── schema.sql # full table/index/constraint definitions
|
||||
```
|
||||
|
||||
Currently the whole schema is a single versioned script. As the schema evolves,
|
||||
new numbered scripts get added alongside it rather than editing `schema.sql` in
|
||||
place, so DbUp's already-applied tracking stays valid.
|
||||
|
||||
### Data seeding
|
||||
|
||||
**Purpose**: Populate development/test databases
|
||||
**Purpose**: Populate development/test databases with realistic data
|
||||
|
||||
**Implementation**: `Database.Seed` project
|
||||
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
||||
|
||||
**Seed Data**:
|
||||
|
||||
- Countries, states/provinces, cities
|
||||
- Test user accounts
|
||||
- Sample breweries (future)
|
||||
- Cities (via `Features.Locations`' `ILocationRepository`)
|
||||
- User accounts (via `Features.Auth`'s repository)
|
||||
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||
|
||||
Tables that exist in the schema but have no corresponding `Domain.Entities` type
|
||||
or feature slice yet (`Photo`, `UserAvatar`, `UserFollow`, `Country`,
|
||||
`StateProvince`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
|
||||
`BreweryPostPhoto`) are not seeded.
|
||||
|
||||
## Deployment architecture
|
||||
|
||||
@@ -470,6 +507,7 @@ scripts/
|
||||
- `database.migrations` - Schema migration runner
|
||||
- `database.seed` - Data seeder
|
||||
- `api.core` - ASP.NET Core Web API
|
||||
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
|
||||
|
||||
**Environments**:
|
||||
|
||||
|
||||
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
|
||||
public class NotFoundController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a 404 response for any request that did not match a route.
|
||||
/// </summary>
|
||||
[HttpGet("404")] //required
|
||||
public IActionResult Handle404()
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ using Shared.Application.Behaviors;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Global Exception Filter
|
||||
// Add current controllers to the exception filter
|
||||
builder
|
||||
.Services.AddControllers(options =>
|
||||
{
|
||||
@@ -37,7 +37,7 @@ builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
|
||||
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
|
||||
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
|
||||
// Add MediatR.
|
||||
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
@@ -67,8 +67,7 @@ builder.Services.AddFeaturesUserManagement();
|
||||
builder.Services.AddFeaturesAuth();
|
||||
builder.Services.AddFeaturesEmails();
|
||||
|
||||
// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because
|
||||
// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly.
|
||||
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||
|
||||
// Register the exception filter
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.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" />
|
||||
|
||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace Features.Auth.Commands.RefreshToken;
|
||||
public class RefreshTokenHandler(ITokenService tokenService)
|
||||
: 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(
|
||||
RefreshTokenCommand request,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
|
||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
{
|
||||
public RefreshTokenValidator()
|
||||
|
||||
@@ -2,6 +2,10 @@ using FluentValidation;
|
||||
|
||||
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 RegisterUserValidator()
|
||||
|
||||
@@ -23,6 +23,11 @@ namespace Features.Auth.Controllers;
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
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]
|
||||
[HttpPost("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]
|
||||
[HttpPost("login")]
|
||||
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")]
|
||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
||||
[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")]
|
||||
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" });
|
||||
}
|
||||
|
||||
/// <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]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||
|
||||
@@ -5,8 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Auth.DependencyInjection;
|
||||
|
||||
/// <summary>Registers the services required by Features.Auth with the dependency injection container.</summary>
|
||||
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)
|
||||
{
|
||||
services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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(
|
||||
string Username,
|
||||
string FirstName,
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
|
||||
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 LoginValidator()
|
||||
|
||||
@@ -19,11 +19,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
private const string SelectColumns =
|
||||
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer";
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account and its initial credential in a single transaction, then fetches
|
||||
/// and returns the newly created user.
|
||||
/// </summary>
|
||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The account and credential are inserted within a single database transaction.</remarks>
|
||||
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -69,7 +66,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
?? 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -103,10 +100,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <inheritdoc />
|
||||
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -161,11 +155,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a user account as confirmed by inserting a verification record. If the user is already
|
||||
/// verified, this is a no-op and the existing user is returned (idempotent). If a concurrent request
|
||||
/// verifies the user first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Confirmation is recorded by inserting a row into <c>dbo.UserVerification</c>. If a concurrent
|
||||
/// request verifies the same user first, the resulting duplicate-key SQL exception (error 2601/2627)
|
||||
/// is swallowed to keep the operation idempotent.
|
||||
/// </remarks>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||
/// Thrown when the database command fails for a reason other than a duplicate verification record.
|
||||
/// </exception>
|
||||
@@ -197,7 +192,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
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)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
@@ -8,21 +8,32 @@ namespace Features.Auth.Repository;
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Retrieves the active (non-revoked) credential for a user account, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Determines whether a user account has been verified.</summary>
|
||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
|
||||
/// </summary>
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
/// <summary>1 hour.</summary>
|
||||
public const double AccessTokenHours = 1;
|
||||
|
||||
/// <summary>21 days.</summary>
|
||||
@@ -43,10 +44,22 @@ public static class TokenServiceExpirationHours
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed refresh token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <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)
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public class TokenService : ITokenService
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
@@ -59,6 +60,7 @@ public class TokenService : ITokenService
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
@@ -72,6 +74,7 @@ public class TokenService : ITokenService
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
@@ -109,33 +112,25 @@ public class TokenService : ITokenService
|
||||
};
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ using MediatR;
|
||||
|
||||
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(
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
|
||||
@@ -5,10 +5,14 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.CreateBrewery;
|
||||
|
||||
/// <summary>Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post.</summary>
|
||||
public class CreateBreweryHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||
{
|
||||
/// <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(
|
||||
CreateBreweryCommand request,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Breweries.Commands.CreateBrewery;
|
||||
|
||||
/// <summary>Validates <see cref="CreateBreweryCommand" />.</summary>
|
||||
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||
{
|
||||
public CreateBreweryValidator()
|
||||
|
||||
@@ -2,4 +2,5 @@ using MediatR;
|
||||
|
||||
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;
|
||||
|
||||
@@ -3,9 +3,13 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||
|
||||
/// <summary>Handles <see cref="DeleteBreweryCommand" /> by deleting the corresponding brewery post.</summary>
|
||||
public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||
: 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)
|
||||
{
|
||||
return repository.DeleteAsync(request.BreweryPostId);
|
||||
|
||||
@@ -3,6 +3,11 @@ using MediatR;
|
||||
|
||||
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(
|
||||
Guid BreweryPostLocationId,
|
||||
Guid CityId,
|
||||
|
||||
@@ -5,6 +5,7 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||
|
||||
/// <summary>Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.</summary>
|
||||
public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateBreweryCommand" />.</summary>
|
||||
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
|
||||
{
|
||||
public UpdateBreweryValidator()
|
||||
|
||||
@@ -11,6 +11,7 @@ using Shared.Contracts;
|
||||
|
||||
namespace Features.Breweries.Controllers;
|
||||
|
||||
/// <summary>Exposes CRUD operations for brewery posts.</summary>
|
||||
/// <remarks>
|
||||
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
|
||||
/// <c>[AllowAnonymous]</c>.
|
||||
@@ -20,6 +21,7 @@ namespace Features.Breweries.Controllers;
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class BreweryController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>Retrieves a single brewery post by ID.</summary>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
|
||||
[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>
|
||||
[AllowAnonymous]
|
||||
[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>
|
||||
[HttpPost]
|
||||
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>
|
||||
/// <returns>
|
||||
/// <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}")]
|
||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||
{
|
||||
|
||||
@@ -3,8 +3,11 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Breweries.DependencyInjection;
|
||||
|
||||
/// <summary>Registers the services required by the Breweries feature.</summary>
|
||||
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)
|
||||
{
|
||||
services.AddScoped<IBreweryRepository, BreweryRepository>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace Features.Breweries.Dtos;
|
||||
|
||||
/// <summary>Location details of a brewery, as returned by the API.</summary>
|
||||
public class BreweryLocationDto
|
||||
{
|
||||
public Guid BreweryPostLocationId { get; set; }
|
||||
@@ -18,6 +19,7 @@ public class BreweryLocationDto
|
||||
public byte[]? Coordinates { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A brewery post, as returned by the API.</summary>
|
||||
public class BreweryDto
|
||||
{
|
||||
public Guid BreweryPostId { get; set; }
|
||||
@@ -30,10 +32,12 @@ public class BreweryDto
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ using Domain.Entities;
|
||||
|
||||
namespace Features.Breweries.Dtos;
|
||||
|
||||
/// <summary>Maps <see cref="BreweryPost" /> domain entities to <see cref="BreweryDto" />.</summary>
|
||||
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)
|
||||
{
|
||||
return new BreweryDto
|
||||
|
||||
@@ -5,9 +5,11 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||
|
||||
/// <summary>Handles <see cref="GetAllBreweriesQuery" /> by retrieving brewery posts, newest first.</summary>
|
||||
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
||||
{
|
||||
/// <summary>Retrieves brewery posts, newest first, applying <see cref="GetAllBreweriesQuery" />'s paging.</summary>
|
||||
public async Task<IEnumerable<BreweryDto>> Handle(
|
||||
GetAllBreweriesQuery request,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
@@ -3,4 +3,7 @@ using MediatR;
|
||||
|
||||
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>>;
|
||||
|
||||
@@ -5,9 +5,11 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetBreweryById;
|
||||
|
||||
/// <summary>Handles <see cref="GetBreweryByIdQuery" /> by retrieving the matching brewery post.</summary>
|
||||
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
||||
: 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(
|
||||
GetBreweryByIdQuery request,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
@@ -3,4 +3,6 @@ using MediatR;
|
||||
|
||||
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?>;
|
||||
|
||||
@@ -26,10 +26,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
"bp.BreweryPostID, bp.PostedByID, bp.BreweryName, bp.Description, bp.CreatedAt, bp.UpdatedAt, bp.Timer, "
|
||||
+ "bpl.BreweryPostLocationID, bpl.CityID, bpl.AddressLine1, bpl.AddressLine2, bpl.PostalCode, bpl.Coordinates";
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a brewery post by ID, joined to its location. A brewery with no location row
|
||||
/// returns <c>null</c> (matches the previous stored procedure's <c>INNER JOIN</c> behavior).
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Joins to the location table with an <c>INNER JOIN</c> (matching the previous stored procedure's
|
||||
/// behavior), which is why a brewery with no location row is treated as not found.
|
||||
/// </remarks>
|
||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -47,10 +48,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -75,15 +73,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
return breweries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <inheritdoc/>
|
||||
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -221,10 +211,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <inheritdoc/>
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -237,11 +224,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
throw new NotFoundException("Brewery not found.");
|
||||
}
|
||||
|
||||
/// <summary>Creates a new brewery post, including its location details.</summary>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception>
|
||||
/// <exception cref="NotFoundException">
|
||||
/// Thrown when <paramref name="brewery" />.<c>PostedById</c> or <c>Location.CityId</c> does not exist.
|
||||
/// </exception>
|
||||
/// <inheritdoc/>
|
||||
public async Task CreateAsync(BreweryPost brewery)
|
||||
{
|
||||
if (brewery.Location is null)
|
||||
|
||||
@@ -7,14 +7,25 @@ namespace Features.Breweries.Repository;
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a brewery post, enforcing optimistic concurrency via <paramref name="brewery" />'s
|
||||
/// <c>Timer</c>: the update is rejected if the row was modified since <c>Timer</c> was read.
|
||||
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
|
||||
/// optimistic concurrency via <paramref name="brewery" />'s <c>Timer</c>: the update is rejected
|
||||
/// if the row was modified since <c>Timer</c> was read. 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>.
|
||||
/// </summary>
|
||||
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||
@@ -27,9 +38,14 @@ public interface IBreweryRepository
|
||||
/// </exception>
|
||||
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);
|
||||
|
||||
/// <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="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);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,8 +5,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Emails.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services used by the Features.Emails slice.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||
|
||||
@@ -22,6 +22,7 @@ public class EmailDispatcher(
|
||||
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
||||
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendRegistrationEmailAsync(
|
||||
string firstName,
|
||||
string email,
|
||||
@@ -38,6 +39,7 @@ public class EmailDispatcher(
|
||||
await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendResendConfirmationEmailAsync(
|
||||
string firstName,
|
||||
string email,
|
||||
|
||||
@@ -8,10 +8,14 @@ public interface IEmailDispatcher
|
||||
/// <summary>
|
||||
/// Sends a welcome email containing an account confirmation link to a newly registered user.
|
||||
/// </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);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an email containing a fresh account confirmation link to a user who requested a resend.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,8 +3,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Locations.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services used by the Features.Locations slice.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
services.AddScoped<ILocationRepository, LocationRepository>();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Commands.UpdateUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateUserCommand" />.
|
||||
/// </summary>
|
||||
public class UpdateUserHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<UpdateUserCommand>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.UserManagement.Queries.GetAllUsers;
|
||||
using Features.UserManagement.Queries.GetUserById;
|
||||
using MediatR;
|
||||
@@ -6,10 +7,14 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Features.UserManagement.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes read endpoints for user accounts.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
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="offset"><c>null</c> for no offset.</param>
|
||||
[HttpGet]
|
||||
@@ -22,6 +27,10 @@ public class UserController(IMediator mediator) : ControllerBase
|
||||
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}")]
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
{
|
||||
|
||||
@@ -3,8 +3,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.UserManagement.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services used by the Features.UserManagement slice.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||
|
||||
@@ -4,6 +4,9 @@ using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Queries.GetAllUsers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetAllUsersQuery" />.
|
||||
/// </summary>
|
||||
public class GetAllUsersHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||
{
|
||||
|
||||
@@ -3,4 +3,9 @@ using MediatR;
|
||||
|
||||
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>>;
|
||||
|
||||
@@ -5,6 +5,9 @@ using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Queries.GetUserById;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetUserByIdQuery" />.
|
||||
/// </summary>
|
||||
public class GetUserByIdHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
||||
{
|
||||
|
||||
@@ -3,4 +3,7 @@ using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Queries.GetUserById;
|
||||
|
||||
/// <summary>
|
||||
/// Requests the user account with the given ID.
|
||||
/// </summary>
|
||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
|
||||
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>
|
||||
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="offset"><c>null</c> for no offset.</param>
|
||||
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>
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
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);
|
||||
|
||||
/// <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 />
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task UpdateAsync(UserAccount userAccount)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -73,7 +72,6 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given <paramref name="id" />.</exception>
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace Infrastructure.Email.Templates.Rendering;
|
||||
public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
|
||||
: IEmailTemplateProvider
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> RenderUserRegisteredEmailAsync(
|
||||
string username,
|
||||
string confirmationLink
|
||||
@@ -26,6 +27,7 @@ public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFact
|
||||
return await RenderComponentAsync<UserRegistration>(parameters);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> RenderResendConfirmationEmailAsync(
|
||||
string username,
|
||||
string confirmationLink
|
||||
|
||||
@@ -5,13 +5,15 @@ namespace Infrastructure.Email.Templates.Rendering;
|
||||
/// </summary>
|
||||
public interface IEmailTemplateProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders the UserRegisteredEmail template.
|
||||
/// </summary>
|
||||
/// <summary>Renders the registration confirmation email template.</summary>
|
||||
/// <param name="username">The recipient's username, shown in the greeting.</param>
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// Renders the ResendConfirmation template.
|
||||
/// </summary>
|
||||
/// <summary>Renders the resend-confirmation email template.</summary>
|
||||
/// <param name="username">The recipient's username, shown in the greeting.</param>
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,17 @@ namespace Infrastructure.Email;
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -51,15 +51,17 @@ public class SmtpEmailProvider : IEmailProvider
|
||||
_fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SendAsync(string to, string subject, string body, bool isHtml = true)
|
||||
{
|
||||
await SendAsync([to], subject, body, isHtml);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates
|
||||
/// if credentials were configured, sends the message, and disconnects.
|
||||
/// </summary>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when connecting, authenticating, or sending via SMTP fails, wrapping the original exception.
|
||||
/// </exception>
|
||||
|
||||
@@ -2,8 +2,11 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Infrastructure.Sql.DependencyInjection;
|
||||
|
||||
/// <summary>Extension methods for registering SQL infrastructure services with dependency injection.</summary>
|
||||
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)
|
||||
{
|
||||
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
namespace Infrastructure.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Custom SQL Server error numbers (50000+) that this application's stored procedures use to
|
||||
/// signal domain-level failures via <c>THROW</c>. Repositories catch
|
||||
/// <see cref="Microsoft.Data.SqlClient.SqlException" /> and switch on <c>ex.Number</c> against
|
||||
/// these constants to translate the failure into the corresponding <c>Domain.Exceptions</c> type.
|
||||
/// Custom SQL Server error numbers (50000+) that a stored procedure could use to signal
|
||||
/// domain-level failures via <c>THROW</c>, for a repository to catch as
|
||||
/// <see cref="Microsoft.Data.SqlClient.SqlException" /> and translate via <c>ex.Number</c> into
|
||||
/// the corresponding <c>Domain.Exceptions</c> type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each number is shared by every procedure that raises that category of error — it identifies
|
||||
/// the category, not a single call site. The procedure's own error message (<c>ex.Message</c>)
|
||||
/// already says which entity or rule was involved, so it is safe to surface directly to callers.
|
||||
/// Currently unused: the schema has no stored procedures, and no repository throws or catches on
|
||||
/// these numbers. Repositories instead run their own existence/concurrency checks and throw
|
||||
/// <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>
|
||||
public static class SqlErrorCodes
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TReq
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace Shared.Contracts;
|
||||
/// </summary>
|
||||
public record ResponseBody<T>
|
||||
{
|
||||
/// <summary>Gets the human-readable message describing the response.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Gets the response's data payload.</summary>
|
||||
public required T Payload { get; init; }
|
||||
}
|
||||
|
||||
@@ -15,5 +17,6 @@ public record ResponseBody<T>
|
||||
/// </summary>
|
||||
public record ResponseBody
|
||||
{
|
||||
/// <summary>Gets the human-readable message describing the response.</summary>
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user