Update doc comments in Features.Breweries source

This commit is contained in:
Aaron Po
2026-09-05 03:55:18 -04:00
parent 272a25fb05
commit dd81d98008
29 changed files with 517 additions and 388 deletions

View File

@@ -17,10 +17,8 @@ public class CreateBreweryHandlerTests
_handler = new CreateBreweryHandler(_repoMock.Object); _handler = new CreateBreweryHandler(_repoMock.Object);
} }
private static CreateBreweryLocation ValidLocation() private static CreateBreweryLocation ValidLocation() =>
{ new(Guid.NewGuid(), "123 Main St", null, "12345", null);
return new CreateBreweryLocation(Guid.NewGuid(), "123 Main St", null, "12345", null);
}
[Fact] [Fact]
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt() public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()

View File

@@ -8,18 +8,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72"/>
<PackageReference Include="FluentAssertions" Version="7.2.0" /> <PackageReference Include="FluentAssertions" Version="7.2.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -36,8 +36,10 @@ public class GetAllBreweryLocationsHandlerTests
{ {
BreweryPost[] breweries = BreweryPost[] breweries =
[ [
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" }, new()
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }, { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
new()
{ BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
]; ];
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries); _repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);

View File

@@ -50,8 +50,10 @@ public class GetBreweryLocationsWithinRangeHandlerTests
{ {
BreweryPost[] breweries = BreweryPost[] breweries =
[ [
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" }, new()
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" }, { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" },
new()
{ BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" },
]; ];
_repoMock _repoMock
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>())) .Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>()))

View File

