update features docs

This commit is contained in:
Aaron Po
2026-08-04 06:18:30 -04:00
parent 633550f808
commit 19dfad5ab5
46 changed files with 65 additions and 671 deletions

View File

@@ -3,8 +3,5 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.ConfirmUser;
/// <summary> /// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
/// Validates a confirmation token and confirms the corresponding user account.
/// </summary>
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>; public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;

View File

@@ -11,8 +11,6 @@ namespace Features.Auth.Commands.ConfirmUser;
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the /// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed. /// corresponding user account as confirmed.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService) public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload> : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{ {

View File

@@ -8,7 +8,6 @@ namespace Features.Auth.Commands.RefreshToken;
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new /// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair. /// access/refresh token pair.
/// </summary> /// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService) public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload> : IRequestHandler<RefreshTokenCommand, LoginPayload>
{ {

View File

@@ -2,14 +2,8 @@ using FluentValidation;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand> public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{ {
/// <summary>
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
/// </summary>
public RefreshTokenValidator() public RefreshTokenValidator()
{ {
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required"); RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");

View File

@@ -14,10 +14,6 @@ namespace Features.Auth.Commands.RegisterUser;
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration /// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails. /// confirmation email via Features.Emails.
/// </summary> /// </summary>
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
public class RegisterUserHandler( public class RegisterUserHandler(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure, IPasswordInfrastructure passwordInfrastructure,

View File

@@ -2,15 +2,8 @@ using FluentValidation;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand> public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{ {
/// <summary>
/// Configures validation rules for username format and length, first/last name length, email format and
/// length, minimum age based on date of birth, and password strength requirements.
/// </summary>
public RegisterUserValidator() public RegisterUserValidator()
{ {
RuleFor(x => x.Username) RuleFor(x => x.Username)

View File

@@ -2,8 +2,5 @@ using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary> /// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
/// </summary>
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;

View File

@@ -10,9 +10,6 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and /// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails. /// sending it via Features.Emails.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
/// <param name="tokenService">Service used to generate the confirmation token.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
/// <remarks> /// <remarks>
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration) /// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
/// or if the user's account is already verified. /// or if the user's account is already verified.

View File

@@ -18,21 +18,11 @@ namespace Features.Auth.Controllers;
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most /// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token. /// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
/// </remarks> /// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[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>
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
/// </remarks>
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register( public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
@@ -50,15 +40,6 @@ public class AuthController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Authenticates a user with a username and password.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and a newly issued refresh/access token pair.
/// </remarks>
/// <param name="query">The login credentials (username and password).</param>
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[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)
@@ -73,15 +54,6 @@ public class AuthController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Confirms a user account using a confirmation token.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
/// user's ID and the confirmation timestamp.
/// </remarks>
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")] [HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm( public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
[FromQuery] string token [FromQuery] string token
@@ -97,14 +69,6 @@ public class AuthController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Resends the account confirmation email for the specified user.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks>
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
[HttpPost("confirm/resend")] [HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId) public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{ {
@@ -112,15 +76,6 @@ 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>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and the newly issued refresh/access token pair.
/// </remarks>
/// <param name="command">The request containing the refresh token to exchange.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh( public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(

View File

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

View File

@@ -1,12 +1,6 @@
namespace Features.Auth.Dtos; namespace Features.Auth.Dtos;
/// <summary> /// <summary>Payload returned to the client after a successful login or token refresh.</summary>
/// Payload returned to the client after a successful login or token refresh.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
/// <param name="Username">The username of the authenticated user account.</param>
/// <param name="RefreshToken">The newly issued refresh token used to obtain new access tokens.</param>
/// <param name="AccessToken">The newly issued JWT access token used to authorize subsequent requests.</param>
public record LoginPayload( public record LoginPayload(
Guid UserAccountId, Guid UserAccountId,
string Username, string Username,
@@ -14,14 +8,7 @@ public record LoginPayload(
string AccessToken string AccessToken
); );
/// <summary> /// <summary>Payload returned to the client after a successful registration.</summary>
/// Payload returned to the client after a successful registration.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
/// <param name="Username">The username of the newly created user account.</param>
/// <param name="RefreshToken">The refresh token issued for the new account.</param>
/// <param name="AccessToken">The JWT access token issued for the new account.</param>
/// <param name="ConfirmationEmailSent">Whether a confirmation email was successfully sent to the new user.</param>
public record RegistrationPayload( public record RegistrationPayload(
Guid UserAccountId, Guid UserAccountId,
string Username, string Username,
@@ -30,9 +17,5 @@ public record RegistrationPayload(
bool ConfirmationEmailSent bool ConfirmationEmailSent
); );
/// <summary> /// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
/// Payload returned to the client after a user account's email has been confirmed.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -11,12 +11,6 @@ namespace Features.Auth.Queries.Login;
/// <summary> /// <summary>
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens. /// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
/// </summary> /// </summary>
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
/// <param name="passwordInfrastructure">
/// Infrastructure component used to verify a plain-text password against a stored
/// hash.
/// </param>
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
public class LoginHandler( public class LoginHandler(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure, IPasswordInfrastructure passwordInfrastructure,

View File

@@ -2,15 +2,8 @@ using FluentValidation;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Queries.Login;
/// <summary>
/// Validates <see cref="LoginQuery" /> instances before they are processed.
/// </summary>
public class LoginValidator : AbstractValidator<LoginQuery> public class LoginValidator : AbstractValidator<LoginQuery>
{ {
/// <summary>
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
/// <see cref="LoginQuery.Password" /> to be non-empty.
/// </summary>
public LoginValidator() public LoginValidator()
{ {
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required"); RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");

View File

@@ -11,7 +11,6 @@ namespace Features.Auth.Repository;
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures, /// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification. /// handling user registration, credential lookup/rotation, and account verification.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class AuthRepository(ISqlConnectionFactory connectionFactory) public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory), : Repository<UserAccount>(connectionFactory),
IAuthRepository IAuthRepository
@@ -26,15 +25,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the /// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail. /// subsequent lookup to fail.
/// </remarks> /// </remarks>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception> /// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount> RegisterUserAsync( public async Task<UserAccount> RegisterUserAsync(
UserRegistrationDto userRegistrationDto UserRegistrationDto userRegistrationDto
) )
@@ -84,13 +75,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> /// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
/// Retrieves a user account by email address (typically used for login) using the
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
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();
@@ -104,13 +89,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <summary> /// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
/// Retrieves a user account by username (typically used for login) using the
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
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();
@@ -128,9 +107,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Retrieves the active (non-revoked) credential for a user account using the /// Retrieves the active (non-revoked) credential for a user account using the
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure. /// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
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();
@@ -148,9 +124,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Rotates a user's credential by invalidating all existing credentials and creating a new one, /// Rotates a user's credential by invalidating all existing credentials and creating a new one,
/// using the <c>USP_RotateUserCredential</c> stored procedure. /// using the <c>USP_RotateUserCredential</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</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();
@@ -164,12 +137,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
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();
@@ -189,8 +157,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user /// 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. /// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException"> /// <exception cref="Microsoft.Data.SqlClient.SqlException">
/// Thrown when the database command fails for a reason other than /// Thrown when the database command fails for a reason other than
/// a duplicate verification record. /// a duplicate verification record.
@@ -225,13 +191,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId); return await GetUserByIdAsync(userAccountId);
} }
/// <summary> /// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
/// Checks whether a user account has been verified by querying the
/// <c>dbo.UserVerification</c> table for a matching record.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
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();
@@ -246,23 +206,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return result != null && result != DBNull.Value; return result != null && result != DBNull.Value;
} }
/// <summary> /// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
/// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
/// </summary>
/// <param name="ex">The SQL exception to inspect.</param>
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
private static bool IsDuplicateVerificationViolation(SqlException ex) private static bool IsDuplicateVerificationViolation(SqlException ex)
{ {
// 2601/2627 are duplicate key violations in SQL Server. // 2601/2627 are duplicate key violations in SQL Server.
return ex.Number == 2601 || ex.Number == 2627; return ex.Number == 2601 || ex.Number == 2627;
} }
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override UserAccount MapToEntity(DbDataReader reader) protected override UserAccount MapToEntity(DbDataReader reader)
{ {
return new UserAccount return new UserAccount
@@ -285,8 +235,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if /// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
/// present in the reader's schema, allowing this method to support result sets that omit it. /// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
private static UserCredential MapToCredentialEntity(DbDataReader reader) private static UserCredential MapToCredentialEntity(DbDataReader reader)
{ {
UserCredential entity = new() UserCredential entity = new()
@@ -319,13 +267,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return entity; return entity;
} }
/// <summary> /// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value" />.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter(DbCommand command, string name, object? value) private static void AddParameter(DbCommand command, string name, object? value)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();

View File

@@ -7,64 +7,23 @@ namespace Features.Auth.Repository;
/// </summary> /// </summary>
public interface IAuthRepository public interface IAuthRepository
{ {
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
Task<UserAccount> RegisterUserAsync( Task<UserAccount> RegisterUserAsync(
UserRegistrationDto userRegistrationDto UserRegistrationDto userRegistrationDto
); );
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByEmailAsync(string email); Task<UserAccount?> GetUserByEmailAsync(string email);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByUsernameAsync(string username); Task<UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
/// <summary> /// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
/// <summary> /// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
/// Marks a user account as confirmed.
/// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId); Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
/// <summary>
/// Retrieves a user account by ID.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId); Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId); Task<bool> IsUserVerifiedAsync(Guid userAccountId);
} }

View File

@@ -18,20 +18,10 @@ public enum TokenType
ConfirmationToken, ConfirmationToken,
} }
/// <summary> /// <summary>Represents the result of successfully validating a token.</summary>
/// Represents the result of successfully validating a token.
/// </summary>
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
/// <param name="Username">The username extracted from the token's claims.</param>
/// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
/// <summary> /// <summary>Represents the result of refreshing a user's session.</summary>
/// Represents the result of refreshing a user's session.
/// </summary>
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
/// <param name="RefreshToken">The newly issued refresh token.</param>
/// <param name="AccessToken">The newly issued access token.</param>
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken); public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
/// <summary> /// <summary>
@@ -39,14 +29,13 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
/// </summary> /// </summary>
public static class TokenServiceExpirationHours public static class TokenServiceExpirationHours
{ {
/// <summary>The expiration window, in hours, for access tokens.</summary>
public const double AccessTokenHours = 1; public const double AccessTokenHours = 1;
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary> /// <summary>21 days.</summary>
public const double RefreshTokenHours = 504; // 21 days public const double RefreshTokenHours = 504;
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary> /// <summary>30 minutes.</summary>
public const double ConfirmationTokenHours = 0.5; // 30 minutes public const double ConfirmationTokenHours = 0.5;
} }
/// <summary> /// <summary>
@@ -54,61 +43,23 @@ public static class TokenServiceExpirationHours
/// </summary> /// </summary>
public interface ITokenService public interface ITokenService
{ {
/// <summary>
/// Generates a new access token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
string GenerateAccessToken(UserAccount user); string GenerateAccessToken(UserAccount user);
/// <summary>
/// Generates a new refresh token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
string GenerateRefreshToken(UserAccount user); string GenerateRefreshToken(UserAccount user);
/// <summary>
/// Generates a new confirmation token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
string GenerateConfirmationToken(UserAccount user); string GenerateConfirmationToken(UserAccount user);
/// <summary> /// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
/// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />. /// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
/// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns>
string GenerateToken<T>(UserAccount user) string GenerateToken<T>(UserAccount user)
where T : struct, Enum; where T : struct, Enum;
/// <summary>
/// Validates an access token.
/// </summary>
/// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateAccessTokenAsync(string token); Task<ValidatedToken> ValidateAccessTokenAsync(string token);
/// <summary>
/// Validates a refresh token.
/// </summary>
/// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateRefreshTokenAsync(string token); Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
/// <summary>
/// Validates a confirmation token.
/// </summary>
/// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token); Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
/// <summary> /// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString); Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
} }

View File

@@ -19,12 +19,7 @@ public class TokenService : ITokenService
private readonly string _refreshTokenSecret; private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure; private readonly ITokenInfrastructure _tokenInfrastructure;
/// <summary> /// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
/// confirmation token signing secrets from environment variables.
/// </summary>
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or /// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set. /// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
@@ -53,12 +48,6 @@ public class TokenService : ITokenService
); );
} }
/// <summary>
/// Generates an access token for the given user, signed with the access token secret and
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
public string GenerateAccessToken(UserAccount user) public string GenerateAccessToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
@@ -70,12 +59,6 @@ public class TokenService : ITokenService
); );
} }
/// <summary>
/// Generates a refresh token for the given user, signed with the refresh token secret and
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user) public string GenerateRefreshToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours( DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -89,12 +72,6 @@ public class TokenService : ITokenService
); );
} }
/// <summary>
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user) public string GenerateConfirmationToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours( DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -111,11 +88,9 @@ public class TokenService : ITokenService
/// <summary> /// <summary>
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to /// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or /// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value. /// <see cref="GenerateConfirmationToken" />.
/// </summary> /// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam> /// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type. /// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
/// </exception> /// </exception>
@@ -139,11 +114,6 @@ public class TokenService : ITokenService
}; };
} }
/// <summary>
/// Validates an access token against the access token secret.
/// </summary>
/// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
@@ -152,11 +122,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
} }
/// <summary>
/// Validates a refresh token against the refresh token secret.
/// </summary>
/// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
@@ -165,11 +130,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
} }
/// <summary>
/// Validates a confirmation token against the confirmation token secret.
/// </summary>
/// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
@@ -178,12 +138,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
} }
/// <summary>
/// Validates the given refresh token, looks up the associated user, and issues a fresh
/// access/refresh token pair.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists. /// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception> /// </exception>
@@ -204,10 +158,7 @@ public class TokenService : ITokenService
/// Performs the shared validation logic for access, refresh, and confirmation tokens: /// Performs the shared validation logic for access, refresh, and confirmation tokens:
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims. /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary> /// </summary>
/// <param name="token">The token string to validate.</param>
/// <param name="secret">The secret key to validate the token's signature against.</param>
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param> /// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />, /// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).

