Update XML doc comment in backend projects

This commit is contained in:
Aaron Po
2026-08-18 16:28:25 -04:00
parent 6a53167cb8
commit 693f74bb5b
53 changed files with 287 additions and 83 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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