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

View File

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

View File

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

View File

@@ -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<CoordinateData>(), It.IsAny<double>()))

View File

@@ -4,8 +4,12 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Location details for a brewery being created.</summary>
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
/// <summary>
/// Describes the location supplied when creating a brewery.
/// </summary>
/// <param name="Coordinates">
/// Serialized geographic coordinates for the brewery.
/// </param>
public record CreateBreweryLocation(
Guid CityId,
string AddressLine1,
@@ -15,7 +19,7 @@ public record CreateBreweryLocation(
);
/// <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>
public record CreateBreweryCommand(
Guid PostedById,
@@ -24,9 +28,11 @@ public record CreateBreweryCommand(
CreateBreweryLocation Location
) : 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(
string BreweryName,
string Description,
CreateBreweryLocation Location
);
);

View File

@@ -5,13 +5,17 @@ using MediatR;
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)
: 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">
/// 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>
public async Task<BreweryDto> 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(

View File

@@ -2,7 +2,9 @@ using FluentValidation;
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 CreateBreweryValidator()
@@ -27,7 +29,6 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
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.");

View File

@@ -2,5 +2,7 @@ using MediatR;
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;

View File

@@ -3,15 +3,20 @@ using MediatR;
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)
: IRequestHandler<DeleteBreweryCommand>
{
/// <summary>
/// Confirms that the caller owns the post before deleting it.
/// </summary>
/// <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 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>
public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
{

View File

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

View File

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

View File

@@ -2,7 +2,9 @@ using FluentValidation;
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 UpdateBreweryValidator()

View File

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

View File

@@ -3,11 +3,15 @@ using Microsoft.Extensions.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
{
/// <summary>Registers <see cref="IBreweryRepository" /> and its implementation.</summary>
/// <returns><paramref name="services" />, for chaining.</returns>
/// <summary>
/// Registers the repository services used by the Breweries feature.
/// </summary>
/// <returns>The supplied service collection.</returns>
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
{
services.AddScoped<IBreweryRepository, BreweryRepository>();

View File

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

View File

@@ -2,74 +2,62 @@ using Domain.Entities;
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
{
/// <summary>
/// Converts a <see cref="BreweryPost" /> to its DTO representation, including its
/// <see cref="BreweryDto.Location" /> if the entity has one.
/// Converts a brewery post into its complete API representation.
/// </summary>
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
)
);
}
/// <summary>
/// Converts a <see cref="BreweryPost" /> 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.
/// </summary>
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
);
}
}

View File

@@ -5,11 +5,15 @@ using MediatR;
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)
: 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(
GetAllBreweriesQuery request,
CancellationToken cancellationToken

View File

@@ -3,7 +3,13 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>Retrieves brewery posts, newest first. Bound directly from the query string of <c>GET /api/brewery</c>.</summary>
/// <param name="Limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param>
/// <param name="Offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param>
/// <summary>
/// Requests brewery posts ordered from newest to oldest.
/// </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>>;

View File

@@ -5,11 +5,15 @@ using MediatR;
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)
: 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(
GetAllBreweryLocationsQuery request,
CancellationToken cancellationToken

View File

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

View File

@@ -5,17 +5,24 @@ using MediatR;
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)
: 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(
GetBreweryByIdQuery request,
CancellationToken cancellationToken
)
{
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken);
BreweryPost? brewery = await repository.GetByIdAsync(
request.BreweryPostId,
cancellationToken
);
return brewery?.ToDto();
}
}

View File

@@ -3,6 +3,10 @@ using MediatR;
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>
/// <remarks>Yields <see langword="null"/> if no brewery exists with the given <c>BreweryPostId</c>.</remarks>
/// <summary>
/// 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?>;

View File

