From 3034020d56eac662a51b41e74179f0c7650e8707 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 18 Jun 2026 23:25:50 -0400 Subject: [PATCH] add xmldoc comments --- .../JwtAuthenticationHandler.cs | 32 ++++ .../API/API.Core/Contracts/Auth/AuthDTO.cs | 20 +++ .../API/API.Core/Contracts/Auth/Login.cs | 17 ++ .../API.Core/Contracts/Auth/RefreshToken.cs | 12 ++ .../API/API.Core/Contracts/Auth/Register.cs | 16 ++ .../BreweryCreateRequestValidator.cs | 9 + .../Contracts/Breweries/BreweryDto.cs | 106 ++++++++++++ .../API.Core/Contracts/Common/ResponseBody.cs | 17 ++ .../API.Core/Controllers/AuthController.cs | 55 ++++++ .../API.Core/Controllers/BreweryController.cs | 60 ++++++- .../Controllers/NotFoundController.cs | 11 ++ .../Controllers/ProtectedController.cs | 11 ++ .../API.Core/Controllers/UserController.cs | 15 ++ web/backend/API/API.Core/GlobalException.cs | 23 +++ .../Database/Database.Migrations/Program.cs | 48 ++++++ web/backend/Database/Database.Seed/ISeeder.cs | 11 ++ .../Database/Database.Seed/LocationSeeder.cs | 40 +++++ web/backend/Database/Database.Seed/Program.cs | 14 ++ .../Database/Database.Seed/UserSeeder.cs | 51 ++++++ .../Domain.Entities/Entities/BreweryPost.cs | 35 ++++ .../Entities/BreweryPostLocation.cs | 35 ++++ .../Domain.Entities/Entities/UserAccount.cs | 39 +++++ .../Entities/UserCredential.cs | 27 +++ .../Entities/UserVerification.cs | 20 +++ .../Rendering/EmailTemplateProvider.cs | 13 ++ .../Infrastructure.Email/SmtpEmailProvider.cs | 37 ++++ .../ITokenInfrastructure.cs | 18 ++ .../Infrastructure.Jwt/JwtInfrastructure.cs | 27 +++ .../Argon2Infrastructure.cs | 27 +++ .../IPasswordInfrastructure.cs | 15 ++ .../Auth/AuthRepository.cs | 93 +++++++++- .../Auth/IAuthRepository.cs | 3 +- .../Breweries/BreweryRepository.cs | 48 ++++++ .../Breweries/IBreweryRepository.cs | 31 ++++ .../Infrastructure.Repository/Repository.cs | 16 ++ .../Sql/DefaultSqlConnectionFactory.cs | 20 +++ .../Sql/ISqlConnectionFactory.cs | 7 + .../Sql/SqlConnectionStringHelper.cs | 3 + .../UserAccount/IUserAccountRepository.cs | 37 ++++ .../UserAccount/UserAccountRepository.cs | 55 ++++++ .../Service.Auth/ConfirmationService.cs | 25 +++ .../Service.Auth/IConfirmationService.cs | 19 ++ .../Service/Service.Auth/ILoginService.cs | 16 ++ .../Service/Service.Auth/IRegisterService.cs | 44 +++++ .../Service/Service.Auth/ITokenService.cs | 163 ++++++++++++++++++ .../Service/Service.Auth/LoginService.cs | 16 ++ .../Service/Service.Auth/RegisterService.cs | 30 ++++ .../Service.Breweries/BreweryService.cs | 31 ++++ .../Service.Breweries/IBreweryService.cs | 87 ++++++++++ .../Service/Service.Emails/EmailService.cs | 42 +++++ .../User/IUserService.cs | 20 +++ .../User/UserService.cs | 21 +++ 52 files changed, 1681 insertions(+), 7 deletions(-) diff --git a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs index c531e2b..0072a84 100644 --- a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs +++ b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -8,6 +8,15 @@ using Microsoft.Extensions.Options; namespace API.Core.Authentication; +/// +/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens +/// supplied in the Authorization header against the JWT authentication scheme. +/// +/// The monitored options for the JWT authentication scheme. +/// The logger factory used by the base . +/// The URL encoder used by the base . +/// The infrastructure service used to validate JWT access tokens. +/// The application configuration, used as a fallback source for the JWT secret key. public class JwtAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, @@ -16,6 +25,20 @@ public class JwtAuthenticationHandler( IConfiguration configuration ) : AuthenticationHandler(options, logger, encoder) { + /// + /// Validates the incoming request's bearer JWT access token and produces an authentication result. + /// + /// + /// The signing secret is resolved first from the ACCESS_TOKEN_SECRET environment variable, falling + /// back to the Jwt:SecretKey configuration value, to stay consistent with the secret source used + /// when tokens are issued. Authentication fails if the secret is not configured, the + /// Authorization header is missing, the header does not use the Bearer scheme, or the + /// token fails validation. + /// + /// + /// A successful containing the validated + /// on success, or a failure result describing why authentication could not be completed. + /// protected override async Task HandleAuthenticateAsync() { // Use the same access-token secret source as TokenService to avoid mismatched validation. @@ -73,6 +96,11 @@ public class JwtAuthenticationHandler( } } + /// + /// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied. + /// + /// The authentication properties associated with the challenge. + /// A task that completes once the response body has been written. protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { Response.ContentType = "application/json"; @@ -83,4 +111,8 @@ public class JwtAuthenticationHandler( } } +/// +/// Options for the JWT authentication scheme handled by . +/// +/// No additional options are defined beyond those provided by . public class JwtAuthenticationOptions : AuthenticationSchemeOptions { } diff --git a/web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs b/web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs index 124ae2e..aa41e65 100644 --- a/web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs +++ b/web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs @@ -3,6 +3,13 @@ using Org.BouncyCastle.Asn1.Cms; namespace API.Core.Contracts.Auth; +/// +/// Payload returned to the client after a successful login or token refresh. +/// +/// The unique identifier of the authenticated user account. +/// The username of the authenticated user account. +/// The newly issued refresh token used to obtain new access tokens. +/// The newly issued JWT access token used to authorize subsequent requests. public record LoginPayload( Guid UserAccountId, string Username, @@ -10,6 +17,14 @@ public record LoginPayload( string AccessToken ); +/// +/// Payload returned to the client after a successful registration. +/// +/// The unique identifier of the newly created user account. +/// The username of the newly created user account. +/// The refresh token issued for the new account. +/// The JWT access token issued for the new account. +/// Whether a confirmation email was successfully sent to the new user. public record RegistrationPayload( Guid UserAccountId, string Username, @@ -18,4 +33,9 @@ public record RegistrationPayload( bool ConfirmationEmailSent ); +/// +/// Payload returned to the client after a user account's email has been confirmed. +/// +/// The unique identifier of the user account that was confirmed. +/// The date and time at which the account was confirmed. public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); diff --git a/web/backend/API/API.Core/Contracts/Auth/Login.cs b/web/backend/API/API.Core/Contracts/Auth/Login.cs index c0bb5f8..4d9a93f 100644 --- a/web/backend/API/API.Core/Contracts/Auth/Login.cs +++ b/web/backend/API/API.Core/Contracts/Auth/Login.cs @@ -3,14 +3,31 @@ using FluentValidation; namespace API.Core.Contracts.Auth; +/// +/// Request body for the login endpoint, containing the credentials used to authenticate a user. +/// public record LoginRequest { + /// + /// The username of the account attempting to log in. + /// public string Username { get; init; } = default!; + + /// + /// The plaintext password of the account attempting to log in. + /// public string Password { get; init; } = default!; } +/// +/// Validates instances before they are processed by the login endpoint. +/// public class LoginRequestValidator : AbstractValidator { + /// + /// Configures validation rules requiring both and + /// to be non-empty. + /// public LoginRequestValidator() { RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required"); diff --git a/web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs b/web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs index 0914d52..f2705cc 100644 --- a/web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs +++ b/web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs @@ -2,14 +2,26 @@ using FluentValidation; namespace API.Core.Contracts.Auth; +/// +/// Request body for the token refresh endpoint, containing the refresh token used to obtain a new access token. +/// public record RefreshTokenRequest { + /// + /// The refresh token previously issued to the client during login, registration, or a prior refresh. + /// public string RefreshToken { get; init; } = default!; } +/// +/// Validates instances before they are processed by the refresh endpoint. +/// public class RefreshTokenRequestValidator : AbstractValidator { + /// + /// Configures a validation rule requiring to be non-empty. + /// public RefreshTokenRequestValidator() { RuleFor(x => x.RefreshToken) diff --git a/web/backend/API/API.Core/Contracts/Auth/Register.cs b/web/backend/API/API.Core/Contracts/Auth/Register.cs index 31c3541..26ea2d3 100644 --- a/web/backend/API/API.Core/Contracts/Auth/Register.cs +++ b/web/backend/API/API.Core/Contracts/Auth/Register.cs @@ -3,6 +3,15 @@ using FluentValidation; namespace API.Core.Contracts.Auth; +/// +/// Request body for the registration endpoint, containing the details needed to create a new user account. +/// +/// The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens. +/// The user's first name; up to 128 characters. +/// The user's last name; up to 128 characters. +/// The user's email address; up to 128 characters and must be a valid email format. +/// The user's date of birth; the user must be at least 19 years old. +/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character. public record RegisterRequest( string Username, string FirstName, @@ -12,8 +21,15 @@ public record RegisterRequest( string Password ); +/// +/// Validates instances before they are processed by the registration endpoint. +/// public class RegisterRequestValidator : AbstractValidator { + /// + /// 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. + /// public RegisterRequestValidator() { RuleFor(x => x.Username) diff --git a/web/backend/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs b/web/backend/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs index b181a74..fa8d26f 100644 --- a/web/backend/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs +++ b/web/backend/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs @@ -2,8 +2,17 @@ using FluentValidation; namespace API.Core.Contracts.Breweries; +/// +/// Validates instances before they are processed by the brewery creation endpoint. +/// public class BreweryCreateDtoValidator : AbstractValidator { + /// + /// Configures validation rules requiring , + /// , , and + /// to be present, with length limits on the name, description, + /// address line 1, and postal code fields. + /// public BreweryCreateDtoValidator() { RuleFor(x => x.PostedById) diff --git a/web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs b/web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs index c6ff6bf..eda26e3 100644 --- a/web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs +++ b/web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs @@ -1,41 +1,147 @@ namespace API.Core.Contracts.Breweries; +/// +/// Request payload describing the location of a brewery to be created, supplied as part of +/// . +/// public class BreweryLocationCreateDto { + /// + /// The unique identifier of the city in which the brewery is located. + /// public Guid CityId { get; set; } + + /// + /// The primary street address line of the brewery. + /// public string AddressLine1 { get; set; } = string.Empty; + + /// + /// An optional secondary address line (e.g. suite or unit number). + /// public string? AddressLine2 { get; set; } + + /// + /// The postal/ZIP code of the brewery's address. + /// public string PostalCode { get; set; } = string.Empty; + + /// + /// The optional geographic coordinates of the brewery, in a raw binary representation. + /// public byte[]? Coordinates { get; set; } } +/// +/// Represents the location details of an existing brewery, as returned in . +/// public class BreweryLocationDto { + /// + /// The unique identifier of the brewery's location record. + /// public Guid BreweryPostLocationId { get; set; } + + /// + /// The unique identifier of the brewery post that this location belongs to. + /// public Guid BreweryPostId { get; set; } + + /// + /// The unique identifier of the city in which the brewery is located. + /// public Guid CityId { get; set; } + + /// + /// The primary street address line of the brewery. + /// public string AddressLine1 { get; set; } = string.Empty; + + /// + /// An optional secondary address line (e.g. suite or unit number). + /// public string? AddressLine2 { get; set; } + + /// + /// The postal/ZIP code of the brewery's address. + /// public string PostalCode { get; set; } = string.Empty; + + /// + /// The optional geographic coordinates of the brewery, in a raw binary representation. + /// public byte[]? Coordinates { get; set; } } +/// +/// Request body used by to create a new brewery post. +/// public class BreweryCreateDto { + /// + /// The unique identifier of the user account that is creating the brewery post. + /// public Guid PostedById { get; set; } + + /// + /// The name of the brewery; required and limited to 256 characters. + /// public string BreweryName { get; set; } = string.Empty; + + /// + /// A description of the brewery; required and limited to 512 characters. + /// public string Description { get; set; } = string.Empty; + + /// + /// The location details of the brewery being created. + /// public BreweryLocationCreateDto Location { get; set; } = null!; } +/// +/// Represents a brewery post as returned by, or submitted to, the brewery endpoints, including its +/// metadata and optional location. +/// public class BreweryDto { + /// + /// The unique identifier of the brewery post. + /// public Guid BreweryPostId { get; set; } + + /// + /// The unique identifier of the user account that created the brewery post. + /// public Guid PostedById { get; set; } + + /// + /// The name of the brewery. + /// public string BreweryName { get; set; } = string.Empty; + + /// + /// A description of the brewery. + /// public string Description { get; set; } = string.Empty; + + /// + /// The date and time at which the brewery post was created. + /// public DateTime CreatedAt { get; set; } + + /// + /// The date and time at which the brewery post was last updated, or null if it has never been updated. + /// public DateTime? UpdatedAt { get; set; } + + /// + /// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post. + /// public byte[]? Timer { get; set; } + + /// + /// The location details of the brewery, or null if no location is associated with it. + /// public BreweryLocationDto? Location { get; set; } } diff --git a/web/backend/API/API.Core/Contracts/Common/ResponseBody.cs b/web/backend/API/API.Core/Contracts/Common/ResponseBody.cs index 12acd29..e0c6935 100644 --- a/web/backend/API/API.Core/Contracts/Common/ResponseBody.cs +++ b/web/backend/API/API.Core/Contracts/Common/ResponseBody.cs @@ -1,12 +1,29 @@ namespace API.Core.Contracts.Common; +/// +/// Generic envelope used to wrap API responses that carry a data payload alongside a human-readable message. +/// +/// The type of the data payload returned in the response. public record ResponseBody { + /// + /// A human-readable message describing the outcome of the request. + /// public required string Message { get; init; } + + /// + /// The data payload associated with the response. + /// public required T Payload { get; init; } } +/// +/// Envelope used to wrap API responses that carry only a human-readable message and no data payload. +/// public record ResponseBody { + /// + /// A human-readable message describing the outcome of the request. + /// public required string Message { get; init; } } diff --git a/web/backend/API/API.Core/Controllers/AuthController.cs b/web/backend/API/API.Core/Controllers/AuthController.cs index a6cc9af..245f60f 100644 --- a/web/backend/API/API.Core/Controllers/AuthController.cs +++ b/web/backend/API/API.Core/Controllers/AuthController.cs @@ -7,6 +7,17 @@ using Service.Auth; namespace API.Core.Controllers { + /// + /// Handles user authentication concerns: registration, login, email confirmation, and token refresh. + /// + /// + /// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default, but most + /// actions opt out via [AllowAnonymous] since they are entry points used before a caller holds a token. + /// + /// Service used to register new user accounts. + /// Service used to authenticate existing user accounts. + /// Service used to confirm user accounts and resend confirmation emails. + /// Service used to refresh JWT access/refresh token pairs. [ApiController] [Route("api/[controller]")] [Authorize(AuthenticationSchemes = "JWT")] @@ -17,6 +28,15 @@ namespace API.Core.Controllers ITokenService tokenService ) : ControllerBase { + /// + /// Registers a new user account. + /// + /// + /// Allows anonymous access. On success, responds with 201 Created containing the new user's ID, + /// username, issued refresh/access tokens, and whether a confirmation email was sent. + /// + /// The registration details, including username, name, email, date of birth, and password. + /// A 201 Created result wrapping a of . [AllowAnonymous] [HttpPost("register")] public async Task> Register( @@ -50,6 +70,15 @@ namespace API.Core.Controllers return Created("/", response); } + /// + /// Authenticates a user with a username and password. + /// + /// + /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, + /// username, and a newly issued refresh/access token pair. + /// + /// The login credentials (username and password). + /// An 200 OK result wrapping a of . [AllowAnonymous] [HttpPost("login")] public async Task Login([FromBody] LoginRequest req) @@ -70,6 +99,15 @@ namespace API.Core.Controllers ); } + /// + /// Confirms a user account using a confirmation token. + /// + /// + /// Requires JWT authentication. On success, responds with 200 OK containing the confirmed + /// user's ID and the confirmation timestamp. + /// + /// The confirmation token supplied via the confirmation email link. + /// A 200 OK result wrapping a of . [HttpPost("confirm")] public async Task Confirm([FromQuery] string token) { @@ -86,6 +124,14 @@ namespace API.Core.Controllers ); } + /// + /// Resends the account confirmation email for the specified user. + /// + /// + /// Requires JWT authentication. On success, responds with 200 OK. + /// + /// The unique identifier of the user account to resend the confirmation email for. + /// A 200 OK result wrapping a confirming the email was resent. [HttpPost("confirm/resend")] public async Task ResendConfirmation([FromQuery] Guid userId) { @@ -93,6 +139,15 @@ namespace API.Core.Controllers return Ok(new ResponseBody { Message = "confirmation email has been resent" }); } + /// + /// Exchanges a valid refresh token for a new access/refresh token pair. + /// + /// + /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, + /// username, and the newly issued refresh/access token pair. + /// + /// The request containing the refresh token to exchange. + /// A 200 OK result wrapping a of . [AllowAnonymous] [HttpPost("refresh")] public async Task Refresh( diff --git a/web/backend/API/API.Core/Controllers/BreweryController.cs b/web/backend/API/API.Core/Controllers/BreweryController.cs index d0fb8d4..6403027 100644 --- a/web/backend/API/API.Core/Controllers/BreweryController.cs +++ b/web/backend/API/API.Core/Controllers/BreweryController.cs @@ -1,16 +1,34 @@ using API.Core.Contracts.Breweries; using API.Core.Contracts.Common; +using Domain.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Service.Breweries; namespace API.Core.Controllers; +/// +/// Provides CRUD endpoints for managing brewery posts. +/// +/// +/// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default; read endpoints +/// ( and ) opt out via [AllowAnonymous]. +/// +/// Service used to query and mutate brewery post data. [ApiController] [Route("api/[controller]")] [Authorize(AuthenticationSchemes = "JWT")] public class BreweryController(IBreweryService breweryService) : ControllerBase { + /// + /// Retrieves a single brewery post by its unique identifier. + /// + /// Allows anonymous access. + /// The unique identifier of the brewery post to retrieve. + /// + /// A 200 OK result wrapping a of if found; + /// otherwise a 404 Not Found result wrapping a error message. + /// [AllowAnonymous] [HttpGet("{id:guid}")] public async Task>> GetById(Guid id) @@ -26,6 +44,13 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase }); } + /// + /// Retrieves a paginated list of brewery posts. + /// + /// Allows anonymous access. + /// The maximum number of brewery posts to return, or null for no limit. + /// The number of brewery posts to skip before returning results, or null for no offset. + /// A 200 OK result wrapping a of a collection of . [AllowAnonymous] [HttpGet] public async Task>>> GetAll( @@ -40,6 +65,14 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase }); } + /// + /// Creates a new brewery post. + /// + /// The brewery details to create, including the posting user, name, description, and location. + /// + /// A 201 Created result wrapping a of the newly created + /// if creation succeeds; otherwise a 400 Bad Request result wrapping a error message. + /// [HttpPost] public async Task>> Create([FromBody] BreweryCreateDto dto) { @@ -67,6 +100,16 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase }); } + /// + /// Updates an existing brewery post. + /// + /// The unique identifier of the brewery post from the route, which must match 's ID. + /// The updated brewery details, including the posting user, name, description, and optional location. + /// + /// A 200 OK result wrapping a of the updated brewery (as a ) + /// if the update succeeds; otherwise a 400 Bad Request result wrapping a error message + /// (returned when the route ID does not match the payload ID, or when the update itself fails). + /// [HttpPut("{id:guid}")] public async Task>> Update(Guid id, [FromBody] BreweryDto dto) { @@ -92,13 +135,18 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase if (!result.Success) return BadRequest(new ResponseBody { Message = result.Message }); - return Ok(new ResponseBody + return Ok(new ResponseBody { Message = "Brewery updated successfully.", - Payload = MapToDto(result.Brewery), + Payload = result.Brewery, }); } + /// + /// Deletes a brewery post. + /// + /// The unique identifier of the brewery post to delete. + /// A 200 OK result wrapping a confirming the deletion. [HttpDelete("{id:guid}")] public async Task> Delete(Guid id) { @@ -106,7 +154,13 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase return Ok(new ResponseBody { Message = "Brewery deleted successfully." }); } - private static BreweryDto MapToDto(Domain.Entities.BreweryPost b) => new() + /// + /// Maps a domain entity to its representation, + /// including its location, if present. + /// + /// The brewery post entity to map. + /// The mapped . + private static BreweryDto MapToDto(BreweryPost b) => new() { BreweryPostId = b.BreweryPostId, PostedById = b.PostedById, diff --git a/web/backend/API/API.Core/Controllers/NotFoundController.cs b/web/backend/API/API.Core/Controllers/NotFoundController.cs index df2521c..ca6b1fa 100644 --- a/web/backend/API/API.Core/Controllers/NotFoundController.cs +++ b/web/backend/API/API.Core/Controllers/NotFoundController.cs @@ -2,11 +2,22 @@ using Microsoft.AspNetCore.Mvc; namespace API.Core.Controllers { + /// + /// Handles requests that do not match any other route, used as the application's fallback controller. + /// + /// + /// Excluded from API explorer/Swagger output via [ApiExplorerSettings(IgnoreApi = true)]. + /// Wired up as the fallback target via app.MapFallbackToController("Handle404", "NotFound") in Program.cs. + /// [ApiController] [ApiExplorerSettings(IgnoreApi = true)] [Route("error")] // required public class NotFoundController : ControllerBase { + /// + /// Returns a generic 404 response for any request that did not match a defined route. + /// + /// A 404 Not Found result with a JSON body containing a "Route not found." message. [HttpGet("404")] //required public IActionResult Handle404() { diff --git a/web/backend/API/API.Core/Controllers/ProtectedController.cs b/web/backend/API/API.Core/Controllers/ProtectedController.cs index 6b6d8b7..fb7e4f2 100644 --- a/web/backend/API/API.Core/Controllers/ProtectedController.cs +++ b/web/backend/API/API.Core/Controllers/ProtectedController.cs @@ -5,11 +5,22 @@ using Microsoft.AspNetCore.Mvc; namespace API.Core.Controllers; +/// +/// Sample controller demonstrating an endpoint that requires JWT authentication. +/// [ApiController] [Route("api/[controller]")] [Authorize(AuthenticationSchemes = "JWT")] public class ProtectedController : ControllerBase { + /// + /// Returns the authenticated caller's user ID and username, extracted from the JWT claims. + /// + /// + /// A 200 OK result wrapping a whose payload contains the + /// caller's userId (from ) and username + /// (from ), either of which may be null if not present in the token. + /// [HttpGet] public ActionResult> Get() { diff --git a/web/backend/API/API.Core/Controllers/UserController.cs b/web/backend/API/API.Core/Controllers/UserController.cs index 7559373..972847a 100644 --- a/web/backend/API/API.Core/Controllers/UserController.cs +++ b/web/backend/API/API.Core/Controllers/UserController.cs @@ -4,10 +4,20 @@ using Service.UserManagement.User; namespace API.Core.Controllers { + /// + /// Provides read-only endpoints for retrieving user accounts. + /// + /// Service used to query user account data. [ApiController] [Route("api/[controller]")] public class UserController(IUserService userService) : ControllerBase { + /// + /// Retrieves a paginated list of user accounts. + /// + /// The maximum number of user accounts to return, or null for no limit. + /// The number of user accounts to skip before returning results, or null for no offset. + /// A 200 OK result containing the collection of entities. [HttpGet] public async Task>> GetAll( [FromQuery] int? limit, @@ -18,6 +28,11 @@ namespace API.Core.Controllers return Ok(users); } + /// + /// Retrieves a single user account by its unique identifier. + /// + /// The unique identifier of the user account to retrieve. + /// A 200 OK result containing the matching . [HttpGet("{id:guid}")] public async Task> GetById(Guid id) { diff --git a/web/backend/API/API.Core/GlobalException.cs b/web/backend/API/API.Core/GlobalException.cs index 6115f64..71b01bc 100644 --- a/web/backend/API/API.Core/GlobalException.cs +++ b/web/backend/API/API.Core/GlobalException.cs @@ -9,9 +9,32 @@ using Microsoft.AspNetCore.Mvc.Filters; namespace API.Core; +/// +/// MVC exception filter that converts unhandled exceptions raised by controller actions into +/// consistent JSON error responses with appropriate HTTP status codes. +/// +/// Logger used to record unhandled exceptions before they are translated into a response. public class GlobalExceptionFilter(ILogger logger) : IExceptionFilter { + /// + /// Logs the exception and sets to an appropriate error response, + /// marking the exception as handled. + /// + /// + /// Maps exception types to responses as follows: + /// + /// 400 Bad Request with per-property validation error messages. + /// 409 Conflict with a message. + /// 404 Not Found with a message. + /// 401 Unauthorized with a message. + /// 403 Forbidden with a message. + /// 503 Service Unavailable with a generic database error message. + /// 400 Bad Request with a message. + /// Any other exception → 500 Internal Server Error with a generic error message. + /// + /// + /// The context for the unhandled exception, including the exception itself and the result to populate. public void OnException(ExceptionContext context) { logger.LogError(context.Exception, "Unhandled exception occurred"); diff --git a/web/backend/Database/Database.Migrations/Program.cs b/web/backend/Database/Database.Migrations/Program.cs index af80640..ed48d08 100644 --- a/web/backend/Database/Database.Migrations/Program.cs +++ b/web/backend/Database/Database.Migrations/Program.cs @@ -5,8 +5,27 @@ using Microsoft.Data.SqlClient; namespace Database.Migrations; +/// +/// Entry point for the database migration runner. Reads connection details from +/// environment variables, optionally clears and recreates the target database, and +/// applies all SQL migration scripts embedded in this assembly using DbUp. +/// public static class Program { + /// + /// Builds a SQL Server connection string from the DB_SERVER, DB_NAME, + /// DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE + /// environment variables. + /// + /// + /// The database (initial catalog) to connect to. When null, falls back to the + /// DB_NAME environment variable. + /// + /// A fully built SQL Server connection string. + /// + /// Thrown when DB_SERVER, DB_USER, DB_PASSWORD, or (when + /// is null) DB_NAME is not set. + /// private static string BuildConnectionString(string? databaseName = null) { var server = Environment.GetEnvironmentVariable("DB_SERVER") @@ -38,9 +57,17 @@ public static class Program return builder.ConnectionString; } + /// The connection string for the target application database (DB_NAME). private static readonly string connectionString = BuildConnectionString(); + + /// The connection string for the master database, used for create/drop operations. private static readonly string masterConnectionString = BuildConnectionString("master"); + /// + /// Applies all pending SQL migration scripts embedded in this assembly to the target + /// database using DbUp, logging progress to the console. + /// + /// true if the upgrade completed successfully; otherwise false. private static bool DeployMigrations() { var upgrader = DeployChanges @@ -53,6 +80,14 @@ public static class Program return result.Successful; } + /// + /// Drops the Biergarten database if it exists, first forcing it into single-user + /// mode with rollback to terminate any existing connections. + /// + /// + /// true if the database was dropped (or did not exist) without error; false + /// if an error occurred while connecting or dropping the database. + /// private static bool ClearDatabase() { var myConn = new SqlConnection(masterConnectionString); @@ -104,6 +139,12 @@ public static class Program return true; } + /// + /// Creates the Biergarten database on the master connection if it does not + /// already exist. Errors encountered while creating the database are logged but do + /// not stop execution. + /// + /// true always; this method does not propagate database errors as a failure result. private static bool CreateDatabaseIfNotExists() { var myConn = new SqlConnection(masterConnectionString); @@ -134,6 +175,13 @@ public static class Program return true; } + /// + /// Migration runner entry point. Optionally clears the existing database when the + /// CLEAR_DATABASE environment variable is set to "true", ensures the + /// database exists, then deploys all pending migrations. + /// + /// Command-line arguments (unused). + /// 0 if migrations completed successfully; 1 if they failed or an error occurred. public static int Main(string[] args) { Console.WriteLine("Starting database migrations..."); diff --git a/web/backend/Database/Database.Seed/ISeeder.cs b/web/backend/Database/Database.Seed/ISeeder.cs index bd0d18d..07918bd 100644 --- a/web/backend/Database/Database.Seed/ISeeder.cs +++ b/web/backend/Database/Database.Seed/ISeeder.cs @@ -2,7 +2,18 @@ using Microsoft.Data.SqlClient; namespace Database.Seed; +/// +/// Defines a unit of seed data that can be applied to the database. Implementations +/// should be safe to run against an already-seeded database (e.g. by checking for +/// existing data before inserting) and may depend on data created by seeders that run +/// before them. +/// internal interface ISeeder { + /// + /// Inserts this seeder's data into the database using the supplied open connection. + /// + /// An open connection to the target database. + /// A task that completes when seeding is finished. Task SeedAsync(SqlConnection connection); } \ No newline at end of file diff --git a/web/backend/Database/Database.Seed/LocationSeeder.cs b/web/backend/Database/Database.Seed/LocationSeeder.cs index c08daa2..61df53a 100644 --- a/web/backend/Database/Database.Seed/LocationSeeder.cs +++ b/web/backend/Database/Database.Seed/LocationSeeder.cs @@ -3,8 +3,18 @@ using Microsoft.Data.SqlClient; namespace Database.Seed; +/// +/// Seeds the location hierarchy (countries, states/provinces, and cities) used to +/// associate other entities (e.g. breweries, users) with a geographic location. +/// Countries must be seeded before states/provinces, which must be seeded before +/// cities, since each level references its parent by code. The underlying stored +/// procedures (USP_CreateCountry, USP_CreateStateProvince, +/// USP_CreateCity) are expected to be idempotent, so re-running this seeder +/// against an already-seeded database is safe. +/// internal class LocationSeeder : ISeeder { + /// The set of countries to seed, identified by name and ISO 3166-1 code. private static readonly IReadOnlyList<( string CountryName, string CountryCode @@ -15,6 +25,10 @@ internal class LocationSeeder : ISeeder ("United States", "US"), ]; + /// + /// The set of states/provinces to seed, each identified by name, ISO 3166-2 code, + /// and the ISO 3166-1 code of its parent country. + /// private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States { get; @@ -123,6 +137,10 @@ internal class LocationSeeder : ISeeder ("Ciudad de México", "MX-CMX", "MX"), ]; + /// + /// The set of cities to seed, each identified by name and the ISO 3166-2 code of its + /// parent state/province. + /// private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } = [ ("US-CA", "Los Angeles"), @@ -240,6 +258,12 @@ internal class LocationSeeder : ISeeder ("MX-ZAC", "Zacatecas"), ]; + /// + /// Seeds all countries, then states/provinces, then cities, in that order, so that + /// each level's parent reference already exists by the time it is created. + /// + /// An open connection to the target database. + /// A task that completes when all locations have been seeded. public async Task SeedAsync(SqlConnection connection) { foreach (var (countryName, countryCode) in Countries) @@ -265,6 +289,11 @@ internal class LocationSeeder : ISeeder } } + /// Creates a single country by invoking the dbo.USP_CreateCountry stored procedure. + /// An open connection to the target database. + /// The display name of the country. + /// The ISO 3166-1 code of the country. + /// A task that completes when the country has been created. private static async Task CreateCountryAsync( SqlConnection connection, string countryName, @@ -282,6 +311,12 @@ internal class LocationSeeder : ISeeder await command.ExecuteNonQueryAsync(); } + /// Creates a single state/province by invoking the dbo.USP_CreateStateProvince stored procedure. + /// An open connection to the target database. + /// The display name of the state/province. + /// The ISO 3166-2 code of the state/province. + /// The ISO 3166-1 code of the parent country, which must already exist. + /// A task that completes when the state/province has been created. private static async Task CreateStateProvinceAsync( SqlConnection connection, string stateProvinceName, @@ -304,6 +339,11 @@ internal class LocationSeeder : ISeeder await command.ExecuteNonQueryAsync(); } + /// Creates a single city by invoking the dbo.USP_CreateCity stored procedure. + /// An open connection to the target database. + /// The display name of the city. + /// The ISO 3166-2 code of the parent state/province, which must already exist. + /// A task that completes when the city has been created. private static async Task CreateCityAsync( SqlConnection connection, string cityName, diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index c7d064d..cdb4652 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -3,6 +3,20 @@ using DbUp; using System.Reflection; using Database.Seed; +// Entry point for the database seeding utility. Connects to the target database +// (retrying on transient failures), then runs each registered ISeeder in order to +// populate location and user data. + +/// +/// Builds a SQL Server connection string from the DB_SERVER, DB_NAME, +/// DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE +/// environment variables. +/// +/// A fully built SQL Server connection string. +/// +/// Thrown when DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD +/// is not set. +/// string BuildConnectionString() { var server = Environment.GetEnvironmentVariable("DB_SERVER") diff --git a/web/backend/Database/Database.Seed/UserSeeder.cs b/web/backend/Database/Database.Seed/UserSeeder.cs index d47fe65..28d4efe 100644 --- a/web/backend/Database/Database.Seed/UserSeeder.cs +++ b/web/backend/Database/Database.Seed/UserSeeder.cs @@ -7,8 +7,18 @@ using Microsoft.Data.SqlClient; namespace Database.Seed; +/// +/// Seeds user accounts, credentials, and verification records. Creates one fixed +/// "Test User" account (test.user@thebiergarten.app / password "password") +/// for testing, followed by a randomly-generated account for each name in +/// , each with a random password and date of birth. Skips +/// adding a verification record for a user that already has one, so this seeder is +/// safe to re-run, though re-running will still attempt to register (and may fail on) +/// users that already exist. +/// internal class UserSeeder : ISeeder { + /// The first/last name pairs used to generate seed user accounts. private static readonly IReadOnlyList<( string FirstName, string LastName @@ -116,6 +126,14 @@ internal class UserSeeder : ISeeder ("Zoie", "Armstrong"), ]; + /// + /// Registers a fixed test user account followed by one randomly-generated account + /// per entry in , adding a user verification record for each + /// newly created account that does not already have one. Progress counts are + /// written to the console on completion. + /// + /// An open connection to the target database. + /// A task that completes when all users have been seeded. public async Task SeedAsync(SqlConnection connection) { var generator = new PasswordGenerator(); @@ -184,6 +202,18 @@ internal class UserSeeder : ISeeder Console.WriteLine($"Added {createdVerifications} user verifications."); } + /// + /// Registers a new user account and its credential by invoking the + /// dbo.USP_RegisterUser stored procedure. + /// + /// An open connection to the target database. + /// The unique username for the account. + /// The user's first name. + /// The user's last name. + /// The user's date of birth. + /// The user's email address. + /// The salted password hash, as produced by . + /// The newly created user account's identifier. private static async Task RegisterUserAsync( SqlConnection connection, string username, @@ -211,6 +241,13 @@ internal class UserSeeder : ISeeder return (Guid)result!; } + /// + /// Hashes a plaintext password using Argon2id with a randomly generated 16-byte + /// salt, a degree of parallelism matching the available processor count, 64 MB of + /// memory, and 4 iterations. + /// + /// The plaintext password to hash. + /// A string containing the base64-encoded salt and hash, separated by a colon. private static string GeneratePasswordHash(string pwd) { byte[] salt = RandomNumberGenerator.GetBytes(16); @@ -227,6 +264,10 @@ internal class UserSeeder : ISeeder return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; } + /// Checks whether a user verification record already exists for the given user account. + /// An open connection to the target database. + /// The user account identifier to check. + /// true if a verification record already exists; otherwise false. private static async Task HasUserVerificationAsync( SqlConnection connection, Guid userAccountId @@ -243,6 +284,10 @@ internal class UserSeeder : ISeeder return result is not null; } + /// Creates a user verification record by invoking the dbo.USP_CreateUserVerification stored procedure. + /// An open connection to the target database. + /// The identifier of the user account to verify. + /// A task that completes when the verification record has been created. private static async Task AddUserVerificationAsync( SqlConnection connection, Guid userAccountId @@ -258,6 +303,12 @@ internal class UserSeeder : ISeeder await command.ExecuteNonQueryAsync(); } + /// + /// Generates a random date of birth corresponding to an age between 19 and 48 + /// years (inclusive), with a random day offset within that birth year. + /// + /// The random number source to use. + /// A randomly generated date of birth. private static DateTime GenerateDateOfBirth(Random random) { int age = 19 + random.Next(0, 30); diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs index 07a7b78..36d5299 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -1,13 +1,48 @@ namespace Domain.Entities; +/// +/// Represents a user-submitted post about a brewery. Maps to the BreweryPost table. +/// A user account cannot be deleted while it has an associated brewery post. +/// public class BreweryPost { + /// + /// Primary key identifying this brewery post. Maps to BreweryPostID. + /// public Guid BreweryPostId { get; set; } + + /// + /// Foreign key referencing the that authored this post. Maps to PostedByID. + /// public Guid PostedById { get; set; } + + /// + /// The name of the brewery being posted about. + /// public string BreweryName { get; set; } = string.Empty; + + /// + /// Free-text description of the brewery. + /// public string Description { get; set; } = string.Empty; + + /// + /// The date and time the post was created. + /// public DateTime CreatedAt { get; set; } + + /// + /// The date and time the post was last updated, or null if it has never been updated. + /// public DateTime? UpdatedAt { get; set; } + + /// + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// public byte[]? Timer { get; set; } + + /// + /// The associated for this post, if one has been set. This is a one-to-one navigation property. + /// public BreweryPostLocation? Location { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs index 460e30a..8da3311 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -1,13 +1,48 @@ namespace Domain.Entities; +/// +/// Represents the physical location of a . Maps to the BreweryPostLocation table. +/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is deleted. +/// public class BreweryPostLocation { + /// + /// Primary key identifying this location. Maps to BreweryPostLocationID. + /// public Guid BreweryPostLocationId { get; set; } + + /// + /// Foreign key referencing the owning . Maps to BreweryPostID; unique, enforcing a one-to-one relationship. + /// public Guid BreweryPostId { get; set; } + + /// + /// The primary street address line. + /// public string AddressLine1 { get; set; } = string.Empty; + + /// + /// An optional secondary address line (e.g., suite or unit number). + /// public string? AddressLine2 { get; set; } + + /// + /// The postal/ZIP code of the location. + /// public string PostalCode { get; set; } = string.Empty; + + /// + /// Foreign key referencing the city in which the brewery is located. Maps to CityID. + /// public Guid CityId { get; set; } + + /// + /// Serialized SQL Server GEOGRAPHY value representing the geographic coordinates of the location, or null if not set. + /// public byte[]? Coordinates { get; set; } + + /// + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// public byte[]? Timer { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs index bda19c7..4c63f58 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs @@ -1,14 +1,53 @@ namespace Domain.Entities; +/// +/// Represents a registered user of the application. Maps to the UserAccount table, the root entity +/// referenced by , , brewery posts, and other user-owned data. +/// public class UserAccount { + /// + /// Primary key identifying this user account. Maps to UserAccountID. + /// public Guid UserAccountId { get; set; } + + /// + /// The user's unique username, used for login and display. Enforced unique at the database level. + /// public string Username { get; set; } = string.Empty; + + /// + /// The user's first name. + /// public string FirstName { get; set; } = string.Empty; + + /// + /// The user's last name. + /// public string LastName { get; set; } = string.Empty; + + /// + /// The user's email address. Enforced unique at the database level. + /// public string Email { get; set; } = string.Empty; + + /// + /// The date and time the account was created. + /// public DateTime CreatedAt { get; set; } + + /// + /// The date and time the account was last updated, or null if it has never been updated. + /// public DateTime? UpdatedAt { get; set; } + + /// + /// The user's date of birth. + /// public DateTime DateOfBirth { get; set; } + + /// + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// public byte[]? Timer { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs index 65b46a1..9f1b337 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs @@ -1,11 +1,38 @@ namespace Domain.Entities; +/// +/// Represents an issued authentication credential (refresh token) for a . +/// Maps to the UserCredential table. Credentials are deleted automatically when the owning user account is deleted. +/// public class UserCredential { + /// + /// Primary key identifying this credential. Maps to UserCredentialID. + /// public Guid UserCredentialId { get; set; } + + /// + /// Foreign key referencing the owning . Maps to UserAccountID. + /// public Guid UserAccountId { get; set; } + + /// + /// The date and time the credential was issued. + /// public DateTime CreatedAt { get; set; } + + /// + /// The date and time after which the credential is no longer valid. + /// public DateTime Expiry { get; set; } + + /// + /// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted. + /// public string Hash { get; set; } = string.Empty; + + /// + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// public byte[]? Timer { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs index 3e66754..132b773 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs @@ -1,9 +1,29 @@ namespace Domain.Entities; +/// +/// Records that a has completed verification (e.g., email confirmation). +/// Maps to the UserVerification table. A user account has at most one verification record, +/// and it is deleted automatically when the owning user account is deleted. +/// public class UserVerification { + /// + /// Primary key identifying this verification record. Maps to UserVerificationID. + /// public Guid UserVerificationId { get; set; } + + /// + /// Foreign key referencing the verified . Maps to UserAccountID; unique, enforcing a one-to-one relationship. + /// public Guid UserAccountId { get; set; } + + /// + /// The date and time at which the user account was verified. + /// public DateTime VerificationDateTime { get; set; } + + /// + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// public byte[]? Timer { get; set; } } diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs index e8203e4..821df27 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs @@ -8,6 +8,8 @@ namespace Infrastructure.Email.Templates.Rendering; /// /// Service for rendering Razor email templates to HTML using HtmlRenderer. /// +/// The service provider used to resolve dependencies for the Razor component rendering pipeline. +/// The logger factory passed to the used to render components. public class EmailTemplateProvider( IServiceProvider serviceProvider, ILoggerFactory loggerFactory @@ -16,6 +18,9 @@ public class EmailTemplateProvider( /// /// Renders the UserRegisteredEmail template with the specified parameters. /// + /// The username to include in the email + /// The email confirmation link + /// The rendered HTML string public async Task RenderUserRegisteredEmailAsync( string username, string confirmationLink @@ -33,6 +38,9 @@ public class EmailTemplateProvider( /// /// Renders the ResendConfirmation template with the specified parameters. /// + /// The username to include in the email + /// The new confirmation link + /// The rendered HTML string public async Task RenderResendConfirmationEmailAsync( string username, string confirmationLink @@ -49,7 +57,12 @@ public class EmailTemplateProvider( /// /// Generic method to render any Razor component to HTML. + /// Creates a scoped , dispatches the render onto its renderer thread, + /// and returns the resulting HTML string. /// + /// The type of the Razor component to render. + /// A dictionary of parameter names and values to pass to the component. + /// The rendered HTML string for the component. private async Task RenderComponentAsync( Dictionary parameters ) diff --git a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs index 45c7103..4b5aa94 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs @@ -18,6 +18,25 @@ public class SmtpEmailProvider : IEmailProvider private readonly string _fromEmail; private readonly string _fromName; + /// + /// Initializes a new instance of , reading SMTP configuration + /// from environment variables. + /// + /// + /// Reads the following environment variables: + /// + /// SMTP_HOST (required) - the SMTP server hostname. + /// SMTP_PORT (optional, default "587") - the SMTP server port, must be a valid integer. + /// SMTP_USERNAME (optional) - the username used for authentication. + /// SMTP_PASSWORD (optional) - the password used for authentication. + /// SMTP_USE_SSL (optional, default "true") - whether to use StartTls when connecting. + /// SMTP_FROM_EMAIL (required) - the email address used as the sender. + /// SMTP_FROM_NAME (optional, default "The Biergarten") - the display name used as the sender. + /// + /// + /// + /// Thrown when SMTP_HOST or SMTP_FROM_EMAIL is not set, or when SMTP_PORT is not a valid integer. + /// public SmtpEmailProvider() { _host = @@ -53,6 +72,14 @@ public class SmtpEmailProvider : IEmailProvider ?? "The Biergarten"; } + /// + /// Sends an email to a single recipient by delegating to the multi-recipient overload. + /// + /// Recipient email address + /// Email subject line + /// Email body (HTML or plain text) + /// Whether the body is HTML (default: true) + /// Thrown when connecting, authenticating, or sending via SMTP fails. public async Task SendAsync( string to, string subject, @@ -63,6 +90,16 @@ public class SmtpEmailProvider : IEmailProvider await SendAsync([to], subject, body, isHtml); } + /// + /// Sends an email to multiple recipients using MailKit's . + /// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates + /// if credentials were configured, sends the message, and disconnects. + /// + /// List of recipient email addresses + /// Email subject line + /// Email body (HTML or plain text) + /// Whether the body is HTML (default: true) + /// Thrown when connecting, authenticating, or sending via SMTP fails. The original exception is included as the inner exception. public async Task SendAsync( IEnumerable to, string subject, diff --git a/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs index 2535c2b..fca9d97 100644 --- a/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs @@ -2,8 +2,19 @@ using System.Security.Claims; namespace Infrastructure.Jwt; +/// +/// Service for generating and validating JSON Web Tokens (JWTs) used for authentication. +/// public interface ITokenInfrastructure { + /// + /// Generates a signed JWT for the given user. + /// + /// The unique identifier of the user the token is issued for. + /// The username of the user, included as a claim. + /// The date and time at which the token expires. + /// The symmetric secret used to sign the token. + /// The serialized, signed JWT string. string GenerateJwt( Guid userId, string username, @@ -11,5 +22,12 @@ public interface ITokenInfrastructure string secret ); + /// + /// Validates a JWT and returns the resulting claims principal. + /// + /// The JWT string to validate. + /// The symmetric secret used to verify the token's signature. + /// A representing the validated token's claims. + /// Thrown when the token is invalid, expired, or fails validation. Task ValidateJwtAsync(string token, string secret); } \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs index 2616d8f..3a6a75f 100644 --- a/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs @@ -7,8 +7,24 @@ using Domain.Exceptions; namespace Infrastructure.Jwt; +/// +/// Generates and validates HMAC-SHA256 signed JWTs using . +/// public class JwtInfrastructure : ITokenInfrastructure { + /// + /// Generates a signed JWT containing the user's ID, username, issued-at time, expiry time, + /// and a unique token identifier (JTI), signed using HMAC-SHA256 with the provided secret. + /// + /// + /// Sets the following registered claims: sub (userId), unique_name (username), + /// iat (current UTC time), exp (expiry), and jti (a newly generated GUID). + /// + /// The unique identifier of the user the token is issued for. + /// The username of the user, included as a claim. + /// The date and time at which the token expires. + /// The symmetric secret used to sign the token (encoded as UTF-8 bytes). + /// The serialized, signed JWT string. public string GenerateJwt( Guid userId, string username, @@ -47,6 +63,17 @@ public class JwtInfrastructure : ITokenInfrastructure } + /// + /// Validates a JWT's signature and lifetime (issuer and audience validation are disabled), + /// using the provided secret as the HMAC-SHA256 symmetric signing key. + /// + /// The JWT string to validate. + /// The symmetric secret used to verify the token's signature (encoded as UTF-8 bytes). + /// A wrapping the validated token's claims identity. + /// + /// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation + /// (including when validation itself throws, e.g. due to a malformed token or signature mismatch). + /// public async Task ValidateJwtAsync( string token, string secret diff --git a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs index 0364900..f65632a 100644 --- a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs @@ -4,6 +4,9 @@ using Konscious.Security.Cryptography; namespace Infrastructure.PasswordHashing; +/// +/// Hashes and verifies passwords using the Argon2id algorithm via Konscious.Security.Cryptography. +/// public class Argon2Infrastructure : IPasswordInfrastructure { private const int SaltSize = 16; // 128-bit @@ -11,6 +14,16 @@ public class Argon2Infrastructure : IPasswordInfrastructure private const int ArgonIterations = 4; private const int ArgonMemoryKb = 65536; // 64MB + /// + /// Hashes a plaintext password using Argon2id with a newly generated 128-bit cryptographically + /// random salt, 4 iterations, 64MB of memory, and a degree of parallelism equal to the number of + /// available processors (minimum 1). + /// + /// The plaintext password to hash. + /// + /// A string of the form "{base64Salt}:{base64Hash}" containing the salt and the resulting + /// 256-bit hash, suitable for storage and later verification. + /// public string Hash(string password) { var salt = RandomNumberGenerator.GetBytes(SaltSize); @@ -26,6 +39,20 @@ public class Argon2Infrastructure : IPasswordInfrastructure return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; } + /// + /// Verifies a plaintext password against a stored salt/hash string by recomputing the Argon2id + /// hash with the extracted salt and comparing it to the stored hash using a fixed-time comparison + /// to mitigate timing attacks. + /// + /// The plaintext password to verify. + /// + /// The stored string of the form "{base64Salt}:{base64Hash}" previously produced by . + /// + /// + /// true if the password matches the stored hash; false if it does not match, the stored + /// string is malformed (e.g. not in the expected two-part format, or not valid base64), or any other + /// error occurs while verifying. + /// public bool Verify(string password, string stored) { try diff --git a/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs index 593ed73..7b26103 100644 --- a/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs @@ -1,7 +1,22 @@ namespace Infrastructure.PasswordHashing; +/// +/// Service for hashing and verifying user passwords. +/// public interface IPasswordInfrastructure { + /// + /// Hashes a plaintext password, generating a new random salt. + /// + /// The plaintext password to hash. + /// A string encoding both the salt and the resulting hash, suitable for storage. public string Hash(string password); + + /// + /// Verifies a plaintext password against a previously stored hash. + /// + /// The plaintext password to verify. + /// The stored salt/hash string previously produced by . + /// true if the password matches the stored hash; otherwise false. public bool Verify(string password, string stored); } diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index bb701b3..a993162 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -6,10 +6,34 @@ using Microsoft.Data.SqlClient; namespace Infrastructure.Repository.Auth; +/// +/// ADO.NET-based implementation of backed by SQL Server stored procedures, +/// handling user registration, credential lookup/rotation, and account verification. +/// +/// The factory used to create database connections. public class AuthRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IAuthRepository { + /// + /// Registers a new user account and initial credential using the USP_RegisterUser stored + /// procedure, then fetches and returns the newly created user. + /// + /// + /// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively: + /// it may be returned as a , a parseable , or a 16-byte array. + /// If the result cannot be interpreted, is used, which will cause the + /// subsequent lookup to fail. + /// + /// Unique username for the user + /// User's first name + /// User's last name + /// User's email address + /// User's date of birth + /// Hashed password + /// The newly created UserAccount with generated ID + /// Thrown when the newly registered user cannot be retrieved after registration. + /// Thrown when the database command fails. public async Task RegisterUserAsync( string username, string firstName, @@ -68,6 +92,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user."); } + /// + /// Retrieves a user account by email address (typically used for login) using the + /// usp_GetUserAccountByEmail stored procedure. + /// + /// Email address to search for + /// UserAccount if found, null otherwise + /// Thrown when the database command fails. public async Task GetUserByEmailAsync( string email ) @@ -83,6 +114,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToEntity(reader) : null; } + /// + /// Retrieves a user account by username (typically used for login) using the + /// usp_GetUserAccountByUsername stored procedure. + /// + /// Username to search for + /// UserAccount if found, null otherwise + /// Thrown when the database command fails. public async Task GetUserByUsernameAsync( string username ) @@ -98,6 +136,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToEntity(reader) : null; } + /// + /// Retrieves the active (non-revoked) credential for a user account using the + /// USP_GetActiveUserCredentialByUserAccountId stored procedure. + /// + /// ID of the user account + /// Active UserCredential if found, null otherwise + /// Thrown when the database command fails. public async Task GetActiveCredentialByUserAccountIdAsync( Guid userAccountId ) @@ -113,6 +158,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null; } + /// + /// Rotates a user's credential by invalidating all existing credentials and creating a new one, + /// using the USP_RotateUserCredential stored procedure. + /// + /// ID of the user account + /// New hashed password + /// Thrown when the database command fails. public async Task RotateCredentialAsync( Guid userAccountId, string newPasswordHash @@ -129,6 +181,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) await command.ExecuteNonQueryAsync(); } + /// + /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. + /// + /// ID of the user account + /// UserAccount if found, null otherwise + /// Thrown when the database command fails. public async Task GetUserByIdAsync( Guid userAccountId ) @@ -144,6 +202,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToEntity(reader) : null; } + /// + /// Marks a user account as confirmed by creating a verification record via the + /// USP_CreateUserVerification stored procedure. If the user is already verified, this is a + /// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user + /// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed. + /// + /// ID of the user account to confirm + /// The confirmed , or null if the user account does not exist. + /// Thrown when the database command fails for a reason other than a duplicate verification record. public async Task ConfirmUserAccountAsync( Guid userAccountId ) @@ -180,6 +247,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await GetUserByIdAsync(userAccountId); } + /// + /// Checks whether a user account has been verified by querying the + /// dbo.UserVerification table for a matching record. + /// + /// ID of the user account + /// True if the user has a verification record, false otherwise + /// Thrown when the database command fails. public async Task IsUserVerifiedAsync(Guid userAccountId) { await using var connection = await CreateConnection(); @@ -194,6 +268,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return result != null && result != DBNull.Value; } + /// + /// Determines whether a represents a duplicate key violation + /// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert. + /// + /// The SQL exception to inspect. + /// True if the exception represents a duplicate key violation, false otherwise. private static bool IsDuplicateVerificationViolation(SqlException ex) { // 2601/2627 are duplicate key violations in SQL Server. @@ -204,6 +284,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) /// /// Maps a data reader row to a UserAccount entity. /// + /// The data reader positioned on the row to map. + /// The mapped instance. protected override Domain.Entities.UserAccount MapToEntity( DbDataReader reader ) @@ -227,8 +309,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) } /// - /// Maps a data reader row to a UserCredential entity. + /// Maps a data reader row to a UserCredential entity. The Timer column is mapped only if + /// present in the reader's schema, allowing this method to support result sets that omit it. /// + /// The data reader positioned on the row to map. + /// The mapped instance. private static UserCredential MapToCredentialEntity(DbDataReader reader) { var entity = new UserCredential @@ -265,8 +350,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) } /// - /// Helper method to add a parameter to a database command. + /// Helper method to add a parameter to a database command, converting null values to + /// . /// + /// The command to add the parameter to. + /// The parameter name (including any prefix, e.g. "@Username"). + /// The parameter value, or null to bind . private static void AddParameter( DbCommand command, string name, diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs index 4648c4b..27ad3f8 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs @@ -65,8 +65,7 @@ public interface IAuthRepository /// Marks a user account as confirmed. /// /// ID of the user account to confirm - /// The confirmed UserAccount entity - /// If user account not found + /// The confirmed UserAccount entity, or null if the user account does not exist Task ConfirmUserAccountAsync(Guid userAccountId); /// diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs index 08332c4..2a5af19 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs @@ -4,11 +4,22 @@ using Infrastructure.Repository.Sql; namespace Infrastructure.Repository.Breweries; +/// +/// ADO.NET-based implementation of backed by SQL Server stored +/// procedures. +/// +/// The factory used to create database connections. public class BreweryRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IBreweryRepository { private readonly ISqlConnectionFactory _connectionFactory = connectionFactory; + /// + /// Retrieves a brewery post by ID using the USP_GetBreweryById stored procedure. + /// + /// The unique identifier of the brewery post. + /// The matching , or null if not found. + /// Thrown when the database command fails. public async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); @@ -26,21 +37,44 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) return null; } + /// + /// Not yet implemented. + /// + /// The maximum number of records to return, or null for no limit. + /// The number of records to skip, or null for no offset. + /// Never returns; always throws. + /// Always thrown. public Task> GetAllAsync(int? limit, int? offset) { throw new NotImplementedException(); } + /// + /// Not yet implemented. + /// + /// The brewery post containing updated values. + /// Always thrown. public Task UpdateAsync(BreweryPost brewery) { throw new NotImplementedException(); } + /// + /// Not yet implemented. + /// + /// The unique identifier of the brewery post to delete. + /// Always thrown. public Task DeleteAsync(Guid id) { throw new NotImplementedException(); } + /// + /// Creates a new brewery post and its location using the USP_CreateBrewery stored procedure. + /// + /// The brewery post to create. Must have a non-null Location. + /// Thrown when .Location is null. + /// Thrown when the database command fails. public async Task CreateAsync(BreweryPost brewery) { await using var connection = await CreateConnection(); @@ -66,6 +100,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) } + /// + /// Maps the current row of a data reader to a entity, including its + /// rowversion Timer field and, if location columns are present in the result set, its + /// associated . + /// + /// The data reader positioned on the row to map. + /// The mapped instance. protected override BreweryPost MapToEntity(DbDataReader reader) { var brewery = new BreweryPost(); @@ -133,6 +174,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) return brewery; } + /// + /// Helper method to add a parameter to a database command, converting null values to + /// . + /// + /// The command to add the parameter to. + /// The parameter name (including any prefix, e.g. "@BreweryName"). + /// The parameter value, or null to bind . private static void AddParameter( DbCommand command, string name, diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs index 2d394cb..9598bfe 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs @@ -2,11 +2,42 @@ using Domain.Entities; namespace Infrastructure.Repository.Breweries; +/// +/// Repository for CRUD operations on brewery post records. +/// public interface IBreweryRepository { + /// + /// Retrieves a brewery post by its unique identifier. + /// + /// The unique identifier of the brewery post. + /// The matching , or null if not found. Task GetByIdAsync(Guid id); + + /// + /// Retrieves all brewery posts, optionally paginated. + /// + /// The maximum number of records to return, or null for no limit. + /// The number of records to skip, or null for no offset. + /// The collection of matching records. Task> GetAllAsync(int? limit, int? offset); + + /// + /// Updates an existing brewery post. + /// + /// The brewery post containing updated values. Task UpdateAsync(BreweryPost brewery); + + /// + /// Deletes a brewery post by its unique identifier. + /// + /// The unique identifier of the brewery post to delete. Task DeleteAsync(Guid id); + + /// + /// Creates a new brewery post, including its location details. + /// + /// The brewery post to create. Must have a non-null Location. + /// Thrown when has no Location. Task CreateAsync(BreweryPost brewery); } \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Repository.cs b/web/backend/Infrastructure/Infrastructure.Repository/Repository.cs index 2419f56..e4eba31 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Repository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Repository.cs @@ -3,9 +3,20 @@ using Infrastructure.Repository.Sql; namespace Infrastructure.Repository; +/// +/// Base class for ADO.NET-based repositories, providing shared connection creation and +/// entity-mapping infrastructure for derived repository implementations. +/// +/// The entity type managed by the repository. +/// The factory used to create database connections. public abstract class Repository(ISqlConnectionFactory connectionFactory) where T : class { + /// + /// Creates and opens a new database connection using the configured . + /// + /// An open ready for use. + /// Thrown when the connection cannot be opened. protected async Task CreateConnection() { var connection = connectionFactory.CreateConnection(); @@ -13,5 +24,10 @@ public abstract class Repository(ISqlConnectionFactory connectionFactory) return connection; } + /// + /// Maps the current row of a data reader to an instance of . + /// + /// The data reader positioned on the row to map. + /// The mapped entity instance. protected abstract T MapToEntity(DbDataReader reader); } diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs index dcd3652..f802e50 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs @@ -4,6 +4,11 @@ using Microsoft.Extensions.Configuration; namespace Infrastructure.Repository.Sql; +/// +/// Default implementation that creates SQL Server connections, +/// resolving the connection string from environment variables or application configuration. +/// +/// The application configuration, used as a fallback source for the connection string. public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory { @@ -11,6 +16,17 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) configuration ); + /// + /// Resolves the SQL Server connection string, preferring (in order): the DB_CONNECTION_STRING + /// environment variable, a connection string built from individual DB_* environment variables + /// via , and finally the "Default" + /// connection string from . + /// + /// The application configuration to fall back to. + /// The resolved SQL Server connection string. + /// + /// Thrown when no connection string can be resolved from any of the supported sources. + /// private static string GetConnectionString(IConfiguration configuration) { // Check for full connection string first @@ -42,6 +58,10 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) } } + /// + /// Creates a new, unopened using the resolved connection string. + /// + /// A new instance. public DbConnection CreateConnection() { return new SqlConnection(_connectionString); diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs index 2a7ac18..1799305 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs @@ -2,7 +2,14 @@ using System.Data.Common; namespace Infrastructure.Repository.Sql; +/// +/// Factory for creating database connections used by repositories. +/// public interface ISqlConnectionFactory { + /// + /// Creates a new, unopened database connection. + /// + /// A new instance. DbConnection CreateConnection(); } diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs b/web/backend/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs index cab4e4e..c8b304d 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs @@ -2,6 +2,9 @@ using Microsoft.Data.SqlClient; namespace Infrastructure.Repository.Sql; +/// +/// Helper for building SQL Server connection strings from environment variables. +/// public static class SqlConnectionStringHelper { /// diff --git a/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs index 2a48851..4a57ad7 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs @@ -1,14 +1,51 @@ namespace Infrastructure.Repository.UserAccount; +/// +/// Repository for CRUD operations on user account records. +/// public interface IUserAccountRepository { + /// + /// Retrieves a user account by its unique identifier. + /// + /// The unique identifier of the user account. + /// The matching , or null if not found. Task GetByIdAsync(Guid id); + + /// + /// Retrieves all user accounts, optionally paginated. + /// + /// The maximum number of records to return, or null for no limit. + /// The number of records to skip, or null for no offset. + /// The collection of matching records. Task> GetAllAsync( int? limit, int? offset ); + + /// + /// Updates an existing user account's details. + /// + /// The user account containing updated values. Must have a valid UserAccountId. Task UpdateAsync(Domain.Entities.UserAccount userAccount); + + /// + /// Deletes a user account by its unique identifier. + /// + /// The unique identifier of the user account to delete. Task DeleteAsync(Guid id); + + /// + /// Retrieves a user account by username. + /// + /// The username to search for. + /// The matching , or null if not found. Task GetByUsernameAsync(string username); + + /// + /// Retrieves a user account by email address. + /// + /// The email address to search for. + /// The matching , or null if not found. Task GetByEmailAsync(string email); } diff --git a/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs index 8de64ba..0bb76cc 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs @@ -4,10 +4,21 @@ using Infrastructure.Repository.Sql; namespace Infrastructure.Repository.UserAccount; +/// +/// ADO.NET-based implementation of backed by SQL Server +/// stored procedures. +/// +/// The factory used to create database connections. public class UserAccountRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IUserAccountRepository { + /// + /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. + /// + /// The unique identifier of the user account. + /// The matching , or null if not found. + /// Thrown when the database command fails. public async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); @@ -21,6 +32,15 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToEntity(reader) : null; } + /// + /// Retrieves all user accounts, optionally paginated, using the usp_GetAllUserAccounts + /// stored procedure. The @Limit and @Offset parameters are only added when their + /// corresponding argument has a value. + /// + /// The maximum number of records to return, or null for no limit. + /// The number of records to skip, or null for no offset. + /// The collection of matching records. + /// Thrown when the database command fails. public async Task> GetAllAsync( int? limit, int? offset @@ -48,6 +68,12 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) return users; } + /// + /// Updates a user account's username, first name, last name, email, and date of birth using + /// the usp_UpdateUserAccount stored procedure. + /// + /// The user account containing updated values. Must have a valid UserAccountId. + /// Thrown when the database command fails. public async Task UpdateAsync(Domain.Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); @@ -65,6 +91,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) await command.ExecuteNonQueryAsync(); } + /// + /// Deletes a user account by ID using the usp_DeleteUserAccount stored procedure. + /// + /// The unique identifier of the user account to delete. + /// Thrown when the database command fails. public async Task DeleteAsync(Guid id) { await using var connection = await CreateConnection(); @@ -76,6 +107,12 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) await command.ExecuteNonQueryAsync(); } + /// + /// Retrieves a user account by username using the usp_GetUserAccountByUsername stored procedure. + /// + /// The username to search for. + /// The matching , or null if not found. + /// Thrown when the database command fails. public async Task GetByUsernameAsync( string username ) @@ -91,6 +128,12 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToEntity(reader) : null; } + /// + /// Retrieves a user account by email address using the usp_GetUserAccountByEmail stored procedure. + /// + /// The email address to search for. + /// The matching , or null if not found. + /// Thrown when the database command fails. public async Task GetByEmailAsync( string email ) @@ -106,6 +149,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) return await reader.ReadAsync() ? MapToEntity(reader) : null; } + /// + /// Maps the current row of a data reader to a entity. + /// + /// The data reader positioned on the row to map. + /// The mapped instance. protected override Domain.Entities.UserAccount MapToEntity( DbDataReader reader ) @@ -128,6 +176,13 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) }; } + /// + /// Helper method to add a parameter to a database command, converting null values to + /// . + /// + /// The command to add the parameter to. + /// The parameter name (including any prefix, e.g. "@Username"). + /// The parameter value, or null to bind . private static void AddParameter( DbCommand command, string name, diff --git a/web/backend/Service/Service.Auth/ConfirmationService.cs b/web/backend/Service/Service.Auth/ConfirmationService.cs index 78015eb..ca82b42 100644 --- a/web/backend/Service/Service.Auth/ConfirmationService.cs +++ b/web/backend/Service/Service.Auth/ConfirmationService.cs @@ -5,12 +5,28 @@ using Service.Emails; namespace Service.Auth; +/// +/// Handles confirmation of newly registered user accounts and resending of confirmation emails. +/// +/// Repository used to look up and confirm user accounts. +/// Service used to generate and validate confirmation tokens. +/// Service used to send confirmation-related emails. public class ConfirmationService( IAuthRepository authRepository, ITokenService tokenService, IEmailService emailService ) : IConfirmationService { + /// + /// Validates a confirmation token and marks the corresponding user account as confirmed. + /// + /// The confirmation token issued to the user, typically delivered via email. + /// + /// A containing the UTC timestamp of confirmation and the confirmed user's ID. + /// + /// + /// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found. + /// public async Task ConfirmUserAsync( string confirmationToken ) @@ -34,6 +50,15 @@ public class ConfirmationService( ); } + /// + /// Resends the account confirmation email to a user, generating a fresh confirmation token. + /// + /// The unique identifier of the user requesting the resend. + /// A task that completes once the operation has finished. + /// + /// 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. + /// public async Task ResendConfirmationEmailAsync(Guid userId) { var user = await authRepository.GetUserByIdAsync(userId); diff --git a/web/backend/Service/Service.Auth/IConfirmationService.cs b/web/backend/Service/Service.Auth/IConfirmationService.cs index c245906..832ddb7 100644 --- a/web/backend/Service/Service.Auth/IConfirmationService.cs +++ b/web/backend/Service/Service.Auth/IConfirmationService.cs @@ -3,11 +3,30 @@ using Infrastructure.Repository.Auth; namespace Service.Auth; +/// +/// Represents the result of successfully confirming a user account. +/// +/// The UTC date and time at which the account was confirmed. +/// The unique identifier of the confirmed user. public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId); +/// +/// Defines operations for confirming user accounts and resending confirmation emails. +/// public interface IConfirmationService { + /// + /// Validates a confirmation token and confirms the corresponding user account. + /// + /// The confirmation token issued to the user. + /// A describing the confirmed account. Task ConfirmUserAsync(string confirmationToken); + + /// + /// Resends the account confirmation email for the specified user. + /// + /// The unique identifier of the user requesting the resend. + /// A task that completes once the operation has finished. Task ResendConfirmationEmailAsync(Guid userId); } diff --git a/web/backend/Service/Service.Auth/ILoginService.cs b/web/backend/Service/Service.Auth/ILoginService.cs index fc39dfc..51fa7bb 100644 --- a/web/backend/Service/Service.Auth/ILoginService.cs +++ b/web/backend/Service/Service.Auth/ILoginService.cs @@ -2,12 +2,28 @@ using Domain.Entities; namespace Service.Auth; +/// +/// Represents the result of a successful login attempt. +/// +/// The authenticated user's account. +/// The issued refresh token. +/// The issued access token. public record LoginServiceReturn( UserAccount UserAccount, string RefreshToken, string AccessToken ); + +/// +/// Defines the operation for authenticating a user with a username and password. +/// public interface ILoginService { + /// + /// Authenticates a user using their username and password and issues new tokens. + /// + /// The username of the account to authenticate. + /// The plain-text password to verify against the stored credential. + /// A containing the authenticated user and issued tokens. Task LoginAsync(string username, string password); } diff --git a/web/backend/Service/Service.Auth/IRegisterService.cs b/web/backend/Service/Service.Auth/IRegisterService.cs index 8c0dccc..6eae342 100644 --- a/web/backend/Service/Service.Auth/IRegisterService.cs +++ b/web/backend/Service/Service.Auth/IRegisterService.cs @@ -2,14 +2,45 @@ using Domain.Entities; namespace Service.Auth; +/// +/// Represents the result of a user registration attempt. +/// public record RegisterServiceReturn { + /// + /// Gets a value indicating whether tokens were issued for the newly created account. + /// false when token generation failed, in which case and + /// are empty. + /// public bool IsAuthenticated { get; init; } = false; + + /// + /// Gets a value indicating whether the registration confirmation email was sent successfully. + /// public bool EmailSent { get; init; } = false; + + /// + /// Gets the user account that was created. + /// public UserAccount UserAccount { get; init; } + + /// + /// Gets the issued access token, or an empty string if authentication was not completed. + /// public string AccessToken { get; init; } = string.Empty; + + /// + /// Gets the issued refresh token, or an empty string if authentication was not completed. + /// public string RefreshToken { get; init; } = string.Empty; + /// + /// Initializes a new instance representing a successful registration with issued tokens. + /// + /// The newly created user account. + /// The issued access token. + /// The issued refresh token. + /// Whether the confirmation email was sent successfully. public RegisterServiceReturn( UserAccount userAccount, string accessToken, @@ -24,14 +55,27 @@ public record RegisterServiceReturn RefreshToken = refreshToken; } + /// + /// Initializes a new instance representing a registration where tokens could not be issued. + /// + /// The newly created user account. public RegisterServiceReturn(UserAccount userAccount) { UserAccount = userAccount; } } +/// +/// Defines the operation for registering a new user account. +/// public interface IRegisterService { + /// + /// Registers a new user account with the given password. + /// + /// The user account details to register. + /// The plain-text password to hash and store for the new account. + /// A describing the outcome of the registration. Task RegisterAsync( UserAccount userAccount, string password diff --git a/web/backend/Service/Service.Auth/ITokenService.cs b/web/backend/Service/Service.Auth/ITokenService.cs index 6252579..2401301 100644 --- a/web/backend/Service/Service.Auth/ITokenService.cs +++ b/web/backend/Service/Service.Auth/ITokenService.cs @@ -7,40 +7,119 @@ using Infrastructure.Repository.Auth; namespace Service.Auth; +/// +/// Identifies the kind of token being generated or validated. +/// public enum TokenType { + /// A short-lived token used to authorize API requests. AccessToken, + /// A long-lived token used to obtain new access tokens. RefreshToken, + /// A short-lived token used to confirm a user's email/account. ConfirmationToken, } +/// +/// Represents the result of successfully validating a token. +/// +/// The unique identifier of the user the token belongs to. +/// The username extracted from the token's claims. +/// The produced from the validated token. public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); +/// +/// Represents the result of refreshing a user's session. +/// +/// The user account associated with the refreshed session. +/// The newly issued refresh token. +/// The newly issued access token. public record RefreshTokenResult( UserAccount UserAccount, string RefreshToken, string AccessToken ); +/// +/// Defines the expiration windows, in hours, for each type of token issued by . +/// public static class TokenServiceExpirationHours { + /// The expiration window, in hours, for access tokens. public const double AccessTokenHours = 1; + /// The expiration window, in hours, for refresh tokens (21 days). public const double RefreshTokenHours = 504; // 21 days + /// The expiration window, in hours, for confirmation tokens (30 minutes). public const double ConfirmationTokenHours = 0.5; // 30 minutes } +/// +/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation. +/// public interface ITokenService { + /// + /// Generates a new access token for the given user. + /// + /// The user account to generate the token for. + /// The signed access token string. string GenerateAccessToken(UserAccount user); + + /// + /// Generates a new refresh token for the given user. + /// + /// The user account to generate the token for. + /// The signed refresh token string. string GenerateRefreshToken(UserAccount user); + + /// + /// Generates a new confirmation token for the given user. + /// + /// The user account to generate the token for. + /// The signed confirmation token string. string GenerateConfirmationToken(UserAccount user); + + /// + /// Generates a token of the type specified by , which must be . + /// + /// The enum type identifying which kind of token to generate. Must be . + /// The user account to generate the token for. + /// The signed token string corresponding to the requested token type. string GenerateToken(UserAccount user) where T : struct, Enum; + + /// + /// Validates an access token. + /// + /// The access token string to validate. + /// A describing the token's claims. Task ValidateAccessTokenAsync(string token); + + /// + /// Validates a refresh token. + /// + /// The refresh token string to validate. + /// A describing the token's claims. Task ValidateRefreshTokenAsync(string token); + + /// + /// Validates a confirmation token. + /// + /// The confirmation token string to validate. + /// A describing the token's claims. Task ValidateConfirmationTokenAsync(string token); + + /// + /// Validates a refresh token and issues a new access/refresh token pair for the associated user. + /// + /// The refresh token string to validate and exchange. + /// A containing the user and newly issued tokens. Task RefreshTokenAsync(string refreshTokenString); } +/// +/// Default implementation of that generates and validates JWTs +/// for access, refresh, and confirmation flows using secrets read from environment variables. +/// public class TokenService : ITokenService { private readonly ITokenInfrastructure _tokenInfrastructure; @@ -50,6 +129,16 @@ public class TokenService : ITokenService private readonly string _refreshTokenSecret; private readonly string _confirmationTokenSecret; + /// + /// Initializes a new instance of , loading the access, refresh, and + /// confirmation token signing secrets from environment variables. + /// + /// The infrastructure component used to generate and validate JWTs. + /// Repository used to look up user accounts during token refresh. + /// + /// Thrown when any of the ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, or + /// CONFIRMATION_TOKEN_SECRET environment variables are not set. + /// public TokenService( ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository @@ -68,24 +157,53 @@ public class TokenService : ITokenService ?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set"); } + /// + /// Generates an access token for the given user, signed with the access token secret and + /// expiring after hours. + /// + /// The user account to generate the token for. + /// The signed access token string. public string GenerateAccessToken(UserAccount user) { var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours); return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret); } + /// + /// Generates a refresh token for the given user, signed with the refresh token secret and + /// expiring after hours. + /// + /// The user account to generate the token for. + /// The signed refresh token string. public string GenerateRefreshToken(UserAccount user) { var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours); return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret); } + /// + /// Generates a confirmation token for the given user, signed with the confirmation token secret and + /// expiring after hours. + /// + /// The user account to generate the token for. + /// The signed confirmation token string. public string GenerateConfirmationToken(UserAccount user) { var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours); return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret); } + /// + /// Generates a token of the kind specified by , dispatching to + /// , , or + /// based on the corresponding value. + /// + /// The enum type identifying which kind of token to generate. Must be . + /// The user account to generate the token for. + /// The signed token string corresponding to the requested token type. + /// + /// Thrown when is not or does not resolve to a known token type. + /// public string GenerateToken(UserAccount user) where T : struct, Enum { if (typeof(T) != typeof(TokenType)) @@ -105,15 +223,51 @@ public class TokenService : ITokenService }; } + /// + /// Validates an access token against the access token secret. + /// + /// The access token string to validate. + /// A describing the token's claims. + /// + /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. + /// public async Task ValidateAccessTokenAsync(string token) => await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); + /// + /// Validates a refresh token against the refresh token secret. + /// + /// The refresh token string to validate. + /// A describing the token's claims. + /// + /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. + /// public async Task ValidateRefreshTokenAsync(string token) => await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); + /// + /// Validates a confirmation token against the confirmation token secret. + /// + /// The confirmation token string to validate. + /// A describing the token's claims. + /// + /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. + /// public async Task ValidateConfirmationTokenAsync(string token) => await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); + /// + /// 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. + /// + /// The token string to validate. + /// The secret key to validate the token's signature against. + /// A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages. + /// A describing the token's claims. + /// + /// Thrown when required claims are missing, the user ID claim is not a valid , + /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). + /// private async Task ValidateTokenInternalAsync(string token, string secret, string tokenType) { try @@ -141,6 +295,15 @@ public class TokenService : ITokenService } } + /// + /// Validates the given refresh token, looks up the associated user, and issues a fresh + /// access/refresh token pair. + /// + /// The refresh token string to validate and exchange. + /// A containing the user and newly issued tokens. + /// + /// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists. + /// public async Task RefreshTokenAsync(string refreshTokenString) { var validated = await ValidateRefreshTokenAsync(refreshTokenString); diff --git a/web/backend/Service/Service.Auth/LoginService.cs b/web/backend/Service/Service.Auth/LoginService.cs index 892977b..c03848e 100644 --- a/web/backend/Service/Service.Auth/LoginService.cs +++ b/web/backend/Service/Service.Auth/LoginService.cs @@ -6,12 +6,28 @@ using Infrastructure.Repository.Auth; namespace Service.Auth; +/// +/// Handles authenticating users by verifying their credentials and issuing access/refresh tokens. +/// +/// Repository used to look up user accounts and their active credentials. +/// Infrastructure component used to verify a plain-text password against a stored hash. +/// Service used to generate access and refresh tokens for an authenticated user. public class LoginService( IAuthRepository authRepo, IPasswordInfrastructure passwordInfrastructure, ITokenService tokenService ) : ILoginService { + /// + /// Authenticates a user by username and password, issuing a new access and refresh token on success. + /// + /// The username of the account to authenticate. + /// The plain-text password to verify against the stored credential. + /// A containing the authenticated user and issued tokens. + /// + /// Thrown when the username does not match any account, the account has no active credential, + /// or the supplied password does not match the stored hash. + /// public async Task LoginAsync( string username, string password diff --git a/web/backend/Service/Service.Auth/RegisterService.cs b/web/backend/Service/Service.Auth/RegisterService.cs index 13b157c..b54dd2f 100644 --- a/web/backend/Service/Service.Auth/RegisterService.cs +++ b/web/backend/Service/Service.Auth/RegisterService.cs @@ -9,6 +9,14 @@ using Service.Emails; namespace Service.Auth; +/// +/// Handles registration of new user accounts, including uniqueness validation, password hashing, +/// token issuance, and sending the registration confirmation email. +/// +/// Repository used to check for existing users and persist the new account. +/// Infrastructure component used to hash the user's plain-text password. +/// Service used to generate access, refresh, and confirmation tokens. +/// Service used to send the registration confirmation email. public class RegisterService( IAuthRepository authRepo, IPasswordInfrastructure passwordInfrastructure, @@ -16,6 +24,13 @@ public class RegisterService( IEmailService emailService ) : IRegisterService { + /// + /// Verifies that no existing user account has the same username or email as the given account. + /// + /// The candidate user account whose username and email should be checked for uniqueness. + /// + /// Thrown when an existing account already has the same username or email address. + /// private async Task ValidateUserDoesNotExist(UserAccount userAccount) { // Check if user already exists @@ -32,6 +47,21 @@ public class RegisterService( } } + /// + /// Registers a new user account: validates uniqueness, hashes the password, persists the account, + /// issues access/refresh/confirmation tokens, and attempts to send the registration confirmation email. + /// + /// The user account details to register. + /// The plain-text password to hash and store for the new account. + /// + /// A describing the outcome. If token generation fails, the + /// returned value has set to false and no tokens. + /// Otherwise tokens are populated and reflects whether the + /// confirmation email was sent successfully (failures to send the email are swallowed and do not fail registration). + /// + /// + /// Thrown when an existing account already has the same username or email address. + /// public async Task RegisterAsync( UserAccount userAccount, string password diff --git a/web/backend/Service/Service.Breweries/BreweryService.cs b/web/backend/Service/Service.Breweries/BreweryService.cs index 5966f6c..dd1b750 100644 --- a/web/backend/Service/Service.Breweries/BreweryService.cs +++ b/web/backend/Service/Service.Breweries/BreweryService.cs @@ -3,14 +3,34 @@ using Infrastructure.Repository.Breweries; namespace Service.Breweries; +/// +/// Handles retrieval, creation, update, and deletion of brewery posts. +/// +/// Repository used to persist and query brewery post data. public class BreweryService(IBreweryRepository repository) : IBreweryService { + /// + /// Retrieves a brewery post by its unique identifier. + /// + /// The unique identifier of the brewery post. + /// The matching , or null if no such post exists. public Task GetByIdAsync(Guid id) => repository.GetByIdAsync(id); + /// + /// Retrieves all brewery posts, optionally paginated. + /// + /// The maximum number of results to return, or null for no limit. + /// The number of results to skip, or null to start from the beginning. + /// A collection of entities. public Task> GetAllAsync(int? limit = null, int? offset = null) => repository.GetAllAsync(limit, offset); + /// + /// Creates a new brewery post, generating new identifiers for the post and its location. + /// + /// The details of the brewery post to create. + /// A successful wrapping the newly created brewery post. public async Task CreateAsync(BreweryCreateRequest request) { var entity = new BreweryPost @@ -35,6 +55,12 @@ public class BreweryService(IBreweryRepository repository) : IBreweryService return new BreweryServiceReturn(entity); } + /// + /// Updates an existing brewery post. If has no Location, + /// the brewery's location is cleared. + /// + /// The updated details of the brewery post. + /// A successful wrapping the updated brewery post. public async Task UpdateAsync(BreweryUpdateRequest request) { var entity = new BreweryPost @@ -60,6 +86,11 @@ public class BreweryService(IBreweryRepository repository) : IBreweryService return new BreweryServiceReturn(entity); } + /// + /// Deletes a brewery post by its unique identifier. + /// + /// The unique identifier of the brewery post to delete. + /// A task that completes once the deletion has finished. public Task DeleteAsync(Guid id) => repository.DeleteAsync(id); } diff --git a/web/backend/Service/Service.Breweries/IBreweryService.cs b/web/backend/Service/Service.Breweries/IBreweryService.cs index 5331634..ef97a9e 100644 --- a/web/backend/Service/Service.Breweries/IBreweryService.cs +++ b/web/backend/Service/Service.Breweries/IBreweryService.cs @@ -2,6 +2,13 @@ using Domain.Entities; namespace Service.Breweries; +/// +/// Represents the data required to create a new brewery post. +/// +/// The unique identifier of the user creating the post. +/// The name of the brewery. +/// A description of the brewery. +/// The location details for the new brewery post. public record BreweryCreateRequest( Guid PostedById, string BreweryName, @@ -9,6 +16,14 @@ public record BreweryCreateRequest( BreweryLocationCreateRequest Location ); +/// +/// Represents the location data required to create a new brewery post. +/// +/// The unique identifier of the city the brewery is located in. +/// The primary address line. +/// An optional secondary address line. +/// The postal code of the brewery's address. +/// Optional geographic coordinates for the brewery's location. public record BreweryLocationCreateRequest( Guid CityId, string AddressLine1, @@ -17,6 +32,14 @@ public record BreweryLocationCreateRequest( byte[]? Coordinates ); +/// +/// Represents the data required to update an existing brewery post. +/// +/// The unique identifier of the brewery post to update. +/// The unique identifier of the user who owns/posted the brewery. +/// The updated name of the brewery. +/// The updated description of the brewery. +/// The updated location details, or null to clear the location. public record BreweryUpdateRequest( Guid BreweryPostId, Guid PostedById, @@ -25,6 +48,15 @@ public record BreweryUpdateRequest( BreweryLocationUpdateRequest? Location ); +/// +/// Represents the location data required to update an existing brewery post. +/// +/// The unique identifier of the existing brewery location record. +/// The unique identifier of the city the brewery is located in. +/// The primary address line. +/// An optional secondary address line. +/// The postal code of the brewery's address. +/// Optional geographic coordinates for the brewery's location. public record BreweryLocationUpdateRequest( Guid BreweryPostLocationId, Guid CityId, @@ -34,18 +66,40 @@ public record BreweryLocationUpdateRequest( byte[]? Coordinates ); +/// +/// Represents the result of a create or update operation against a brewery post. +/// public record BreweryServiceReturn { + /// + /// Gets a value indicating whether the operation succeeded. + /// public bool Success { get; init; } + + /// + /// Gets the resulting brewery post when the operation succeeded; otherwise a default/unset value. + /// public BreweryPost Brewery { get; init; } + + /// + /// Gets a message describing the failure reason, empty when is true. + /// public string Message { get; init; } = string.Empty; + /// + /// Initializes a new successful result wrapping the given brewery post. + /// + /// The brewery post resulting from the operation. public BreweryServiceReturn(BreweryPost brewery) { Success = true; Brewery = brewery; } + /// + /// Initializes a new failed result with the given error message. + /// + /// A message describing why the operation failed. public BreweryServiceReturn(string message) { Success = false; @@ -54,11 +108,44 @@ public record BreweryServiceReturn } } +/// +/// Defines operations for retrieving, creating, updating, and deleting brewery posts. +/// public interface IBreweryService { + /// + /// Retrieves a brewery post by its unique identifier. + /// + /// The unique identifier of the brewery post. + /// The matching , or null if no such post exists. Task GetByIdAsync(Guid id); + + /// + /// Retrieves all brewery posts, optionally paginated. + /// + /// The maximum number of results to return, or null for no limit. + /// The number of results to skip, or null to start from the beginning. + /// A collection of entities. Task> GetAllAsync(int? limit = null, int? offset = null); + + /// + /// Creates a new brewery post. + /// + /// The details of the brewery post to create. + /// A describing the outcome of the creation. Task CreateAsync(BreweryCreateRequest request); + + /// + /// Updates an existing brewery post. + /// + /// The updated details of the brewery post. + /// A describing the outcome of the update. Task UpdateAsync(BreweryUpdateRequest request); + + /// + /// Deletes a brewery post by its unique identifier. + /// + /// The unique identifier of the brewery post to delete. + /// A task that completes once the deletion has finished. Task DeleteAsync(Guid id); } diff --git a/web/backend/Service/Service.Emails/EmailService.cs b/web/backend/Service/Service.Emails/EmailService.cs index a65a9c9..ded54a0 100644 --- a/web/backend/Service/Service.Emails/EmailService.cs +++ b/web/backend/Service/Service.Emails/EmailService.cs @@ -4,28 +4,63 @@ using Infrastructure.Email.Templates.Rendering; namespace Service.Emails; +/// +/// Defines operations for sending account-related emails, such as registration and confirmation resend emails. +/// public interface IEmailService { + /// + /// Sends a welcome email containing an account confirmation link to a newly registered user. + /// + /// The newly created user account to email. + /// The confirmation token to embed in the confirmation link. + /// A task that completes once the email has been sent. public Task SendRegistrationEmailAsync( UserAccount createdUser, string confirmationToken ); + /// + /// Sends an email containing a fresh account confirmation link to a user who requested a resend. + /// + /// The user account to email. + /// The confirmation token to embed in the confirmation link. + /// A task that completes once the email has been sent. public Task SendResendConfirmationEmailAsync( UserAccount user, string confirmationToken ); } +/// +/// Default implementation of that renders email templates and dispatches +/// them via an . +/// +/// Provider used to deliver the rendered emails. +/// Provider used to render HTML email bodies from templates. public class EmailService( IEmailProvider emailProvider, IEmailTemplateProvider emailTemplateProvider ) : IEmailService { + /// + /// The base URL of the website, used to build confirmation links. Read from the + /// WEBSITE_BASE_URL environment variable. + /// + /// + /// Thrown at type initialization time when the WEBSITE_BASE_URL environment variable is not set. + /// private static readonly string WebsiteBaseUrl = Environment.GetEnvironmentVariable("WEBSITE_BASE_URL") ?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set"); + /// + /// Builds a confirmation link from the given token, renders the registration welcome email template, + /// and sends it to the newly created user. + /// + /// The newly created user account to email. + /// The confirmation token to embed in the confirmation link. + /// A task that completes once the email has been sent. public async Task SendRegistrationEmailAsync( UserAccount createdUser, string confirmationToken @@ -48,6 +83,13 @@ public class EmailService( ); } + /// + /// Builds a confirmation link from the given token, renders the resend-confirmation email template, + /// and sends it to the user. + /// + /// The user account to email. + /// The confirmation token to embed in the confirmation link. + /// A task that completes once the email has been sent. public async Task SendResendConfirmationEmailAsync( UserAccount user, string confirmationToken diff --git a/web/backend/Service/Service.UserManagement/User/IUserService.cs b/web/backend/Service/Service.UserManagement/User/IUserService.cs index 75b30e5..bd017eb 100644 --- a/web/backend/Service/Service.UserManagement/User/IUserService.cs +++ b/web/backend/Service/Service.UserManagement/User/IUserService.cs @@ -2,13 +2,33 @@ using Domain.Entities; namespace Service.UserManagement.User; +/// +/// Defines operations for retrieving and updating user accounts. +/// public interface IUserService { + /// + /// Retrieves all user accounts, optionally paginated. + /// + /// The maximum number of results to return, or null for no limit. + /// The number of results to skip, or null to start from the beginning. + /// A collection of entities. Task> GetAllAsync( int? limit = null, int? offset = null ); + + /// + /// Retrieves a user account by its unique identifier. + /// + /// The unique identifier of the user account. + /// The matching . Task GetByIdAsync(Guid id); + /// + /// Updates an existing user account. + /// + /// The user account containing the updated data. + /// A task that completes once the update has finished. Task UpdateAsync(UserAccount userAccount); } diff --git a/web/backend/Service/Service.UserManagement/User/UserService.cs b/web/backend/Service/Service.UserManagement/User/UserService.cs index a767477..bfba75a 100644 --- a/web/backend/Service/Service.UserManagement/User/UserService.cs +++ b/web/backend/Service/Service.UserManagement/User/UserService.cs @@ -4,8 +4,18 @@ using Infrastructure.Repository.UserAccount; namespace Service.UserManagement.User; +/// +/// Handles retrieval and update of user accounts. +/// +/// Repository used to persist and query user account data. public class UserService(IUserAccountRepository repository) : IUserService { + /// + /// Retrieves all user accounts, optionally paginated. + /// + /// The maximum number of results to return, or null for no limit. + /// The number of results to skip, or null to start from the beginning. + /// A collection of entities. public async Task> GetAllAsync( int? limit = null, int? offset = null @@ -14,6 +24,12 @@ public class UserService(IUserAccountRepository repository) : IUserService return await repository.GetAllAsync(limit, offset); } + /// + /// Retrieves a user account by its unique identifier. + /// + /// The unique identifier of the user account. + /// The matching . + /// Thrown when no user account exists with the given . public async Task GetByIdAsync(Guid id) { var user = await repository.GetByIdAsync(id); @@ -22,6 +38,11 @@ public class UserService(IUserAccountRepository repository) : IUserService return user; } + /// + /// Updates an existing user account. + /// + /// The user account containing the updated data. + /// A task that completes once the update has finished. public async Task UpdateAsync(UserAccount userAccount) { await repository.UpdateAsync(userAccount);