From 4aa7ba5d9b3333cc446cb000f49d562d1972019c Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 5 Sep 2026 14:57:39 -0400 Subject: [PATCH] Update doc comments in Features.Breweries source (#279) --- .../Commands/CreateBreweryHandlerTests.cs | 6 +- .../Features.Breweries.Tests.csproj | 14 +- .../GetAllBreweryLocationsHandlerTests.cs | 6 +- ...BreweryLocationsWithinRangeHandlerTests.cs | 6 +- .../CreateBrewery/CreateBreweryCommand.cs | 16 +- .../CreateBrewery/CreateBreweryHandler.cs | 14 +- .../CreateBrewery/CreateBreweryValidator.cs | 5 +- .../DeleteBrewery/DeleteBreweryCommand.cs | 4 +- .../DeleteBrewery/DeleteBreweryHandler.cs | 11 +- .../UpdateBrewery/UpdateBreweryCommand.cs | 20 +- .../UpdateBrewery/UpdateBreweryHandler.cs | 13 +- .../UpdateBrewery/UpdateBreweryValidator.cs | 4 +- .../Controllers/BreweryController.cs | 93 +++-- ...resBreweriesServiceCollectionExtensions.cs | 10 +- .../Features.Breweries/Dtos/BreweryDto.cs | 31 +- .../Dtos/BreweryDtoMapper.cs | 98 ++--- .../GetAllBreweries/GetAllBreweriesHandler.cs | 8 +- .../GetAllBreweries/GetAllBreweriesQuery.cs | 12 +- .../GetAllBreweryLocationsHandler.cs | 8 +- .../GetAllBreweryLocationsQuery.cs | 3 +- .../GetBreweryById/GetBreweryByIdHandler.cs | 13 +- .../GetBreweryById/GetBreweryByIdQuery.cs | 8 +- .../GetBreweryLocationsWithinRangeHandler.cs | 6 +- .../GetBreweryLocationsWithinRangeQuery.cs | 23 +- ...GetBreweryLocationsWithinRangeValidator.cs | 4 +- .../Repository/BreweryOwnershipExtensions.cs | 15 +- .../Repository/BreweryRepository.cs | 375 +++++++++--------- .../Repository/CoordinateDataTypeHandler.cs | 4 +- .../Repository/IBreweryRepository.cs | 75 ++-- 29 files changed, 517 insertions(+), 388 deletions(-) diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs index de0dc48a..485a6e13 100644 --- a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs @@ -17,10 +17,8 @@ public class CreateBreweryHandlerTests _handler = new CreateBreweryHandler(_repoMock.Object); } - private static CreateBreweryLocation ValidLocation() - { - return new CreateBreweryLocation(Guid.NewGuid(), "123 Main St", null, "12345", null); - } + private static CreateBreweryLocation ValidLocation() => + new(Guid.NewGuid(), "123 Main St", null, "12345", null); [Fact] public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt() diff --git a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj index 4fbc5a99..5179a631 100644 --- a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj +++ b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj @@ -8,18 +8,18 @@ - - - - - + + + + + - + - + diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs index 93fe73c6..8a6e1453 100644 --- a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs @@ -36,8 +36,10 @@ public class GetAllBreweryLocationsHandlerTests { BreweryPost[] breweries = [ - new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" }, - new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }, + new() + { BreweryPostId = Guid.NewGuid(), BreweryName = "A" }, + new() + { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }, ]; _repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries); diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs index 8af18141..c707a4cf 100644 --- a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs @@ -50,8 +50,10 @@ public class GetBreweryLocationsWithinRangeHandlerTests { BreweryPost[] breweries = [ - new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" }, - new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" }, + new() + { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" }, + new() + { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" }, ]; _repoMock .Setup(r => r.GetAllLocationsWithinRange(It.IsAny(), It.IsAny())) diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs index 2f0d3fd2..3ed8635c 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs @@ -4,8 +4,12 @@ using MediatR; namespace Features.Breweries.Commands.CreateBrewery; -/// Location details for a brewery being created. -/// Raw binary representation of the brewery's geographic coordinates. +/// +/// Describes the location supplied when creating a brewery. +/// +/// +/// Serialized geographic coordinates for the brewery. +/// public record CreateBreweryLocation( Guid CityId, string AddressLine1, @@ -15,7 +19,7 @@ public record CreateBreweryLocation( ); /// -/// Creates a new brewery post. Bound directly from the request body of POST /api/brewery. +/// Represents the application request used to create a brewery post. /// public record CreateBreweryCommand( Guid PostedById, @@ -24,9 +28,11 @@ public record CreateBreweryCommand( CreateBreweryLocation Location ) : IRequest; -/// Request body for POST /api/brewery. PostedById comes from the caller's JWT. +/// +/// Defines the client-supplied body for creating a brewery post. +/// public record CreateBreweryRequest( string BreweryName, string Description, CreateBreweryLocation Location -); \ No newline at end of file +); diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs index 3df6dbf8..2a9c239f 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs @@ -5,13 +5,17 @@ using MediatR; namespace Features.Breweries.Commands.CreateBrewery; -/// Handles by persisting a new brewery post. +/// +/// Persists brewery posts created through . +/// public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler { - /// Creates a new brewery post, generating new identifiers for the post and its location. + /// + /// Creates the post and its associated location with new identifiers. + /// /// - /// Thrown when 's PostedById or Location.CityId does not exist. + /// Thrown if the posting user or the selected city cannot be found. /// public async Task Handle( CreateBreweryCommand request, @@ -38,10 +42,6 @@ public class CreateBreweryHandler(IBreweryRepository repository) await repository.CreateAsync(entity); - // RowVersion is a DB-computed column, unset on the in-memory entity; re-fetch so callers - // get back the value needed for a subsequent optimistic-concurrency update. Uses - // CancellationToken.None since the write already committed: cancelling the caller's request - // at this point must not turn a successful creation into a thrown exception. BreweryPost created = await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None) ?? throw new InvalidOperationException( diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs index e2f08885..c9bdeba0 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs @@ -2,7 +2,9 @@ using FluentValidation; namespace Features.Breweries.Commands.CreateBrewery; -/// Validates . +/// +/// Applies creation rules to . +/// public class CreateBreweryValidator : AbstractValidator { public CreateBreweryValidator() @@ -27,7 +29,6 @@ public class CreateBreweryValidator : AbstractValidator RuleFor(x => x.Location.AddressLine1) .NotEmpty() - .When(x => x.Location is not null) .WithMessage("Address line 1 is required.") .MaximumLength(256) .WithMessage("Address line 1 cannot exceed 256 characters."); diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs index e0c391a6..8a26df9f 100644 --- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs @@ -2,5 +2,7 @@ using MediatR; namespace Features.Breweries.Commands.DeleteBrewery; -/// Deletes a brewery post. is bound from the route of DELETE /api/brewery/{id}. +/// +/// Represents a request to delete a brewery post on behalf of a caller. +/// public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest; diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs index 557a6326..fffafa17 100644 --- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs @@ -3,15 +3,20 @@ using MediatR; namespace Features.Breweries.Commands.DeleteBrewery; -/// Handles by deleting the corresponding brewery post. +/// +/// Processes requests. +/// public class DeleteBreweryHandler(IBreweryRepository repository) : IRequestHandler { + /// + /// Confirms that the caller owns the post before deleting it. + /// /// - /// Thrown when no brewery exists with 's BreweryPostId. + /// Thrown if the brewery post does not exist. /// /// - /// Thrown when 's RequestingUserId did not post the brewery. + /// Thrown if the caller did not create the brewery post. /// public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) { diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs index 41e669fc..d51a3556 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs @@ -4,11 +4,15 @@ using MediatR; namespace Features.Breweries.Commands.UpdateBrewery; -/// Location details for a brewery being updated. +/// +/// Describes the location supplied when updating a brewery. +/// /// -/// Identifier of the existing location row, if any; a new row is created if none exists yet. +/// Identifies the existing location; a new location is created when needed. +/// +/// +/// Serialized geographic coordinates for the brewery. /// -/// Raw binary representation of the brewery's geographic coordinates. public record UpdateBreweryLocation( Guid BreweryPostLocationId, Guid CityId, @@ -19,9 +23,8 @@ public record UpdateBreweryLocation( ); /// -/// Updates an existing brewery post. A null clears the brewery's location. -/// must be the row-version last read for this brewery (e.g. from a prior -/// GET); the update is rejected with a 409 Conflict if the brewery was modified since then. +/// Represents a concurrency-protected change to an existing brewery post. +/// A removes the existing location. /// public record UpdateBreweryCommand( Guid BreweryPostId, @@ -32,7 +35,9 @@ public record UpdateBreweryCommand( UpdateBreweryLocation? Location ) : IRequest; -/// Request body for PUT /api/brewery/{id}. +/// +/// Defines the client-supplied body for updating a brewery post. +/// public record UpdateBreweryRequest( Guid BreweryPostId, byte[] RowVersion, @@ -40,4 +45,3 @@ public record UpdateBreweryRequest( string Description, UpdateBreweryLocation? Location ); - diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs index 5c237148..bdbdbbc3 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs @@ -5,21 +5,20 @@ using MediatR; namespace Features.Breweries.Commands.UpdateBrewery; -/// Handles by persisting changes to an existing brewery post. +/// +/// Persists changes requested through . +/// public class UpdateBreweryHandler(IBreweryRepository repository) : IRequestHandler { /// - /// Updates an existing brewery post. If has no Location, - /// the brewery's location is cleared. + /// Updates the post after confirming that the caller owns it. /// /// - /// Thrown when no brewery exists with 's BreweryPostId, or its - /// location's CityId does not exist. + /// Thrown if the brewery post or its requested city cannot be found. /// /// - /// Thrown when the brewery was modified by another request since . - /// RowVersion was read. + /// Thrown if the post has changed since its supplied row version was read. /// public async Task Handle( UpdateBreweryCommand request, diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs index a8757fcb..c0a39bd0 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs @@ -2,7 +2,9 @@ using FluentValidation; namespace Features.Breweries.Commands.UpdateBrewery; -/// Validates . +/// +/// Applies update rules to . +/// public class UpdateBreweryValidator : AbstractValidator { public UpdateBreweryValidator() diff --git a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs index b95e0d06..2d95743b 100644 --- a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs +++ b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs @@ -13,19 +13,25 @@ using Shared.Contracts; namespace Features.Breweries.Controllers; -/// Exposes CRUD operations for brewery posts. +/// +/// Provides HTTP endpoints for reading and managing brewery posts. +/// /// -/// Requires JWT authentication by default; and opt out via -/// [AllowAnonymous]. +/// Requests require JWT authentication unless the endpoint allows anonymous access. /// +/// [ApiController] [Route("api/[controller]")] [Authorize(AuthenticationSchemes = "JWT")] public class BreweryController(IMediator mediator) : ControllerBase { - /// Retrieves a single brewery post by ID. - /// Allows anonymous access. - /// 200 OK with the brewery if found; otherwise 404 Not Found. + /// + /// Returns the brewery post identified by . + /// + /// This endpoint permits anonymous access. + /// + /// A successful response containing the post, or 404 Not Found when it is absent. + /// [AllowAnonymous] [HttpGet("{id:guid}")] public async Task>> GetById(Guid id) @@ -43,10 +49,16 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } - /// Retrieves brewery posts, newest first. - /// Maximum number of breweries to return. Unbounded if . - /// Number of breweries to skip. Treated as zero if . - /// Allows anonymous access. + /// + /// Returns brewery posts in reverse chronological order. + /// + /// + /// Limits the number of returned posts; leaves it unlimited. + /// + /// + /// Skips this many posts; means none. + /// + /// This endpoint permits anonymous access. [AllowAnonymous] [HttpGet] public async Task>>> GetAll( @@ -66,11 +78,15 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } - /// Retrieves all brewery posts that have a set location. - /// Allows anonymous access. + /// + /// Returns all brewery posts with an associated location. + /// + /// This endpoint permits anonymous access. [AllowAnonymous] [HttpGet("locations")] - public async Task>>> GetAllLocations() + public async Task< + ActionResult>> + > GetAllLocations() { IEnumerable breweries = await mediator.Send( new GetAllBreweryLocationsQuery() @@ -84,14 +100,24 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } - /// Retrieves brewery posts with a set location within range of the given coordinates, nearest first. - /// The origin latitude, in decimal degrees. - /// The origin longitude, in decimal degrees. - /// The maximum distance, in metres, from the origin coordinates. - /// Allows anonymous access. + /// + /// Returns located brewery posts within the requested radius, ordered by proximity. + /// + /// + /// Identifies the origin latitude in decimal degrees. + /// + /// + /// Identifies the origin longitude in decimal degrees. + /// + /// + /// Sets the maximum distance from the origin in metres. + /// + /// This endpoint permits anonymous access. [AllowAnonymous] [HttpGet("locations/nearby")] - public async Task>>> GetLocationsWithinRange( + public async Task< + ActionResult>> + > GetLocationsWithinRange( [FromQuery] double latitude, [FromQuery] double longitude, [FromQuery] double rangeInMetres @@ -109,8 +135,10 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } - /// Creates a new brewery post. - /// 201 Created with the newly created brewery. + /// + /// Creates a brewery post for the authenticated user. + /// + /// A 201 Created response containing the new post. [HttpPost] public async Task>> Create( [FromBody] CreateBreweryRequest request @@ -134,12 +162,15 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } - /// Updates an existing brewery post. - /// Must match 's BreweryPostId. + /// + /// Updates a brewery post owned by the authenticated user. + /// + /// + /// Must equal the brewery post identifier in . + /// /// - /// 200 OK with the updated brewery; 400 Bad Request if the route ID does not match the - /// payload ID; 404 Not Found if the brewery or its CityId does not exist; or - /// 409 Conflict if the brewery was modified since request.RowVersion was read. + /// A successful response containing the updated post, or an error for an invalid ID, + /// missing resource, or stale row version. /// [HttpPut("{id:guid}")] public async Task>> Update( @@ -170,12 +201,16 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } - /// Deletes a brewery post. - /// 200 OK confirming the deletion; 404 Not Found if no brewery exists with . + /// + /// Deletes a brewery post owned by the authenticated user. + /// + /// + /// A confirmation response, or 404 Not Found if the post is absent. + /// [HttpDelete("{id:guid}")] public async Task> Delete(Guid id) { await mediator.Send(new DeleteBreweryCommand(id, User.GetAuthenticatedUserId())); return Ok(new ResponseBody { Message = "Brewery deleted successfully." }); } -} \ No newline at end of file +} diff --git a/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs index 79770f37..a5f27abd 100644 --- a/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs @@ -3,11 +3,15 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Breweries.DependencyInjection; -/// Registers the services required by the Breweries feature. +/// +/// Adds Breweries feature services to the dependency-injection container. +/// public static class FeaturesBreweriesServiceCollectionExtensions { - /// Registers and its implementation. - /// , for chaining. + /// + /// Registers the repository services used by the Breweries feature. + /// + /// The supplied service collection. public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services) { services.AddScoped(); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index a63ea99f..7402ed66 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -2,8 +2,12 @@ using Domain.Entities; namespace Features.Breweries.Dtos; -/// Location details of a brewery, as returned by the API. -/// The brewery's geographic coordinates, in a raw binary representation. +/// +/// Represents location data returned for a brewery post. +/// +/// +/// Contains the serialized geographic coordinates, when available. +/// public record BreweryLocationDto( Guid BreweryPostLocationId, Guid CityId, @@ -18,10 +22,18 @@ public record BreweryLocationDto( CoordinateData? Coordinates ); -/// A brewery post, as returned by the API. -/// The date and time of the last edit, or if never edited. -/// The row-version/concurrency token used to detect conflicting concurrent updates. -/// The brewery's location, or if none has been set. +/// +/// Represents a brewery post returned by the API. +/// +/// +/// Records the most recent update time, if the post has been updated. +/// +/// +/// Provides the concurrency token required for updates. +/// +/// +/// Contains the post's location, when one is available. +/// public record BreweryDto( Guid BreweryPostId, Guid PostedById, @@ -32,9 +44,12 @@ public record BreweryDto( byte[]? RowVersion, BreweryLocationDto? Location ); + +/// +/// Represents the location-focused result returned by brewery searches. +/// /// -/// The distance, in metres, from a query's origin point, or for queries with no -/// origin point (e.g. GET /api/brewery/locations). +/// Contains the distance from the search origin in metres, when applicable. /// public record BreweryWithLocationDto( Guid BreweryPostId, diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index 4cbebeed..7afb7309 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -2,74 +2,62 @@ using Domain.Entities; namespace Features.Breweries.Dtos; -/// Maps domain entities to . +/// +/// Converts brewery domain entities into API response models. +/// public static class BreweryDtoMapper { /// - /// Converts a to its DTO representation, including its - /// if the entity has one. + /// Converts a brewery post into its complete API representation. /// - public static BreweryDto ToDto(this BreweryPost brewery) - { - return new BreweryDto( - BreweryPostId: brewery.BreweryPostId, - PostedById: brewery.PostedById, - BreweryName: brewery.BreweryName, - Description: brewery.Description, - CreatedAt: brewery.CreatedAt, - UpdatedAt: brewery.UpdatedAt, - RowVersion: brewery.RowVersion, - Location: brewery.Location is null + public static BreweryDto ToDto(this BreweryPost brewery) => + new( + brewery.BreweryPostId, + brewery.PostedById, + brewery.BreweryName, + brewery.Description, + brewery.CreatedAt, + brewery.UpdatedAt, + brewery.RowVersion, + brewery.Location is null ? null : new BreweryLocationDto( - BreweryPostLocationId: brewery.Location.BreweryPostLocationId, - CityId: brewery.Location.CityId, - CityName: brewery.Location.City?.CityName ?? string.Empty, - StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName - ?? string.Empty, - StateProvinceCode: brewery.Location.City?.StateProvince?.Iso31662 - ?? string.Empty, - CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName - ?? string.Empty, - CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661 - ?? string.Empty, - AddressLine1: brewery.Location.AddressLine1, - AddressLine2: brewery.Location.AddressLine2, - PostalCode: brewery.Location.PostalCode, - Coordinates: brewery.Location.Coordinates + brewery.Location.BreweryPostLocationId, + brewery.Location.CityId, + brewery.Location.City?.CityName ?? string.Empty, + brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty, + brewery.Location.City?.StateProvince?.Iso31662 ?? string.Empty, + brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty, + brewery.Location.City?.StateProvince?.Country?.Iso31661 ?? string.Empty, + brewery.Location.AddressLine1, + brewery.Location.AddressLine2, + brewery.Location.PostalCode, + brewery.Location.Coordinates ) ); - } /// - /// Converts a to its simplified DTO representation, used for - /// location-based queries that only need identifying and location details. + /// Converts a brewery post into its location-search representation. /// - public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery) - { - return new BreweryWithLocationDto( - BreweryPostId: brewery.BreweryPostId, - BreweryName: brewery.BreweryName, - Location: brewery.Location is null + public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery) => + new( + brewery.BreweryPostId, + brewery.BreweryName, + brewery.Location is null ? null : new BreweryLocationDto( - BreweryPostLocationId: brewery.Location.BreweryPostLocationId, - CityId: brewery.Location.CityId, - CityName: brewery.Location.City?.CityName ?? string.Empty, - StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName - ?? string.Empty, - StateProvinceCode: brewery.Location.City?.StateProvince?.Iso31662 - ?? string.Empty, - CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName - ?? string.Empty, - CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661 - ?? string.Empty, - AddressLine1: brewery.Location.AddressLine1, - AddressLine2: brewery.Location.AddressLine2, - PostalCode: brewery.Location.PostalCode, - Coordinates: brewery.Location.Coordinates + brewery.Location.BreweryPostLocationId, + brewery.Location.CityId, + brewery.Location.City?.CityName ?? string.Empty, + brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty, + brewery.Location.City?.StateProvince?.Iso31662 ?? string.Empty, + brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty, + brewery.Location.City?.StateProvince?.Country?.Iso31661 ?? string.Empty, + brewery.Location.AddressLine1, + brewery.Location.AddressLine2, + brewery.Location.PostalCode, + brewery.Location.Coordinates ), - DistanceMetres: brewery.Distance?.DistanceMetres + brewery.Distance?.DistanceMetres ); - } } diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs index 2b0308c7..15a6da7e 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs @@ -5,11 +5,15 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweries; -/// Handles by retrieving brewery posts, newest first. +/// +/// Handles ordered, paged brewery-post queries. +/// public class GetAllBreweriesHandler(IBreweryRepository repository) : IRequestHandler> { - /// Retrieves brewery posts, newest first, applying 's paging. + /// + /// Retrieves and maps the requested page of brewery posts. + /// public async Task> Handle( GetAllBreweriesQuery request, CancellationToken cancellationToken diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs index 96267d81..bade0212 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs @@ -3,7 +3,13 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweries; -/// Retrieves brewery posts, newest first. Bound directly from the query string of GET /api/brewery. -/// Maximum number of breweries to return. Unbounded if . -/// Number of breweries to skip. Treated as zero if . +/// +/// Requests brewery posts ordered from newest to oldest. +/// +/// +/// Caps the result count; disables the cap. +/// +/// +/// Specifies the number of results to skip; means zero. +/// public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest>; diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs index 42e1c2c0..45f07fe3 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs @@ -5,11 +5,15 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweryLocations; -/// Handles by retrieving all located brewery posts. +/// +/// Handles queries for brewery posts with locations. +/// public class GetAllBreweryLocationsHandler(IBreweryRepository repository) : IRequestHandler> { - /// Retrieves every brewery post that has a set location. + /// + /// Retrieves and maps every brewery post that has a location. + /// public async Task> Handle( GetAllBreweryLocationsQuery request, CancellationToken cancellationToken diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs index b6ea4ea3..d9b3b7fb 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs @@ -4,7 +4,6 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweryLocations; /// -/// Retrieves all brewery posts that have a set location. Bound directly from -/// GET /api/brewery/locations. +/// Requests brewery posts that include a location. /// public record GetAllBreweryLocationsQuery : IRequest>; diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs index 1a27170b..e5678796 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs @@ -5,17 +5,24 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryById; -/// Handles by retrieving the matching brewery post. +/// +/// Handles lookup requests for individual brewery posts. +/// public class GetBreweryByIdHandler(IBreweryRepository repository) : IRequestHandler { - /// Retrieves the brewery post, or if none exists with the given ID. + /// + /// Retrieves and maps the post, if it exists. + /// public async Task Handle( GetBreweryByIdQuery request, CancellationToken cancellationToken ) { - BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken); + BreweryPost? brewery = await repository.GetByIdAsync( + request.BreweryPostId, + cancellationToken + ); return brewery?.ToDto(); } } diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs index 775edc92..8d3c46e2 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs @@ -3,6 +3,10 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryById; -/// Retrieves a brewery post by ID. Bound directly from the route of GET /api/brewery/{id}. -/// Yields if no brewery exists with the given BreweryPostId. +/// +/// Requests a brewery post by its identifier. +/// +/// +/// The handler returns when the post is absent. +/// public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest; diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs index 00b7a7ce..d21110d5 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs @@ -6,12 +6,14 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; /// -/// Handles by retrieving nearby located brewery posts. +/// Handles proximity searches for brewery locations. /// public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository) : IRequestHandler> { - /// Retrieves brewery posts within range of the requested coordinates, nearest first. + /// + /// Retrieves and maps nearby brewery locations. + /// public async Task> Handle( GetBreweryLocationsWithinRangeQuery request, CancellationToken cancellationToken diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs index f7322485..4adeadf9 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs @@ -4,12 +4,19 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; /// -/// Retrieves brewery posts with a set location within of the given -/// coordinates, nearest first. Bound directly from the query string of -/// GET /api/brewery/locations/nearby. +/// Requests located brewery posts near a geographic point, nearest first. /// -/// The origin latitude, in decimal degrees. -/// The origin longitude, in decimal degrees. -/// The maximum distance, in metres, from the origin coordinates. -public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres) - : IRequest>; +/// +/// Sets the origin latitude in decimal degrees. +/// +/// +/// Sets the origin longitude in decimal degrees. +/// +/// +/// Sets the inclusive search radius in metres. +/// +public record GetBreweryLocationsWithinRangeQuery( + double Latitude, + double Longitude, + double RangeInMetres +) : IRequest>; diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeValidator.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeValidator.cs index 8b2e3fda..cdaad6b5 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeValidator.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeValidator.cs @@ -2,7 +2,9 @@ using FluentValidation; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; -/// Validates . +/// +/// Validates coordinates and radius values for proximity searches. +/// public class GetBreweryLocationsWithinRangeValidator : AbstractValidator { diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryOwnershipExtensions.cs b/web/backend/Features/Features.Breweries/Repository/BreweryOwnershipExtensions.cs index 83169d6f..c3731a4f 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryOwnershipExtensions.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryOwnershipExtensions.cs @@ -2,11 +2,20 @@ using Domain.Exceptions; namespace Features.Breweries.Repository; -/// Shared ownership check for commands that mutate a specific brewery post. +/// +/// Provides authorization checks for brewery-post mutations. +/// public static class BreweryOwnershipExtensions { - /// Thrown when no brewery exists with the given ID. - /// Thrown when did not post the brewery. + /// + /// Verifies that the specified user created the brewery post. + /// + /// + /// Thrown if the brewery post cannot be found. + /// + /// + /// Thrown if the specified user does not own the brewery post. + /// public static async Task EnsureCallerOwnsBreweryAsync( this IBreweryRepository repository, Guid breweryPostId, diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 8c8cac7a..e078a28c 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -7,14 +7,11 @@ using Domain.Exceptions; namespace Features.Breweries.Repository; /// -/// Dapper-based implementation of . +/// Implements brewery persistence with Dapper. /// /// -/// BreweryPostLocation.Coordinates is a SQL Server GEOGRAPHY column. Dapper's generic -/// Query<T> deserializer reads columns via IDataRecord.GetValue, which for a UDT -/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to byte[]. -/// The read queries below therefore select CONVERT(varbinary(max), bpl.Coordinates), which -/// yields the UDT's serialized bytes as a plain varbinary value that Dapper can bind directly. +/// Coordinate queries convert SQL Server GEOGRAPHY values to varbinary(max) +/// so Dapper can deserialize them as binary coordinate data. /// public class BreweryRepository(ISqlConnectionFactory connectionFactory) : DapperRepository(connectionFactory), @@ -27,8 +24,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler()); } - /// - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + /// Gets a brewery post with its location data. + public async Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default + ) { await using DbConnection connection = await CreateConnection(); IEnumerable results = await connection.QueryAsync< @@ -73,14 +73,17 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) cancellationToken: cancellationToken ), MapBreweryRow, - splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + "BreweryPostLocationID,CityID,StateProvinceID,CountryID" ); return results.SingleOrDefault(); } - /// - public async Task GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default) + /// Gets the identifier of the user who created a brewery post. + public async Task GetPostedByIdAsync( + Guid id, + CancellationToken cancellationToken = default + ) { await using DbConnection connection = await CreateConnection(); return await connection.ExecuteScalarAsync( @@ -92,7 +95,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) ); } - /// + /// Gets brewery posts in reverse chronological order. public async Task> GetAllAsync(int? limit, int? offset) { await using DbConnection connection = await CreateConnection(); @@ -140,125 +143,141 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) ); } - /// - public async Task> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres) + /// Gets brewery posts within the requested distance from an origin. + public async Task> GetAllLocationsWithinRange( + CoordinateData coords, + double rangeInMetres + ) { await using DbConnection connection = await CreateConnection(); return await connection.QueryAsync< - BreweryPost, - BreweryPostLocation, - City, - StateProvince, - Country, - DistanceRow, - BreweryPost - > ( - """ - DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326); + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + DistanceRow, + BreweryPost + >( + """ + DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326); - SELECT - bp.BreweryPostID, - bp.BreweryName, - bpl.BreweryPostLocationID, - bpl.AddressLine1, - bpl.AddressLine2, - bpl.PostalCode, - CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, - c.CityID, - c.CityName, - sp.StateProvinceID, - sp.StateProvinceName, - sp.ISO3166_2 AS Iso31662, - co.CountryID, - co.CountryName, - co.ISO3166_1 AS Iso31661, - bpl.Coordinates.STDistance(@Origin) AS DistanceMetres - FROM Brewery.BreweryPost bp - INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID - LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID - LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID - LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID - WHERE bpl.Coordinates IS NOT NULL - AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres - ORDER BY bpl.Coordinates.STDistance(@Origin) ASC - """, - (post, location, city, stateProvince, country, distance) => - MapBreweryRowWithDistance(post, location, city, stateProvince, country, distance, coords), - param: new - { - coords.Latitude, - coords.Longitude, - RangeInMetres = rangeInMetres - }, - splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres" - ); + SELECT + bp.BreweryPostID, + bp.BreweryName, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2 AS Iso31662, + co.CountryID, + co.CountryName, + co.ISO3166_1 AS Iso31661, + bpl.Coordinates.STDistance(@Origin) AS DistanceMetres + FROM Brewery.BreweryPost bp + INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID + LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID + WHERE bpl.Coordinates IS NOT NULL + AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres + ORDER BY bpl.Coordinates.STDistance(@Origin) ASC + """, + (post, location, city, stateProvince, country, distance) => + MapBreweryRowWithDistance( + post, + location, + city, + stateProvince, + country, + distance, + coords + ), + new + { + coords.Latitude, + coords.Longitude, + RangeInMetres = rangeInMetres, + }, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres" + ); } - /// + /// Gets brewery posts that have location data. public async Task> GetAllLocations() { await using DbConnection connection = await CreateConnection(); return await connection.QueryAsync< - BreweryPost, - BreweryPostLocation, - City, - StateProvince, - Country, - BreweryPost - > ( - """ - SELECT - bp.BreweryPostID, - bp.BreweryName, - bpl.BreweryPostLocationID, - bpl.AddressLine1, - bpl.AddressLine2, - bpl.PostalCode, - CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, - c.CityID, - c.CityName, - sp.StateProvinceID, - sp.StateProvinceName, - sp.ISO3166_2 AS Iso31662, - co.CountryID, - co.CountryName, - co.ISO3166_1 AS Iso31661 - FROM Brewery.BreweryPost bp - INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID - LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID - LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID - LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID - WHERE bpl.Coordinates IS NOT NULL - ORDER BY bp.BreweryPostID ASC - """, - MapBreweryRow, - splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" - ); + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + BreweryPost + >( + """ + SELECT + bp.BreweryPostID, + bp.BreweryName, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2 AS Iso31662, + co.CountryID, + co.CountryName, + co.ISO3166_1 AS Iso31661 + FROM Brewery.BreweryPost bp + INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID + LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID + WHERE bpl.Coordinates IS NOT NULL + ORDER BY bp.BreweryPostID ASC + """, + MapBreweryRow, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + ); } - /// - public async Task UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default) + /// Updates a brewery post and its location in a transaction. + public async Task UpdateAsync( + BreweryPost brewery, + CancellationToken cancellationToken = default + ) { await using DbConnection connection = await CreateConnection(); - await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken); + await using DbTransaction transaction = await connection.BeginTransactionAsync( + cancellationToken + ); try { bool breweryExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 FROM - Brewery.BreweryPost - WHERE - BreweryPostID = @BreweryPostId - """, - new { brewery.BreweryPostId }, - transaction, - cancellationToken: cancellationToken - ) + new CommandDefinition( + """ + SELECT 1 FROM + Brewery.BreweryPost + WHERE + BreweryPostID = @BreweryPostId + """, + new { brewery.BreweryPostId }, + transaction, + cancellationToken: cancellationToken ) - is not null; + ) + is not null; if (!breweryExists) throw new NotFoundException("Brewery not found."); @@ -267,14 +286,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { bool cityExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - "SELECT 1 FROM Geolocation.City WHERE CityID = @CityId", - new { brewery.Location.CityId }, - transaction, - cancellationToken: cancellationToken - ) + new CommandDefinition( + "SELECT 1 FROM Geolocation.City WHERE CityID = @CityId", + new { brewery.Location.CityId }, + transaction, + cancellationToken: cancellationToken ) - is not null; + ) + is not null; if (!cityExists) throw new NotFoundException("City not found."); @@ -328,18 +347,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { bool locationExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 - FROM Brewery.BreweryPostLocation - WHERE BreweryPostID = @BreweryPostId - """, - new { brewery.BreweryPostId }, - transaction, - cancellationToken: cancellationToken - ) + new CommandDefinition( + """ + SELECT 1 + FROM Brewery.BreweryPostLocation + WHERE BreweryPostID = @BreweryPostId + """, + new { brewery.BreweryPostId }, + transaction, + cancellationToken: cancellationToken ) - is not null; + ) + is not null; if (locationExists) await connection.ExecuteAsync( @@ -363,8 +382,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) brewery.Location.AddressLine1, brewery.Location.AddressLine2, brewery.Location.PostalCode, - Latitude = brewery.Location.Coordinates?.Latitude, - Longitude = brewery.Location.Coordinates?.Longitude, + brewery.Location.Coordinates?.Latitude, + brewery.Location.Coordinates?.Longitude, Srid = GeographySrid, }, transaction, @@ -403,8 +422,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) brewery.Location.AddressLine1, brewery.Location.AddressLine2, brewery.Location.PostalCode, - Latitude = brewery.Location.Coordinates?.Latitude, - Longitude = brewery.Location.Coordinates?.Longitude, + brewery.Location.Coordinates?.Latitude, + brewery.Location.Coordinates?.Longitude, Srid = GeographySrid, }, transaction, @@ -422,12 +441,12 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) } return await GetByIdAsync(brewery.BreweryPostId, cancellationToken) - ?? throw new InvalidOperationException( - $"Brewery '{brewery.BreweryPostId}' was not found after a successful update." - ); + ?? throw new InvalidOperationException( + $"Brewery '{brewery.BreweryPostId}' was not found after a successful update." + ); } - /// + /// Deletes the brewery post identified by . public async Task DeleteAsync(Guid id) { await using DbConnection connection = await CreateConnection(); @@ -445,7 +464,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) throw new NotFoundException("Brewery not found."); } - /// + /// Creates a brewery post and its location in a transaction. public async Task CreateAsync(BreweryPost brewery) { if (brewery.Location is null) @@ -458,38 +477,38 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { bool userExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 - FROM - Auth.UserAccount - WHERE - UserAccountID = @PostedById - """, - new { brewery.PostedById }, - transaction - ) + new CommandDefinition( + """ + SELECT 1 + FROM + Auth.UserAccount + WHERE + UserAccountID = @PostedById + """, + new { brewery.PostedById }, + transaction ) - is not null; + ) + is not null; if (!userExists) throw new NotFoundException("User not found."); bool cityExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 - FROM - Geolocation.City - WHERE - CityID = @CityId - """, - new { brewery.Location.CityId }, - transaction - ) + new CommandDefinition( + """ + SELECT 1 + FROM + Geolocation.City + WHERE + CityID = @CityId + """, + new { brewery.Location.CityId }, + transaction ) - is not null; + ) + is not null; if (!cityExists) throw new NotFoundException("City not found."); @@ -543,8 +562,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) brewery.Location.AddressLine1, brewery.Location.AddressLine2, brewery.Location.PostalCode, - Latitude = brewery.Location.Coordinates?.Latitude, - Longitude = brewery.Location.Coordinates?.Longitude, + brewery.Location.Coordinates?.Latitude, + brewery.Location.Coordinates?.Longitude, Srid = GeographySrid, }, transaction @@ -560,15 +579,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) } } - /// - /// Composes a joined row's , , - /// , and fragments into a - /// single populated . When the row has no location (a left-joined - /// brewery with no address on file), Dapper passes for - /// rather than a blank instance, since its identity column - /// (BreweryPostLocationID) is NULL; the schema guarantees that whenever a location - /// row was joined in, its City/StateProvince/Country chain was too. + /// Reconstructs a brewery post and its optional location hierarchy from a joined row. /// private static BreweryPost MapBreweryRow( BreweryPost post, @@ -596,15 +608,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) return post; } - /// Dapper multi-mapping row for the DistanceMetres column computed by a range query. - private sealed class DistanceRow - { - public double DistanceMetres { get; set; } - } - /// - /// Composes a joined row via , then attaches the DB-computed distance - /// from as the result's . + /// Reconstructs a brewery row and attaches its database-calculated distance. /// private static BreweryPost MapBreweryRowWithDistance( BreweryPost post, @@ -620,4 +625,12 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres); return mapped; } -} \ No newline at end of file + + /// + /// Holds the distance calculated by a proximity query. + /// + private sealed class DistanceRow + { + public double DistanceMetres { get; set; } + } +} diff --git a/web/backend/Features/Features.Breweries/Repository/CoordinateDataTypeHandler.cs b/web/backend/Features/Features.Breweries/Repository/CoordinateDataTypeHandler.cs index 2068aceb..322272d1 100644 --- a/web/backend/Features/Features.Breweries/Repository/CoordinateDataTypeHandler.cs +++ b/web/backend/Features/Features.Breweries/Repository/CoordinateDataTypeHandler.cs @@ -7,9 +7,7 @@ using NetTopologySuite.IO; namespace Features.Breweries.Repository; /// -/// Dapper that deserializes a SQL Server GEOGRAPHY -/// column (read as varbinary(max), see ) into a -/// . +/// Converts SQL Server geography values read as binary data into . /// public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler { diff --git a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs index c429616a..5821b54e 100644 --- a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs @@ -3,69 +3,80 @@ using Domain.Entities; namespace Features.Breweries.Repository; /// -/// Repository for CRUD operations on brewery post records. +/// Defines persistence operations for brewery posts. /// public interface IBreweryRepository { /// - /// Retrieves a brewery post by ID, joined to its location. Returns null if no brewery post - /// exists with the given ID, or if it has no associated location. + /// Gets a brewery post and its location data by identifier. /// Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); /// - /// Retrieves the PostedById of the brewery post with the given ID, without joining its - /// location. Returns null if no brewery post exists with the given ID. + /// Gets the identifier of the user who created a brewery post. /// Task GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default); /// - /// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts - /// without a location are included, with left null. + /// Gets brewery posts ordered by creation time, with optional paging. /// - /// Maximum number of rows to return. Unbounded if . - /// Number of rows to skip. Treated as zero if . + /// + /// Limits returned posts; applies no limit. + /// + /// + /// Skips returned posts; skips none. + /// Task> GetAllAsync(int? limit, int? offset); /// - /// Retrieves brewery posts with a set location within of - /// , nearest first. Posts without coordinates are excluded. + /// Gets located brewery posts within a radius, ordered by distance. /// - /// The origin point to measure distance from. - /// The maximum distance, in metres, from . - Task> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres); + /// + /// Sets the point from which distance is measured. + /// + /// + /// Sets the maximum search distance in metres. + /// + Task> GetAllLocationsWithinRange( + CoordinateData coords, + double rangeInMetres + ); /// - /// Retrieves all brewery posts that have a set location, ordered by BreweryPostId. Posts - /// without coordinates are excluded. + /// Gets brewery posts with location data. /// Task> GetAllLocations(); /// - /// Updates a brewery post's name and description, and upserts or clears its location, enforcing - /// optimistic concurrency via 's RowVersion: the update is rejected - /// if the row was modified since RowVersion was read. When 's - /// Location is null, any existing location for the brewery is removed. - /// Returns the freshly persisted brewery, including its new RowVersion. + /// Saves a brewery post and its location using optimistic concurrency. /// /// - /// Thrown when no brewery exists with the given ID, or the given location's CityId does not - /// exist. + /// Thrown if the brewery post or its requested city cannot be found. /// /// - /// Thrown when 's RowVersion no longer matches the stored row (it was - /// modified by another request since it was last read). + /// Thrown if the supplied row version is no longer current. /// - Task UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default); + Task UpdateAsync( + BreweryPost brewery, + CancellationToken cancellationToken = default + ); - /// Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys. - /// Thrown when no brewery exists with the given . + /// + /// Deletes a brewery post and its dependent records. + /// + /// + /// Thrown if the brewery post cannot be found. + /// Task DeleteAsync(Guid id); - /// Creates a new brewery post, including its location details. - /// Thrown when has no Location. + /// + /// Creates a brewery post and its required location. + /// + /// + /// Thrown if has no location. + /// /// - /// Thrown when 's PostedById or Location.CityId does not exist. + /// Thrown if the posting user or location city cannot be found. /// Task CreateAsync(BreweryPost brewery); -} \ No newline at end of file +}