@@ -6,12 +6,14 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary>
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
/// Handles proximity searches for brewery locations.
/// </summary>
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
: 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(
GetBreweryLocationsWithinRangeQuery request,
CancellationToken cancellationToken

View File

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

View File

@@ -2,11 +2,20 @@ using Domain.Exceptions;
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
{
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given ID.</exception>
/// <exception cref="Domain.Exceptions.ForbiddenException">Thrown when <paramref name="requestingUserId" /> did not post the brewery.</exception>
/// <summary>
/// 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(
this IBreweryRepository repository,
Guid breweryPostId,

View File

@@ -7,14 +7,11 @@ using Domain.Exceptions;
namespace Features.Breweries.Repository;
/// <summary>
/// Dapper-based implementation of <see cref="IBreweryRepository" />.
/// Implements brewery persistence with Dapper.
/// </summary>
/// <remarks>
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic
/// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT
/// 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.
/// Coordinate queries convert SQL Server <c>GEOGRAPHY</c> values to <c>varbinary(max)</c>
/// so Dapper can deserialize them as binary coordinate data.
/// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
@@ -27,8 +24,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler());
}
/// <inheritdoc/>
public async Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
/// <summary>Gets a brewery post with its location data.</summary>
public async Task<BreweryPost?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default
)
{
await using DbConnection connection = await CreateConnection();
IEnumerable<BreweryPost> 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();
}
/// <inheritdoc/>
public async Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default)
/// <summary>Gets the identifier of the user who created a brewery post.</summary>
public async Task<Guid?> GetPostedByIdAsync(
Guid id,
CancellationToken cancellationToken = default
)
{
await using DbConnection connection = await CreateConnection();
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)
{
await using DbConnection connection = await CreateConnection();
@@ -140,125 +143,141 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
);
}
/// <inheritdoc/>
public async Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres)
/// <summary>Gets brewery posts within the requested distance from an origin.</summary>
public async Task<IEnumerable<BreweryPost>> 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"
);
}
/// <inheritdoc/>
/// <summary>Gets brewery posts that have location data.</summary>
public async Task<IEnumerable<BreweryPost>> 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"
);
}
/// <inheritdoc/>
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default)
/// <summary>Updates a brewery post and its location in a transaction.</summary>
public async Task<BreweryPost> 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<int?>(
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<int?>(
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<int?>(
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."
);
}
/// <inheritdoc/>
/// <summary>Deletes the brewery post identified by <paramref name="id" />.</summary>
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.");
}
/// <inheritdoc/>
/// <summary>Creates a brewery post and its location in a transaction.</summary>
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<int?>(
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<int?>(
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)
}
}
/// <summary>
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
/// <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.
/// Reconstructs a brewery post and its optional location hierarchy from a joined row.
/// </summary>
private static BreweryPost MapBreweryRow(
BreweryPost post,
@@ -596,15 +608,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
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>
/// Composes a joined row via <see cref="MapBreweryRow" />, then attaches the DB-computed distance
/// from <paramref name="origin" /> as the result's <see cref="BreweryPost.Distance" />.
/// Reconstructs a brewery row and attaches its database-calculated distance.
/// </summary>
private static BreweryPost MapBreweryRowWithDistance(
BreweryPost post,
@@ -620,4 +625,12 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres);
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;
/// <summary>
/// Dapper <see cref="SqlMapper.TypeHandler{T}" /> that deserializes a SQL Server <c>GEOGRAPHY</c>
/// column (read as <c>varbinary(max)</c>, see <see cref="BreweryRepository" />) into a
/// <see cref="CoordinateData" />.
/// Converts SQL Server geography values read as binary data into <see cref="CoordinateData" />.
/// </summary>
public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler<CoordinateData>
{

View File

@@ -3,69 +3,80 @@ using Domain.Entities;
namespace Features.Breweries.Repository;
/// <summary>
/// Repository for CRUD operations on brewery post records.
/// Defines persistence operations for brewery posts.
/// </summary>
public interface IBreweryRepository
{
/// <summary>
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> 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.
/// </summary>
Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the <c>PostedById</c> of the brewery post with the given ID, without joining its
/// location. Returns <c>null</c> if no brewery post exists with the given ID.
/// Gets the identifier of the user who created a brewery post.
/// </summary>
Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts
/// without a location are included, with <see cref="BreweryPost.Location" /> left <c>null</c>.
/// Gets brewery posts ordered by creation time, with optional paging.
/// </summary>
/// <param name="limit">Maximum number of rows to return. Unbounded if <see langword="null" />.</param>
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param>
/// <param name="limit">
/// 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);
/// <summary>
/// Retrieves brewery posts with a set location within <paramref name="rangeInMetres" /> of
/// <paramref name="coords" />, nearest first. Posts without coordinates are excluded.
/// Gets located brewery posts within a radius, ordered by distance.
/// </summary>
/// <param name="coords">The origin point to measure distance from.</param>
/// <param name="rangeInMetres">The maximum distance, in metres, from <paramref name="coords" />.</param>
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres);
/// <param name="coords">
/// Sets the point from which distance is measured.
/// </param>
/// <param name="rangeInMetres">
/// Sets the maximum search distance in metres.
/// </param>
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(
CoordinateData coords,
double rangeInMetres
);
/// <summary>
/// Retrieves all brewery posts that have a set location, ordered by <c>BreweryPostId</c>. Posts
/// without coordinates are excluded.
/// Gets brewery posts with location data.
/// </summary>
Task<IEnumerable<BreweryPost>> GetAllLocations();
/// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
/// 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>.
/// Saves a brewery post and its location using optimistic concurrency.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with the given ID, or the given location's <c>CityId</c> does not
/// exist.
/// Thrown if the brewery post or its requested city cannot be found.
/// </exception>
/// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> 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.
/// </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>
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception>
/// <summary>
/// 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);
/// <summary>Creates a new brewery post, including its location details.</summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception>
/// <summary>
/// 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">
/// 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>
Task CreateAsync(BreweryPost brewery);
}
}