mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
update features docs
This commit is contained in:
@@ -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>;
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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?>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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?>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user