@@ -4,8 +4,12 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Location details for a brewery being created.</summary> /// <summary>
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param> /// Describes the location supplied when creating a brewery.
/// </summary>
/// <param name="Coordinates">
/// Serialized geographic coordinates for the brewery.
/// </param>
public record CreateBreweryLocation( public record CreateBreweryLocation(
Guid CityId, Guid CityId,
string AddressLine1, string AddressLine1,
@@ -15,7 +19,7 @@ public record CreateBreweryLocation(
); );
/// <summary> /// <summary>
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>. /// Represents the application request used to create a brewery post.
/// </summary> /// </summary>
public record CreateBreweryCommand( public record CreateBreweryCommand(
Guid PostedById, Guid PostedById,
@@ -24,9 +28,11 @@ public record CreateBreweryCommand(
CreateBreweryLocation Location CreateBreweryLocation Location
) : IRequest<BreweryDto>; ) : IRequest<BreweryDto>;
/// <summary>Request body for <c>POST /api/brewery</c>. <c>PostedById</c> comes from the caller's JWT.</summary> /// <summary>
/// Defines the client-supplied body for creating a brewery post.
/// </summary>
public record CreateBreweryRequest( public record CreateBreweryRequest(
string BreweryName, string BreweryName,
string Description, string Description,
CreateBreweryLocation Location CreateBreweryLocation Location
); );

View File

@@ -5,13 +5,17 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post.</summary> /// <summary>
/// Persists brewery posts created through <see cref="CreateBreweryCommand" />.
/// </summary>
public class CreateBreweryHandler(IBreweryRepository repository) public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto> : IRequestHandler<CreateBreweryCommand, BreweryDto>
{ {
/// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary> /// <summary>
/// Creates the post and its associated location with new identifiers.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when <paramref name="request" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist. /// Thrown if the posting user or the selected city cannot be found.
/// </exception> /// </exception>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
CreateBreweryCommand request, CreateBreweryCommand request,
@@ -38,10 +42,6 @@ public class CreateBreweryHandler(IBreweryRepository repository)
await repository.CreateAsync(entity); 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 = BreweryPost created =
await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None) await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(

View File

@@ -2,7 +2,9 @@ using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Validates <see cref="CreateBreweryCommand" />.</summary> /// <summary>
/// Applies creation rules to <see cref="CreateBreweryCommand" />.
/// </summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand> public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{ {
public CreateBreweryValidator() public CreateBreweryValidator()
@@ -27,7 +29,6 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
RuleFor(x => x.Location.AddressLine1) RuleFor(x => x.Location.AddressLine1)
.NotEmpty() .NotEmpty()
.When(x => x.Location is not null)
.WithMessage("Address line 1 is required.") .WithMessage("Address line 1 is required.")
.MaximumLength(256) .MaximumLength(256)
.WithMessage("Address line 1 cannot exceed 256 characters."); .WithMessage("Address line 1 cannot exceed 256 characters.");

View File

@@ -2,5 +2,7 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Deletes a brewery post. <see cref="BreweryPostId" /> is bound from the route of <c>DELETE /api/brewery/{id}</c>.</summary> /// <summary>
/// Represents a request to delete a brewery post on behalf of a caller.
/// </summary>
public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest; public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest;

View File

@@ -3,15 +3,20 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Handles <see cref="DeleteBreweryCommand" /> by deleting the corresponding brewery post.</summary> /// <summary>
/// Processes <see cref="DeleteBreweryCommand" /> requests.
/// </summary>
public class DeleteBreweryHandler(IBreweryRepository repository) public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand> : IRequestHandler<DeleteBreweryCommand>
{ {
/// <summary>
/// Confirms that the caller owns the post before deleting it.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>. /// Thrown if the brewery post does not exist.
/// </exception> /// </exception>
/// <exception cref="Domain.Exceptions.ForbiddenException"> /// <exception cref="Domain.Exceptions.ForbiddenException">
/// Thrown when <paramref name="request" />'s <c>RequestingUserId</c> did not post the brewery. /// Thrown if the caller did not create the brewery post.
/// </exception> /// </exception>
public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
{ {

View File

@@ -4,11 +4,15 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Location details for a brewery being updated.</summary> /// <summary>
/// Describes the location supplied when updating a brewery.
/// </summary>
/// <param name="BreweryPostLocationId"> /// <param name="BreweryPostLocationId">
/// 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.
/// </param>
/// <param name="Coordinates">
/// Serialized geographic coordinates for the brewery.
/// </param> /// </param>
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
public record UpdateBreweryLocation( public record UpdateBreweryLocation(
Guid BreweryPostLocationId, Guid BreweryPostLocationId,
Guid CityId, Guid CityId,
@@ -19,9 +23,8 @@ public record UpdateBreweryLocation(
); );
/// <summary> /// <summary>
/// Updates an existing brewery post. A <c>null</c> <see cref="Location" /> clears the brewery's location. /// Represents a concurrency-protected change to an existing brewery post.
/// <see cref="RowVersion" /> must be the row-version last read for this brewery (e.g. from a prior /// A <see langword="null" /> <see cref="Location" /> removes the existing location.
/// <c>GET</c>); the update is rejected with a <c>409 Conflict</c> if the brewery was modified since then.
/// </summary> /// </summary>
public record UpdateBreweryCommand( public record UpdateBreweryCommand(
Guid BreweryPostId, Guid BreweryPostId,
@@ -32,7 +35,9 @@ public record UpdateBreweryCommand(
UpdateBreweryLocation? Location UpdateBreweryLocation? Location
) : IRequest<BreweryDto>; ) : IRequest<BreweryDto>;
/// <summary>Request body for <c>PUT /api/brewery/{id}</c>.</summary> /// <summary>
/// Defines the client-supplied body for updating a brewery post.
/// </summary>
public record UpdateBreweryRequest( public record UpdateBreweryRequest(
Guid BreweryPostId, Guid BreweryPostId,
byte[] RowVersion, byte[] RowVersion,
@@ -40,4 +45,3 @@ public record UpdateBreweryRequest(
string Description, string Description,
UpdateBreweryLocation? Location UpdateBreweryLocation? Location
); );

View File

@@ -5,21 +5,20 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.</summary> /// <summary>
/// Persists changes requested through <see cref="UpdateBreweryCommand" />.
/// </summary>
public class UpdateBreweryHandler(IBreweryRepository repository) public class UpdateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<UpdateBreweryCommand, BreweryDto> : IRequestHandler<UpdateBreweryCommand, BreweryDto>
{ {
/// <summary> /// <summary>
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>, /// Updates the post after confirming that the caller owns it.
/// the brewery's location is cleared.
/// </summary> /// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>, or its /// Thrown if the brewery post or its requested city cannot be found.
/// location's <c>CityId</c> does not exist.
/// </exception> /// </exception>
/// <exception cref="Domain.Exceptions.ConflictException"> /// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when the brewery was modified by another request since <paramref name="request" />. /// Thrown if the post has changed since its supplied row version was read.
/// <c>RowVersion</c> was read.
/// </exception> /// </exception>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
UpdateBreweryCommand request, UpdateBreweryCommand request,

View File

@@ -2,7 +2,9 @@ using FluentValidation;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Validates <see cref="UpdateBreweryCommand" />.</summary> /// <summary>
/// Applies update rules to <see cref="UpdateBreweryCommand" />.
/// </summary>
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand> public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
{ {
public UpdateBreweryValidator() public UpdateBreweryValidator()

View File

@@ -13,19 +13,25 @@ using Shared.Contracts;
namespace Features.Breweries.Controllers; namespace Features.Breweries.Controllers;
/// <summary>Exposes CRUD operations for brewery posts.</summary> /// <summary>
/// Provides HTTP endpoints for reading and managing brewery posts.
/// </summary>
/// <remarks> /// <remarks>
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via /// Requests require JWT authentication unless the endpoint allows anonymous access.
/// <c>[AllowAnonymous]</c>.
/// </remarks> /// </remarks>
///
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")] [Authorize(AuthenticationSchemes = "JWT")]
public class BreweryController(IMediator mediator) : ControllerBase public class BreweryController(IMediator mediator) : ControllerBase
{ {
/// <summary>Retrieves a single brewery post by ID.</summary> /// <summary>
/// <remarks>Allows anonymous access.</remarks> /// Returns the brewery post identified by <paramref name="id" />.
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns> /// </summary>
/// <remarks>This endpoint permits anonymous access.</remarks>
/// <returns>
/// A successful response containing the post, or <c>404 Not Found</c> when it is absent.
/// </returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id) public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
@@ -43,10 +49,16 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Retrieves brewery posts, newest first.</summary> /// <summary>
/// <param name="limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param> /// Returns brewery posts in reverse chronological order.
/// <param name="offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param> /// </summary>
/// <remarks>Allows anonymous access.</remarks> /// <param name="limit">
/// Limits the number of returned posts; <see langword="null" /> leaves it unlimited.
/// </param>
/// <param name="offset">
/// Skips this many posts; <see langword="null" /> means none.
/// </param>
/// <remarks>This endpoint permits anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet] [HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll( public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
@@ -66,11 +78,15 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Retrieves all brewery posts that have a set location.</summary> /// <summary>
/// <remarks>Allows anonymous access.</remarks> /// Returns all brewery posts with an associated location.
/// </summary>
/// <remarks>This endpoint permits anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("locations")] [HttpGet("locations")]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetAllLocations() public async Task<
ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>
> GetAllLocations()
{ {
IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send( IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send(
new GetAllBreweryLocationsQuery() new GetAllBreweryLocationsQuery()
@@ -84,14 +100,24 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Retrieves brewery posts with a set location within range of the given coordinates, nearest first.</summary> /// <summary>
/// <param name="latitude">The origin latitude, in decimal degrees.</param> /// Returns located brewery posts within the requested radius, ordered by proximity.
/// <param name="longitude">The origin longitude, in decimal degrees.</param> /// </summary>
/// <param name="rangeInMetres">The maximum distance, in metres, from the origin coordinates.</param> /// <param name="latitude">
/// <remarks>Allows anonymous access.</remarks> /// Identifies the origin latitude in decimal degrees.
/// </param>
/// <param name="longitude">
/// Identifies the origin longitude in decimal degrees.
/// </param>
/// <param name="rangeInMetres">
/// Sets the maximum distance from the origin in metres.
/// </param>
/// <remarks>This endpoint permits anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("locations/nearby")] [HttpGet("locations/nearby")]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetLocationsWithinRange( public async Task<
ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>
> GetLocationsWithinRange(
[FromQuery] double latitude, [FromQuery] double latitude,
[FromQuery] double longitude, [FromQuery] double longitude,
[FromQuery] double rangeInMetres [FromQuery] double rangeInMetres
@@ -109,8 +135,10 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Creates a new brewery post.</summary> /// <summary>
/// <returns><c>201 Created</c> with the newly created brewery.</returns> /// Creates a brewery post for the authenticated user.
/// </summary>
/// <returns>A <c>201 Created</c> response containing the new post.</returns>
[HttpPost] [HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create( public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
[FromBody] CreateBreweryRequest request [FromBody] CreateBreweryRequest request
@@ -134,12 +162,15 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Updates an existing brewery post.</summary> /// <summary>
/// <param name="id">Must match <paramref name="request" />'s <c>BreweryPostId</c>.</param> /// Updates a brewery post owned by the authenticated user.
/// </summary>
/// <param name="id">
/// Must equal the brewery post identifier in <paramref name="request" />.
/// </param>
/// <returns> /// <returns>
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the /// A successful response containing the updated post, or an error for an invalid ID,
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or /// missing resource, or stale row version.
/// <c>409 Conflict</c> if the brewery was modified since <c>request.RowVersion</c> was read.
/// </returns> /// </returns>
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update( public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
@@ -170,12 +201,16 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Deletes a brewery post.</summary> /// <summary>
/// <returns><c>200 OK</c> confirming the deletion; <c>404 Not Found</c> if no brewery exists with <paramref name="id" />.</returns> /// Deletes a brewery post owned by the authenticated user.
/// </summary>
/// <returns>
/// A confirmation response, or <c>404 Not Found</c> if the post is absent.
/// </returns>
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id) public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{ {
await mediator.Send(new DeleteBreweryCommand(id, User.GetAuthenticatedUserId())); await mediator.Send(new DeleteBreweryCommand(id, User.GetAuthenticatedUserId()));
return Ok(new ResponseBody { Message = "Brewery deleted successfully." }); return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
} }
} }

View File

@@ -3,11 +3,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Breweries.DependencyInjection; namespace Features.Breweries.DependencyInjection;
/// <summary>Registers the services required by the Breweries feature.</summary> /// <summary>
/// Adds Breweries feature services to the dependency-injection container.
/// </summary>
public static class FeaturesBreweriesServiceCollectionExtensions public static class FeaturesBreweriesServiceCollectionExtensions
{ {
/// <summary>Registers <see cref="IBreweryRepository" /> and its implementation.</summary> /// <summary>
/// <returns><paramref name="services" />, for chaining.</returns> /// Registers the repository services used by the Breweries feature.
/// </summary>
/// <returns>The supplied service collection.</returns>
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services) public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
{ {
services.AddScoped<IBreweryRepository, BreweryRepository>(); services.AddScoped<IBreweryRepository, BreweryRepository>();

View File

@@ -2,8 +2,12 @@ using Domain.Entities;
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>Location details of a brewery, as returned by the API.</summary> /// <summary>
/// <param name="Coordinates">The brewery's geographic coordinates, in a raw binary representation.</param> /// Represents location data returned for a brewery post.
/// </summary>
/// <param name="Coordinates">
/// Contains the serialized geographic coordinates, when available.
/// </param>
public record BreweryLocationDto( public record BreweryLocationDto(
Guid BreweryPostLocationId, Guid BreweryPostLocationId,
Guid CityId, Guid CityId,
@@ -18,10 +22,18 @@ public record BreweryLocationDto(
CoordinateData? Coordinates CoordinateData? Coordinates
); );
/// <summary>A brewery post, as returned by the API.</summary> /// <summary>
/// <param name="UpdatedAt">The date and time of the last edit, or <see langword="null"/> if never edited.</param> /// Represents a brewery post returned by the API.
/// <param name="RowVersion">The row-version/concurrency token used to detect conflicting concurrent updates.</param> /// </summary>
/// <param name="Location">The brewery's location, or <see langword="null"/> if none has been set.</param> /// <param name="UpdatedAt">
/// Records the most recent update time, if the post has been updated.
/// </param>
/// <param name="RowVersion">
/// Provides the concurrency token required for updates.
/// </param>
/// <param name="Location">
/// Contains the post's location, when one is available.
/// </param>
public record BreweryDto( public record BreweryDto(
Guid BreweryPostId, Guid BreweryPostId,
Guid PostedById, Guid PostedById,
@@ -32,9 +44,12 @@ public record BreweryDto(
byte[]? RowVersion, byte[]? RowVersion,
BreweryLocationDto? Location BreweryLocationDto? Location
); );
/// <summary>
/// Represents the location-focused result returned by brewery searches.
/// </summary>
/// <param name="DistanceMetres"> /// <param name="DistanceMetres">
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no /// Contains the distance from the search origin in metres, when applicable.
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
/// </param> /// </param>
public record BreweryWithLocationDto( public record BreweryWithLocationDto(
Guid BreweryPostId, Guid BreweryPostId,

View File

@@ -2,74 +2,62 @@ using Domain.Entities;
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>Maps <see cref="BreweryPost" /> domain entities to <see cref="BreweryDto" />.</summary> /// <summary>
/// Converts brewery domain entities into API response models.
/// </summary>
public static class BreweryDtoMapper public static class BreweryDtoMapper
{ {
/// <summary> /// <summary>
/// Converts a <see cref="BreweryPost" /> to its DTO representation, including its /// Converts a brewery post into its complete API representation.
/// <see cref="BreweryDto.Location" /> if the entity has one.
/// </summary> /// </summary>
public static BreweryDto ToDto(this BreweryPost brewery) public static BreweryDto ToDto(this BreweryPost brewery) =>
{ new(
return new BreweryDto( brewery.BreweryPostId,
BreweryPostId: brewery.BreweryPostId, brewery.PostedById,
PostedById: brewery.PostedById, brewery.BreweryName,
BreweryName: brewery.BreweryName, brewery.Description,
Description: brewery.Description, brewery.CreatedAt,
CreatedAt: brewery.CreatedAt, brewery.UpdatedAt,
UpdatedAt: brewery.UpdatedAt, brewery.RowVersion,
RowVersion: brewery.RowVersion, brewery.Location is null
Location: brewery.Location is null
? null ? null
: new BreweryLocationDto( : new BreweryLocationDto(
BreweryPostLocationId: brewery.Location.BreweryPostLocationId, brewery.Location.BreweryPostLocationId,
CityId: brewery.Location.CityId, brewery.Location.CityId,
CityName: brewery.Location.City?.CityName ?? string.Empty, brewery.Location.City?.CityName ?? string.Empty,
StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Iso31662 ?? string.Empty,
StateProvinceCode: brewery.Location.City?.StateProvince?.Iso31662 brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Country?.Iso31661 ?? string.Empty,
CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName brewery.Location.AddressLine1,
?? string.Empty, brewery.Location.AddressLine2,
CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661 brewery.Location.PostalCode,
?? string.Empty, brewery.Location.Coordinates
AddressLine1: brewery.Location.AddressLine1,
AddressLine2: brewery.Location.AddressLine2,
PostalCode: brewery.Location.PostalCode,
Coordinates: brewery.Location.Coordinates
) )
); );
}
/// <summary> /// <summary>
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for /// Converts a brewery post into its location-search representation.
/// location-based queries that only need identifying and location details.
/// </summary> /// </summary>
public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery) public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery) =>
{ new(
return new BreweryWithLocationDto( brewery.BreweryPostId,
BreweryPostId: brewery.BreweryPostId, brewery.BreweryName,
BreweryName: brewery.BreweryName, brewery.Location is null
Location: brewery.Location is null
? null ? null
: new BreweryLocationDto( : new BreweryLocationDto(
BreweryPostLocationId: brewery.Location.BreweryPostLocationId, brewery.Location.BreweryPostLocationId,
CityId: brewery.Location.CityId, brewery.Location.CityId,
CityName: brewery.Location.City?.CityName ?? string.Empty, brewery.Location.City?.CityName ?? string.Empty,
StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Iso31662 ?? string.Empty,
StateProvinceCode: brewery.Location.City?.StateProvince?.Iso31662 brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Country?.Iso31661 ?? string.Empty,
CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName brewery.Location.AddressLine1,
?? string.Empty, brewery.Location.AddressLine2,
CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661 brewery.Location.PostalCode,
?? string.Empty, brewery.Location.Coordinates
AddressLine1: brewery.Location.AddressLine1,
AddressLine2: brewery.Location.AddressLine2,
PostalCode: brewery.Location.PostalCode,
Coordinates: brewery.Location.Coordinates
), ),
DistanceMetres: brewery.Distance?.DistanceMetres brewery.Distance?.DistanceMetres
); );
}
} }

View File

@@ -5,11 +5,15 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries; namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>Handles <see cref="GetAllBreweriesQuery" /> by retrieving brewery posts, newest first.</summary> /// <summary>
/// Handles ordered, paged brewery-post queries.
/// </summary>
public class GetAllBreweriesHandler(IBreweryRepository repository) public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>> : IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{ {
/// <summary>Retrieves brewery posts, newest first, applying <see cref="GetAllBreweriesQuery" />'s paging.</summary> /// <summary>
/// Retrieves and maps the requested page of brewery posts.
/// </summary>
public async Task<IEnumerable<BreweryDto>> Handle( public async Task<IEnumerable<BreweryDto>> Handle(
GetAllBreweriesQuery request, GetAllBreweriesQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -3,7 +3,13 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries; namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>Retrieves brewery posts, newest first. Bound directly from the query string of <c>GET /api/brewery</c>.</summary> /// <summary>
/// <param name="Limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param> /// Requests brewery posts ordered from newest to oldest.
/// <param name="Offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param> /// </summary>
/// <param name="Limit">
/// Caps the result count; <see langword="null" /> disables the cap.
/// </param>
/// <param name="Offset">
/// Specifies the number of results to skip; <see langword="null" /> means zero.
/// </param>
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>; public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;

View File

@@ -5,11 +5,15 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweryLocations; namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary> /// <summary>
/// Handles queries for brewery posts with locations.
/// </summary>
public class GetAllBreweryLocationsHandler(IBreweryRepository repository) public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<BreweryWithLocationDto>> : IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<BreweryWithLocationDto>>
{ {
/// <summary>Retrieves every brewery post that has a set location.</summary> /// <summary>
/// Retrieves and maps every brewery post that has a location.
/// </summary>
public async Task<IEnumerable<BreweryWithLocationDto>> Handle( public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
GetAllBreweryLocationsQuery request, GetAllBreweryLocationsQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -4,7 +4,6 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweryLocations; namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// <summary> /// <summary>
/// Retrieves all brewery posts that have a set location. Bound directly from /// Requests brewery posts that include a location.
/// <c>GET /api/brewery/locations</c>.
/// </summary> /// </summary>
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<BreweryWithLocationDto>>; public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<BreweryWithLocationDto>>;

View File

@@ -5,17 +5,24 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>Handles <see cref="GetBreweryByIdQuery" /> by retrieving the matching brewery post.</summary> /// <summary>
/// Handles lookup requests for individual brewery posts.
/// </summary>
public class GetBreweryByIdHandler(IBreweryRepository repository) public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?> : IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{ {
/// <summary>Retrieves the brewery post, or <see langword="null"/> if none exists with the given ID.</summary> /// <summary>
/// Retrieves and maps the post, if it exists.
/// </summary>
public async Task<BreweryDto?> Handle( public async Task<BreweryDto?> Handle(
GetBreweryByIdQuery request, GetBreweryByIdQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken); BreweryPost? brewery = await repository.GetByIdAsync(
request.BreweryPostId,
cancellationToken
);
return brewery?.ToDto(); return brewery?.ToDto();
} }
} }

View File

@@ -3,6 +3,10 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>Retrieves a brewery post by ID. Bound directly from the route of <c>GET /api/brewery/{id}</c>.</summary> /// <summary>
/// <remarks>Yields <see langword="null"/> if no brewery exists with the given <c>BreweryPostId</c>.</remarks> /// Requests a brewery post by its identifier.
/// </summary>
/// <remarks>
/// The handler returns <see langword="null" /> when the post is absent.
/// </remarks>
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>; public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;

View File

@@ -6,12 +6,14 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary> /// <summary>
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts. /// Handles proximity searches for brewery locations.
/// </summary> /// </summary>
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository) public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<BreweryWithLocationDto>> : IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<BreweryWithLocationDto>>
{ {
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary> /// <summary>
/// Retrieves and maps nearby brewery locations.
/// </summary>
public async Task<IEnumerable<BreweryWithLocationDto>> Handle( public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
GetBreweryLocationsWithinRangeQuery request, GetBreweryLocationsWithinRangeQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -4,12 +4,19 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary> /// <summary>
/// Retrieves brewery posts with a set location within <paramref name="RangeInMetres" /> of the given /// Requests located brewery posts near a geographic point, nearest first.
/// coordinates, nearest first. Bound directly from the query string of
/// <c>GET /api/brewery/locations/nearby</c>.
/// </summary> /// </summary>
/// <param name="Latitude">The origin latitude, in decimal degrees.</param> /// <param name="Latitude">
/// <param name="Longitude">The origin longitude, in decimal degrees.</param> /// Sets the origin latitude in decimal degrees.
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param> /// </param>
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres) /// <param name="Longitude">
: IRequest<IEnumerable<BreweryWithLocationDto>>; /// Sets the origin longitude in decimal degrees.
/// </param>
/// <param name="RangeInMetres">
/// Sets the inclusive search radius in metres.
/// </param>
public record GetBreweryLocationsWithinRangeQuery(
double Latitude,
double Longitude,
double RangeInMetres
) : IRequest<IEnumerable<BreweryWithLocationDto>>;

View File

@@ -2,7 +2,9 @@ using FluentValidation;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary>Validates <see cref="GetBreweryLocationsWithinRangeQuery" />.</summary> /// <summary>
/// Validates coordinates and radius values for proximity searches.
/// </summary>
public class GetBreweryLocationsWithinRangeValidator public class GetBreweryLocationsWithinRangeValidator
: AbstractValidator<GetBreweryLocationsWithinRangeQuery> : AbstractValidator<GetBreweryLocationsWithinRangeQuery>
{ {

View File

@@ -2,11 +2,20 @@ using Domain.Exceptions;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary>Shared ownership check for commands that mutate a specific brewery post.</summary> /// <summary>
/// Provides authorization checks for brewery-post mutations.
/// </summary>
public static class BreweryOwnershipExtensions public static class BreweryOwnershipExtensions
{ {
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given ID.</exception> /// <summary>
/// <exception cref="Domain.Exceptions.ForbiddenException">Thrown when <paramref name="requestingUserId" /> did not post the brewery.</exception> /// Verifies that the specified user created the brewery post.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown if the brewery post cannot be found.
/// </exception>
/// <exception cref="Domain.Exceptions.ForbiddenException">
/// Thrown if the specified user does not own the brewery post.
/// </exception>
public static async Task EnsureCallerOwnsBreweryAsync( public static async Task EnsureCallerOwnsBreweryAsync(
this IBreweryRepository repository, this IBreweryRepository repository,
Guid breweryPostId, Guid breweryPostId,

View File

@@ -7,14 +7,11 @@ using Domain.Exceptions;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// Dapper-based implementation of <see cref="IBreweryRepository" />. /// Implements brewery persistence with Dapper.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic /// Coordinate queries convert SQL Server <c>GEOGRAPHY</c> values to <c>varbinary(max)</c>
/// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT /// so Dapper can deserialize them as binary coordinate data.
/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to <c>byte[]</c>.
/// The read queries below therefore select <c>CONVERT(varbinary(max), bpl.Coordinates)</c>, which
/// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
/// </remarks> /// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory), : DapperRepository(connectionFactory),
@@ -27,8 +24,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler()); SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler());
} }
/// <inheritdoc/> /// <summary>Gets a brewery post with its location data.</summary>
public async Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) public async Task<BreweryPost?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
IEnumerable<BreweryPost> results = await connection.QueryAsync< IEnumerable<BreweryPost> results = await connection.QueryAsync<
@@ -73,14 +73,17 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
cancellationToken: cancellationToken cancellationToken: cancellationToken
), ),
MapBreweryRow, MapBreweryRow,
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
); );
return results.SingleOrDefault(); return results.SingleOrDefault();
} }
/// <inheritdoc/> /// <summary>Gets the identifier of the user who created a brewery post.</summary>
public async Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default) public async Task<Guid?> GetPostedByIdAsync(
Guid id,
CancellationToken cancellationToken = default
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>( return await connection.ExecuteScalarAsync<Guid?>(
@@ -92,7 +95,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
); );
} }
/// <inheritdoc/> /// <summary>Gets brewery posts in reverse chronological order.</summary>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -140,125 +143,141 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
); );
} }
/// <inheritdoc/> /// <summary>Gets brewery posts within the requested distance from an origin.</summary>
public async Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres) public async Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(
CoordinateData coords,
double rangeInMetres
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync< return await connection.QueryAsync<
BreweryPost, BreweryPost,
BreweryPostLocation, BreweryPostLocation,
City, City,
StateProvince, StateProvince,
Country, Country,
DistanceRow, DistanceRow,
BreweryPost BreweryPost
> ( >(
""" """
DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326); DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326);
SELECT SELECT
bp.BreweryPostID, bp.BreweryPostID,
bp.BreweryName, bp.BreweryName,
bpl.BreweryPostLocationID, bpl.BreweryPostLocationID,
bpl.AddressLine1, bpl.AddressLine1,
bpl.AddressLine2, bpl.AddressLine2,
bpl.PostalCode, bpl.PostalCode,
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
c.CityID, c.CityID,
c.CityName, c.CityName,
sp.StateProvinceID, sp.StateProvinceID,
sp.StateProvinceName, sp.StateProvinceName,
sp.ISO3166_2 AS Iso31662, sp.ISO3166_2 AS Iso31662,
co.CountryID, co.CountryID,
co.CountryName, co.CountryName,
co.ISO3166_1 AS Iso31661, co.ISO3166_1 AS Iso31661,
bpl.Coordinates.STDistance(@Origin) AS DistanceMetres bpl.Coordinates.STDistance(@Origin) AS DistanceMetres
FROM Brewery.BreweryPost bp FROM Brewery.BreweryPost bp
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
WHERE bpl.Coordinates IS NOT NULL WHERE bpl.Coordinates IS NOT NULL
AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres
ORDER BY bpl.Coordinates.STDistance(@Origin) ASC ORDER BY bpl.Coordinates.STDistance(@Origin) ASC
""", """,
(post, location, city, stateProvince, country, distance) => (post, location, city, stateProvince, country, distance) =>
MapBreweryRowWithDistance(post, location, city, stateProvince, country, distance, coords), MapBreweryRowWithDistance(
param: new post,
{ location,
coords.Latitude, city,
coords.Longitude, stateProvince,
RangeInMetres = rangeInMetres country,
}, distance,
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres" coords
); ),
new
{
coords.Latitude,
coords.Longitude,
RangeInMetres = rangeInMetres,
},
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres"
);
} }
/// <inheritdoc/> /// <summary>Gets brewery posts that have location data.</summary>
public async Task<IEnumerable<BreweryPost>> GetAllLocations() public async Task<IEnumerable<BreweryPost>> GetAllLocations()
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync< return await connection.QueryAsync<
BreweryPost, BreweryPost,
BreweryPostLocation, BreweryPostLocation,
City, City,
StateProvince, StateProvince,
Country, Country,
BreweryPost BreweryPost
> ( >(
""" """
SELECT SELECT
bp.BreweryPostID, bp.BreweryPostID,
bp.BreweryName, bp.BreweryName,
bpl.BreweryPostLocationID, bpl.BreweryPostLocationID,
bpl.AddressLine1, bpl.AddressLine1,
bpl.AddressLine2, bpl.AddressLine2,
bpl.PostalCode, bpl.PostalCode,
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
c.CityID, c.CityID,
c.CityName, c.CityName,
sp.StateProvinceID, sp.StateProvinceID,
sp.StateProvinceName, sp.StateProvinceName,
sp.ISO3166_2 AS Iso31662, sp.ISO3166_2 AS Iso31662,
co.CountryID, co.CountryID,
co.CountryName, co.CountryName,
co.ISO3166_1 AS Iso31661 co.ISO3166_1 AS Iso31661
FROM Brewery.BreweryPost bp FROM Brewery.BreweryPost bp
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
WHERE bpl.Coordinates IS NOT NULL WHERE bpl.Coordinates IS NOT NULL
ORDER BY bp.BreweryPostID ASC ORDER BY bp.BreweryPostID ASC
""", """,
MapBreweryRow, MapBreweryRow,
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
); );
} }
/// <inheritdoc/> /// <summary>Updates a brewery post and its location in a transaction.</summary>
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default) public async Task<BreweryPost> UpdateAsync(
BreweryPost brewery,
CancellationToken cancellationToken = default
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken); await using DbTransaction transaction = await connection.BeginTransactionAsync(
cancellationToken
);
try try
{ {
bool breweryExists = bool breweryExists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 FROM SELECT 1 FROM
Brewery.BreweryPost Brewery.BreweryPost
WHERE WHERE
BreweryPostID = @BreweryPostId BreweryPostID = @BreweryPostId
""", """,
new { brewery.BreweryPostId }, new { brewery.BreweryPostId },
transaction, transaction,
cancellationToken: cancellationToken cancellationToken: cancellationToken
)
) )
is not null; )
is not null;
if (!breweryExists) if (!breweryExists)
throw new NotFoundException("Brewery not found."); throw new NotFoundException("Brewery not found.");
@@ -267,14 +286,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{ {
bool cityExists = bool cityExists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
"SELECT 1 FROM Geolocation.City WHERE CityID = @CityId", "SELECT 1 FROM Geolocation.City WHERE CityID = @CityId",
new { brewery.Location.CityId }, new { brewery.Location.CityId },
transaction, transaction,
cancellationToken: cancellationToken cancellationToken: cancellationToken
)
) )
is not null; )
is not null;
if (!cityExists) if (!cityExists)
throw new NotFoundException("City not found."); throw new NotFoundException("City not found.");
@@ -328,18 +347,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{ {
bool locationExists = bool locationExists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 SELECT 1
FROM Brewery.BreweryPostLocation FROM Brewery.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId WHERE BreweryPostID = @BreweryPostId
""", """,
new { brewery.BreweryPostId }, new { brewery.BreweryPostId },
transaction, transaction,
cancellationToken: cancellationToken cancellationToken: cancellationToken
)
) )
is not null; )
is not null;
if (locationExists) if (locationExists)
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -363,8 +382,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Location.AddressLine1, brewery.Location.AddressLine1,
brewery.Location.AddressLine2, brewery.Location.AddressLine2,
brewery.Location.PostalCode, brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude, brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude, brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction, transaction,
@@ -403,8 +422,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Location.AddressLine1, brewery.Location.AddressLine1,
brewery.Location.AddressLine2, brewery.Location.AddressLine2,
brewery.Location.PostalCode, brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude, brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude, brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction, transaction,
@@ -422,12 +441,12 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
return await GetByIdAsync(brewery.BreweryPostId, cancellationToken) return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update." $"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
); );
} }
/// <inheritdoc/> /// <summary>Deletes the brewery post identified by <paramref name="id" />.</summary>
public async Task DeleteAsync(Guid id) public async Task DeleteAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -445,7 +464,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
throw new NotFoundException("Brewery not found."); throw new NotFoundException("Brewery not found.");
} }
/// <inheritdoc/> /// <summary>Creates a brewery post and its location in a transaction.</summary>
public async Task CreateAsync(BreweryPost brewery) public async Task CreateAsync(BreweryPost brewery)
{ {
if (brewery.Location is null) if (brewery.Location is null)
@@ -458,38 +477,38 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{ {
bool userExists = bool userExists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 SELECT 1
FROM FROM
Auth.UserAccount Auth.UserAccount
WHERE WHERE
UserAccountID = @PostedById UserAccountID = @PostedById
""", """,
new { brewery.PostedById }, new { brewery.PostedById },
transaction transaction
)
) )
is not null; )
is not null;
if (!userExists) if (!userExists)
throw new NotFoundException("User not found."); throw new NotFoundException("User not found.");
bool cityExists = bool cityExists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 SELECT 1
FROM FROM
Geolocation.City Geolocation.City
WHERE WHERE
CityID = @CityId CityID = @CityId
""", """,
new { brewery.Location.CityId }, new { brewery.Location.CityId },
transaction transaction
)
) )
is not null; )
is not null;
if (!cityExists) if (!cityExists)
throw new NotFoundException("City not found."); throw new NotFoundException("City not found.");
@@ -543,8 +562,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Location.AddressLine1, brewery.Location.AddressLine1,
brewery.Location.AddressLine2, brewery.Location.AddressLine2,
brewery.Location.PostalCode, brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude, brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude, brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction transaction
@@ -560,15 +579,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
} }
/// <summary> /// <summary>
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />, /// Reconstructs a brewery post and its optional location hierarchy from a joined row.
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
/// brewery with no address on file), Dapper passes <see langword="null" /> for
/// <paramref name="location" /> rather than a blank instance, since its identity column
/// (<c>BreweryPostLocationID</c>) is <c>NULL</c>; the schema guarantees that whenever a location
/// row was joined in, its City/StateProvince/Country chain was too.
/// </summary> /// </summary>
private static BreweryPost MapBreweryRow( private static BreweryPost MapBreweryRow(
BreweryPost post, BreweryPost post,
@@ -596,15 +608,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
return post; return post;
} }
/// <summary>Dapper multi-mapping row for the <c>DistanceMetres</c> column computed by a range query.</summary>
private sealed class DistanceRow
{
public double DistanceMetres { get; set; }
}
/// <summary> /// <summary>
/// Composes a joined row via <see cref="MapBreweryRow" />, then attaches the DB-computed distance /// Reconstructs a brewery row and attaches its database-calculated distance.
/// from <paramref name="origin" /> as the result's <see cref="BreweryPost.Distance" />.
/// </summary> /// </summary>
private static BreweryPost MapBreweryRowWithDistance( private static BreweryPost MapBreweryRowWithDistance(
BreweryPost post, BreweryPost post,
@@ -620,4 +625,12 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres); mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres);
return mapped; return mapped;
} }
}
/// <summary>
/// Holds the distance calculated by a proximity query.
/// </summary>
private sealed class DistanceRow
{
public double DistanceMetres { get; set; }
}
}

