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