View File

@@ -3,9 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand" />.
/// </summary>
public record CreateBreweryLocation( public record CreateBreweryLocation(
Guid CityId, Guid CityId,
string AddressLine1, string AddressLine1,

View File

@@ -5,19 +5,10 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post and its location.
/// </summary>
/// <param name="repository">Repository used to persist the new brewery post.</param>
public class CreateBreweryHandler(IBreweryRepository repository) public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto> : IRequestHandler<CreateBreweryCommand, BreweryDto>
{ {
/// <summary> /// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary>
/// Creates a new brewery post, generating new identifiers for the post and its location.
/// </summary>
/// <param name="request">The details of the brewery post to create.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The newly created brewery post.</returns>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
CreateBreweryCommand request, CreateBreweryCommand request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -2,17 +2,8 @@ using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Validates <see cref="CreateBreweryCommand" /> instances before they are processed.
/// </summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand> public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{ {
/// <summary>
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById" />,
/// <see cref="CreateBreweryCommand.BreweryName" />, <see cref="CreateBreweryCommand.Description" />, and
/// <see cref="CreateBreweryCommand.Location" /> to be present, with length limits on the name, description,
/// address line 1, and postal code fields.
/// </summary>
public CreateBreweryValidator() public CreateBreweryValidator()
{ {
RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required."); RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");

View File

@@ -2,7 +2,4 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>
/// Deletes a brewery post by its unique identifier.
/// </summary>
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest; public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;

View File

@@ -3,10 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>
/// Handles <see cref="DeleteBreweryCommand" /> by deleting the matching brewery post.
/// </summary>
/// <param name="repository">Repository used to delete the brewery post.</param>
public class DeleteBreweryHandler(IBreweryRepository repository) public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand> : IRequestHandler<DeleteBreweryCommand>
{ {

View File

@@ -3,9 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand" />.
/// </summary>
public record UpdateBreweryLocation( public record UpdateBreweryLocation(
Guid BreweryPostLocationId, Guid BreweryPostLocationId,
Guid CityId, Guid CityId,

View File

@@ -5,10 +5,6 @@ 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>
/// <param name="repository">Repository used to persist the updated brewery post.</param>
public class UpdateBreweryHandler(IBreweryRepository repository) public class UpdateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<UpdateBreweryCommand, BreweryDto> : IRequestHandler<UpdateBreweryCommand, BreweryDto>
{ {
@@ -16,9 +12,6 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>, /// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
/// the brewery's location is cleared. /// the brewery's location is cleared.
/// </summary> /// </summary>
/// <param name="request">The updated details of the brewery post.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The updated brewery post.</returns>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
UpdateBreweryCommand request, UpdateBreweryCommand request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -11,28 +11,17 @@ using Shared.Contracts;
namespace Features.Breweries.Controllers; namespace Features.Breweries.Controllers;
/// <summary>
/// Provides CRUD endpoints for managing brewery posts.
/// </summary>
/// <remarks> /// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints /// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
/// (<see cref="GetById" /> and <see cref="GetAll" />) opt out via <c>[AllowAnonymous]</c>. /// <c>[AllowAnonymous]</c>.
/// </remarks> /// </remarks>
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[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 its unique identifier.
/// </summary>
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
/// <param name="id">The unique identifier of the brewery post to retrieve.</param> /// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="BreweryDto" /> if found;
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody" /> error message.
/// </returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id) public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
@@ -50,13 +39,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Retrieves a paginated list of brewery posts.
/// </summary>
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of a collection of <see cref="BreweryDto" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet] [HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll( public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
@@ -76,14 +59,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary> /// <returns><c>201 Created</c> with the newly created brewery.</returns>
/// Creates a new brewery post.
/// </summary>
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
/// <returns>
/// A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of the newly created
/// <see cref="BreweryDto" />.
/// </returns>
[HttpPost] [HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create( public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
[FromBody] CreateBreweryCommand command [FromBody] CreateBreweryCommand command
@@ -100,22 +76,8 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary> /// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
/// Updates an existing brewery post. /// <returns><c>200 OK</c> with the updated brewery; or <c>400 Bad Request</c> if the route ID does not match the payload ID.</returns>
/// </summary>
/// <param name="id">
/// The unique identifier of the brewery post from the route, which must match <paramref name="command" />
/// 's ID.
/// </param>
/// <param name="command">
/// The updated brewery details, including the posting user, name, description, and optional
/// location.
/// </param>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of the updated <see cref="BreweryDto" /> if the
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody" /> error message
/// when the route ID does not match the payload ID.
/// </returns>
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update( public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id, Guid id,
@@ -135,11 +97,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary> /// <returns><c>200 OK</c> confirming the deletion.</returns>
/// Deletes a brewery post.
/// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the deletion.</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

@@ -1,89 +1,39 @@
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto" />.
/// </summary>
public class BreweryLocationDto public class BreweryLocationDto
{ {
/// <summary>
/// The unique identifier of the brewery's location record.
/// </summary>
public Guid BreweryPostLocationId { get; set; } public Guid BreweryPostLocationId { get; set; }
/// <summary>
/// The unique identifier of the brewery post that this location belongs to.
/// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary>
/// The unique identifier of the city in which the brewery is located.
/// </summary>
public Guid CityId { get; set; } public Guid CityId { get; set; }
/// <summary>
/// The primary street address line of the brewery.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty; public string AddressLine1 { get; set; } = string.Empty;
/// <summary>
/// An optional secondary address line (e.g. suite or unit number).
/// </summary>
public string? AddressLine2 { get; set; } public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the brewery's address.
/// </summary>
public string PostalCode { get; set; } = string.Empty; public string PostalCode { get; set; } = string.Empty;
/// <summary> /// <summary>Gets or sets the brewery's geographic coordinates, in a raw binary representation.</summary>
/// The optional geographic coordinates of the brewery, in a raw binary representation.
/// </summary>
public byte[]? Coordinates { get; set; } public byte[]? Coordinates { get; set; }
} }
/// <summary>
/// Represents a brewery post as returned by the brewery endpoints, including its metadata and
/// optional location.
/// </summary>
public class BreweryDto public class BreweryDto
{ {
/// <summary>
/// The unique identifier of the brewery post.
/// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary>
/// The unique identifier of the user account that created the brewery post.
/// </summary>
public Guid PostedById { get; set; } public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery.
/// </summary>
public string BreweryName { get; set; } = string.Empty; public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// A description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time at which the brewery post was created.
/// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time at which the brewery post was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary> /// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
/// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post.
/// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
/// <summary>
/// The location details of the brewery, or <c>null</c> if no location is associated with it.
/// </summary>
public BreweryLocationDto? Location { get; set; } public BreweryLocationDto? Location { get; set; }
} }

View File

@@ -2,17 +2,8 @@ using Domain.Entities;
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>
/// Maps <see cref="BreweryPost" /> domain entities to their <see cref="BreweryDto" /> wire representation.
/// </summary>
public static class BreweryDtoMapper public static class BreweryDtoMapper
{ {
/// <summary>
/// Maps a <see cref="BreweryPost" /> domain entity to its <see cref="BreweryDto" /> representation,
/// including its location, if present.
/// </summary>
/// <param name="brewery">The brewery post entity to map.</param>
/// <returns>The mapped <see cref="BreweryDto" />.</returns>
public static BreweryDto ToDto(this BreweryPost brewery) public static BreweryDto ToDto(this BreweryPost brewery)
{ {
return new BreweryDto return new BreweryDto

View File

@@ -5,10 +5,6 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries; namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>
/// Handles <see cref="GetAllBreweriesQuery" /> by retrieving a paginated list of brewery posts.
/// </summary>
/// <param name="repository">Repository used to query brewery post data.</param>
public class GetAllBreweriesHandler(IBreweryRepository repository) public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>> : IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{ {

View File

@@ -5,10 +5,6 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>
/// Handles <see cref="GetBreweryByIdQuery" /> by looking up the matching brewery post.
/// </summary>
/// <param name="repository">Repository used to query brewery post data.</param>
public class GetBreweryByIdHandler(IBreweryRepository repository) public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?> : IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{ {

View File

@@ -3,7 +3,4 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>
/// Retrieves a single brewery post by its unique identifier.
/// </summary>
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>; public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;

View File

@@ -9,17 +9,11 @@ namespace Features.Breweries.Repository;
/// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored /// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
/// procedures. /// procedures.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class BreweryRepository(ISqlConnectionFactory connectionFactory) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory), : Repository<BreweryPost>(connectionFactory),
IBreweryRepository IBreweryRepository
{ {
/// <summary> /// <summary>Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.</summary>
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
/// </summary>
/// <param name="id">The unique identifier of the brewery post.</param>
/// <returns>The matching <see cref="BreweryPost" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
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();
@@ -40,10 +34,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their /// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
/// corresponding argument has a value. /// corresponding argument has a value.
/// </summary> /// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="BreweryPost" /> records.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
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();
@@ -71,8 +61,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is /// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is
/// <c>null</c>, any existing location for the brewery is removed. /// <c>null</c>, any existing location for the brewery is removed.
/// </summary> /// </summary>
/// <param name="brewery">The brewery post containing updated values.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task UpdateAsync(BreweryPost brewery) public async Task UpdateAsync(BreweryPost brewery)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -97,8 +85,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location /// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
/// and photos are removed via cascading foreign keys. /// and photos are removed via cascading foreign keys.
/// </summary> /// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</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();
@@ -110,12 +96,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.</summary>
/// Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.
/// </summary>
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception> /// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task CreateAsync(BreweryPost brewery) public async Task CreateAsync(BreweryPost brewery)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -139,12 +121,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
/// <summary> /// <summary>
/// Maps the current row of a data reader to a <see cref="BreweryPost" /> entity, including its /// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
/// rowversion <c>Timer</c> field and, if location columns are present in the result set, its /// associated <see cref="BreweryPostLocation" /> if location columns are present in the result set.
/// associated <see cref="BreweryPostLocation" />.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="BreweryPost" /> instance.</returns>
protected override BreweryPost MapToEntity(DbDataReader reader) protected override BreweryPost MapToEntity(DbDataReader reader)
{ {
BreweryPost brewery = new(); BreweryPost brewery = new();
@@ -212,13 +191,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
return brewery; return brewery;
} }
/// <summary> /// <summary>Adds a parameter to <paramref name="command" />, converting <c>null</c> to <see cref="DBNull.Value" />.</summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value" />.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter(DbCommand command, string name, object? value) private static void AddParameter(DbCommand command, string name, object? value)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();

View File

@@ -7,37 +7,16 @@ namespace Features.Breweries.Repository;
/// </summary> /// </summary>
public interface IBreweryRepository public interface IBreweryRepository
{ {
/// <summary> /// <summary>Retrieves a brewery post by ID, or <c>null</c> if not found.</summary>
/// Retrieves a brewery post by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the brewery post.</param>
/// <returns>The matching <see cref="BreweryPost" />, or <c>null</c> if not found.</returns>
Task<BreweryPost?> GetByIdAsync(Guid id); Task<BreweryPost?> GetByIdAsync(Guid id);
/// <summary>
/// Retrieves all brewery posts, optionally paginated.
/// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="BreweryPost" /> records.</returns>
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset); Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
/// <summary>
/// Updates an existing brewery post.
/// </summary>
/// <param name="brewery">The brewery post containing updated values.</param>
Task UpdateAsync(BreweryPost brewery); Task UpdateAsync(BreweryPost brewery);
/// <summary>
/// Deletes a brewery post by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
Task DeleteAsync(Guid id); Task DeleteAsync(Guid id);
/// <summary> /// <summary>Creates a new brewery post, including its location details.</summary>
/// Creates a new brewery post, including its location details.
/// </summary>
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
/// <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>
Task CreateAsync(BreweryPost brewery); Task CreateAsync(BreweryPost brewery);
} }

View File

@@ -8,7 +8,6 @@ namespace Features.Emails.Commands.SendRegistrationEmail;
/// Handles <see cref="SendRegistrationEmailCommand" />, the cross-slice command sent by Features.Auth /// Handles <see cref="SendRegistrationEmailCommand" />, the cross-slice command sent by Features.Auth
/// after a new user registers. /// after a new user registers.
/// </summary> /// </summary>
/// <param name="emailDispatcher">Dispatcher used to render and send the email.</param>
public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher) public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
: IRequestHandler<SendRegistrationEmailCommand> : IRequestHandler<SendRegistrationEmailCommand>
{ {

View File

@@ -8,7 +8,6 @@ namespace Features.Emails.Commands.SendResendConfirmationEmail;
/// Handles <see cref="SendResendConfirmationEmailCommand" />, the cross-slice command sent by Features.Auth /// Handles <see cref="SendResendConfirmationEmailCommand" />, the cross-slice command sent by Features.Auth
/// when a user requests a fresh confirmation link. /// when a user requests a fresh confirmation link.
/// </summary> /// </summary>
/// <param name="emailDispatcher">Dispatcher used to render and send the email.</param>
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher) public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
: IRequestHandler<SendResendConfirmationEmailCommand> : IRequestHandler<SendResendConfirmationEmailCommand>
{ {

View File

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

View File

@@ -4,11 +4,8 @@ using Infrastructure.Email.Templates.Rendering;
namespace Features.Emails.Services; namespace Features.Emails.Services;
/// <summary> /// <summary>
/// Default implementation of <see cref="IEmailDispatcher" /> that renders email templates and dispatches /// Default implementation of <see cref="IEmailDispatcher" />.
/// them via an <see cref="IEmailProvider" />.
/// </summary> /// </summary>
/// <param name="emailProvider">Provider used to deliver the rendered emails.</param>
/// <param name="emailTemplateProvider">Provider used to render HTML email bodies from templates.</param>
public class EmailDispatcher( public class EmailDispatcher(
IEmailProvider emailProvider, IEmailProvider emailProvider,
IEmailTemplateProvider emailTemplateProvider IEmailTemplateProvider emailTemplateProvider
@@ -25,10 +22,6 @@ 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");
/// <summary>
/// Builds a confirmation link from the given token, renders the registration welcome email template,
/// and sends it to the newly created user.
/// </summary>
public async Task SendRegistrationEmailAsync( public async Task SendRegistrationEmailAsync(
string firstName, string firstName,
string email, string email,
@@ -45,10 +38,6 @@ 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);
} }
/// <summary>
/// Builds a confirmation link from the given token, renders the resend-confirmation email template,
/// and sends it to the user.
/// </summary>
public async Task SendResendConfirmationEmailAsync( public async Task SendResendConfirmationEmailAsync(
string firstName, string firstName,
string email, string email,

View File

@@ -1,25 +1,17 @@
namespace Features.Emails.Services; namespace Features.Emails.Services;
/// <summary> /// <summary>
/// Defines operations for sending account-related emails, such as registration and confirmation resend emails. /// Defines operations for sending account-related emails.
/// </summary> /// </summary>
public interface IEmailDispatcher 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>
/// <param name="firstName">The recipient's first name, used to personalize the email.</param>
/// <param name="email">The recipient's email address.</param>
/// <param name="confirmationToken">The confirmation token to embed in the confirmation link.</param>
/// <returns>A task that completes once the email has been sent.</returns>
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>
/// <param name="firstName">The recipient's first name, used to personalize the email.</param>
/// <param name="email">The recipient's email address.</param>
/// <param name="confirmationToken">The confirmation token to embed in the confirmation link.</param>
/// <returns>A task that completes once the email has been sent.</returns>
Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken); Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken);
} }

View File

@@ -3,10 +3,6 @@ using MediatR;
namespace Features.UserManagement.Commands.UpdateUser; namespace Features.UserManagement.Commands.UpdateUser;
/// <summary>
/// Handles <see cref="UpdateUserCommand" /> by persisting changes to an existing user account.
/// </summary>
/// <param name="repository">Repository used to persist the updated user account.</param>
public class UpdateUserHandler(IUserAccountRepository repository) public class UpdateUserHandler(IUserAccountRepository repository)
: IRequestHandler<UpdateUserCommand> : IRequestHandler<UpdateUserCommand>
{ {

View File

@@ -6,20 +6,12 @@ using Microsoft.AspNetCore.Mvc;
namespace Features.UserManagement.Controllers; namespace Features.UserManagement.Controllers;
/// <summary>
/// Provides read-only endpoints for retrieving user accounts.
/// </summary>
/// <param name="mediator">Used to dispatch user queries to their handlers.</param>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class UserController(IMediator mediator) : ControllerBase public class UserController(IMediator mediator) : ControllerBase
{ {
/// <summary> /// <param name="limit"><c>null</c> for no limit.</param>
/// Retrieves a paginated list of user accounts. /// <param name="offset"><c>null</c> for no offset.</param>
/// </summary>
/// <param name="limit">The maximum number of user accounts to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of user accounts to skip before returning results, or <c>null</c> for no offset.</param>
/// <returns>A <c>200 OK</c> result containing the collection of <see cref="UserAccount" /> entities.</returns>
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll( public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
[FromQuery] int? limit, [FromQuery] int? limit,
@@ -30,11 +22,6 @@ public class UserController(IMediator mediator) : ControllerBase
return Ok(users); return Ok(users);
} }
/// <summary>
/// Retrieves a single user account by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the user account to retrieve.</param>
/// <returns>A <c>200 OK</c> result containing the matching <see cref="UserAccount" />.</returns>
[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,9 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.UserManagement.DependencyInjection; namespace Features.UserManagement.DependencyInjection;
/// <summary>
/// Registers the services owned by the UserManagement feature slice.
/// </summary>
public static class FeaturesUserManagementServiceCollectionExtensions public static class FeaturesUserManagementServiceCollectionExtensions
{ {
public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services) public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services)

View File

@@ -4,10 +4,6 @@ using MediatR;
namespace Features.UserManagement.Queries.GetAllUsers; namespace Features.UserManagement.Queries.GetAllUsers;
/// <summary>
/// Handles <see cref="GetAllUsersQuery" /> by retrieving a paginated list of user accounts.
/// </summary>
/// <param name="repository">Repository used to query user account data.</param>
public class GetAllUsersHandler(IUserAccountRepository repository) public class GetAllUsersHandler(IUserAccountRepository repository)
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>> : IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
{ {

View File

@@ -3,7 +3,4 @@ using MediatR;
namespace Features.UserManagement.Queries.GetAllUsers; namespace Features.UserManagement.Queries.GetAllUsers;
/// <summary>
/// Retrieves a paginated list of user accounts.
/// </summary>
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>; public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;

View File

@@ -5,10 +5,6 @@ using MediatR;
namespace Features.UserManagement.Queries.GetUserById; namespace Features.UserManagement.Queries.GetUserById;
/// <summary>
/// Handles <see cref="GetUserByIdQuery" /> by looking up the matching user account.
/// </summary>
/// <param name="repository">Repository used to query user account data.</param>
public class GetUserByIdHandler(IUserAccountRepository repository) public class GetUserByIdHandler(IUserAccountRepository repository)
: IRequestHandler<GetUserByIdQuery, UserAccount> : IRequestHandler<GetUserByIdQuery, UserAccount>
{ {

View File

@@ -3,7 +3,4 @@ using MediatR;
namespace Features.UserManagement.Queries.GetUserById; namespace Features.UserManagement.Queries.GetUserById;
/// <summary>
/// Retrieves a single user account by its unique identifier.
/// </summary>
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>; public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;

View File

@@ -8,43 +8,29 @@ namespace Features.UserManagement.Repository;
public interface IUserAccountRepository public interface IUserAccountRepository
{ {
/// <summary> /// <summary>
/// Retrieves a user account by its unique identifier. /// Retrieves a user account by its unique identifier, or <c>null</c> if not found.
/// </summary> /// </summary>
/// <param name="id">The unique identifier of the user account.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
Task<UserAccount?> GetByIdAsync(Guid id); Task<UserAccount?> GetByIdAsync(Guid id);
/// <summary> /// <summary>
/// Retrieves all user accounts, optionally paginated. /// Retrieves all user accounts, optionally paginated.
/// </summary> /// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param> /// <param name="limit"><c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param> /// <param name="offset"><c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns>
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset); Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
/// <summary> /// <param name="userAccount">Must have a valid <c>UserAccountId</c>.</param>
/// Updates an existing user account's details.
/// </summary>
/// <param name="userAccount">The user account containing updated values. Must have a valid <c>UserAccountId</c>.</param>
Task UpdateAsync(UserAccount userAccount); Task UpdateAsync(UserAccount userAccount);
/// <summary>
/// Deletes a user account by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the user account to delete.</param>
Task DeleteAsync(Guid id); Task DeleteAsync(Guid id);
/// <summary> /// <summary>
/// Retrieves a user account by username. /// Retrieves a user account by username, or <c>null</c> if not found.
/// </summary> /// </summary>
/// <param name="username">The username to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
Task<UserAccount?> GetByUsernameAsync(string username); Task<UserAccount?> GetByUsernameAsync(string username);
/// <summary> /// <summary>
/// Retrieves a user account by email address. /// Retrieves a user account by email address, or <c>null</c> if not found.
/// </summary> /// </summary>
/// <param name="email">The email address to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
Task<UserAccount?> GetByEmailAsync(string email); Task<UserAccount?> GetByEmailAsync(string email);
} }

View File

@@ -9,17 +9,12 @@ namespace Features.UserManagement.Repository;
/// ADO.NET-based implementation of <see cref="IUserAccountRepository" /> backed by SQL Server /// ADO.NET-based implementation of <see cref="IUserAccountRepository" /> backed by SQL Server
/// stored procedures. /// stored procedures.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class UserAccountRepository(ISqlConnectionFactory connectionFactory) public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory), : Repository<UserAccount>(connectionFactory),
IUserAccountRepository IUserAccountRepository
{ {
/// <summary> /// <summary>Uses the <c>usp_GetUserAccountById</c> stored procedure.</summary>
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure. /// <inheritdoc />
/// </summary>
/// <param name="id">The unique identifier of the user account.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetByIdAsync(Guid id) public async Task<UserAccount?> GetByIdAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -34,14 +29,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
} }
/// <summary> /// <summary>
/// Retrieves all user accounts, optionally paginated, using the <c>usp_GetAllUserAccounts</c> /// Uses the <c>usp_GetAllUserAccounts</c> stored procedure. The <c>@Limit</c> and <c>@Offset</c>
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their /// parameters are only added when their corresponding argument has a value.
/// corresponding argument has a value.
/// </summary> /// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param> /// <inheritdoc />
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -68,8 +59,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// Updates a user account's username, first name, last name, email, and date of birth using /// Updates a user account's username, first name, last name, email, and date of birth using
/// the <c>usp_UpdateUserAccount</c> stored procedure. /// the <c>usp_UpdateUserAccount</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccount">The user account containing updated values. Must have a valid <c>UserAccountId</c>.</param> /// <inheritdoc />
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</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();
@@ -87,11 +77,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>Uses the <c>usp_DeleteUserAccount</c> stored procedure.</summary>
/// Deletes a user account by ID using the <c>usp_DeleteUserAccount</c> stored procedure. /// <inheritdoc />
/// </summary>
/// <param name="id">The unique identifier of the user account to delete.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</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();
@@ -103,12 +90,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>Uses the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
/// Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure. /// <inheritdoc />
/// </summary>
/// <param name="username">The username to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetByUsernameAsync(string username) public async Task<UserAccount?> GetByUsernameAsync(string username)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -122,12 +105,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <summary> /// <summary>Uses the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
/// Retrieves a user account by email address using the <c>usp_GetUserAccountByEmail</c> stored procedure. /// <inheritdoc />
/// </summary>
/// <param name="email">The email address to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetByEmailAsync(string email) public async Task<UserAccount?> GetByEmailAsync(string email)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -141,11 +120,6 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <summary>
/// Maps the current row of a data reader to a <see cref="Domain.Entities.UserAccount" /> entity.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override UserAccount MapToEntity(DbDataReader reader) protected override UserAccount MapToEntity(DbDataReader reader)
{ {
return new UserAccount return new UserAccount
@@ -164,13 +138,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
}; };
} }
/// <summary> /// <summary>Converts <c>null</c> values to <see cref="DBNull.Value" /> when adding the parameter.</summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value" />.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter(DbCommand command, string name, object? value) private static void AddParameter(DbCommand command, string name, object? value)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();