View File

@@ -7,9 +7,7 @@ using NetTopologySuite.IO;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// Dapper <see cref="SqlMapper.TypeHandler{T}" /> that deserializes a SQL Server <c>GEOGRAPHY</c> /// Converts SQL Server geography values read as binary data into <see cref="CoordinateData" />.
/// column (read as <c>varbinary(max)</c>, see <see cref="BreweryRepository" />) into a
/// <see cref="CoordinateData" />.
/// </summary> /// </summary>
public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler<CoordinateData> public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler<CoordinateData>
{ {

View File

@@ -3,69 +3,80 @@ using Domain.Entities;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// Repository for CRUD operations on brewery post records. /// Defines persistence operations for brewery posts.
/// </summary> /// </summary>
public interface IBreweryRepository public interface IBreweryRepository
{ {
/// <summary> /// <summary>
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> if no brewery post /// Gets a brewery post and its location data by identifier.
/// exists with the given ID, or if it has no associated location.
/// </summary> /// </summary>
Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves the <c>PostedById</c> of the brewery post with the given ID, without joining its /// Gets the identifier of the user who created a brewery post.
/// location. Returns <c>null</c> if no brewery post exists with the given ID.
/// </summary> /// </summary>
Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts /// Gets brewery posts ordered by creation time, with optional paging.
/// without a location are included, with <see cref="BreweryPost.Location" /> left <c>null</c>.
/// </summary> /// </summary>
/// <param name="limit">Maximum number of rows to return. Unbounded if <see langword="null" />.</param> /// <param name="limit">
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param> /// Limits returned posts; <see langword="null" /> applies no limit.
/// </param>
/// <param name="offset">
/// Skips returned posts; <see langword="null" /> skips none.
/// </param>
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset); Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
/// <summary> /// <summary>
/// Retrieves brewery posts with a set location within <paramref name="rangeInMetres" /> of /// Gets located brewery posts within a radius, ordered by distance.
/// <paramref name="coords" />, nearest first. Posts without coordinates are excluded.
/// </summary> /// </summary>
/// <param name="coords">The origin point to measure distance from.</param> /// <param name="coords">
/// <param name="rangeInMetres">The maximum distance, in metres, from <paramref name="coords" />.</param> /// Sets the point from which distance is measured.
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres); /// </param>
/// <param name="rangeInMetres">
/// Sets the maximum search distance in metres.
/// </param>
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(
CoordinateData coords,
double rangeInMetres
);
/// <summary> /// <summary>
/// Retrieves all brewery posts that have a set location, ordered by <c>BreweryPostId</c>. Posts /// Gets brewery posts with location data.
/// without coordinates are excluded.
/// </summary> /// </summary>
Task<IEnumerable<BreweryPost>> GetAllLocations(); Task<IEnumerable<BreweryPost>> GetAllLocations();
/// <summary> /// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing /// Saves a brewery post and its location using optimistic concurrency.
/// optimistic concurrency via <paramref name="brewery" />'s <c>RowVersion</c>: the update is rejected
/// if the row was modified since <c>RowVersion</c> was read. When <paramref name="brewery" />'s
/// <c>Location</c> is <c>null</c>, any existing location for the brewery is removed.
/// Returns the freshly persisted brewery, including its new <c>RowVersion</c>.
/// </summary> /// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with the given ID, or the given location's <c>CityId</c> does not /// Thrown if the brewery post or its requested city cannot be found.
/// exist.
/// </exception> /// </exception>
/// <exception cref="Domain.Exceptions.ConflictException"> /// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> no longer matches the stored row (it was /// Thrown if the supplied row version is no longer current.
/// modified by another request since it was last read).
/// </exception> /// </exception>
Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default); Task<BreweryPost> UpdateAsync(
BreweryPost brewery,
CancellationToken cancellationToken = default
);
/// <summary>Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.</summary> /// <summary>
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception> /// Deletes a brewery post and its dependent records.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown if the brewery post cannot be found.
/// </exception>
Task DeleteAsync(Guid id); Task DeleteAsync(Guid id);
/// <summary>Creates a new brewery post, including its location details.</summary> /// <summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception> /// Creates a brewery post and its required location.
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="brewery" /> has no location.
/// </exception>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when <paramref name="brewery" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist. /// Thrown if the posting user or location city cannot be found.
/// </exception> /// </exception>
Task CreateAsync(BreweryPost brewery); Task CreateAsync(BreweryPost brewery);
} }