Add brewery location query endpoints

This commit is contained in:
Aaron Po
2026-08-26 23:19:50 -04:00
parent d6306cbcba
commit eebb87811f
14 changed files with 491 additions and 61 deletions

View File

@@ -3,7 +3,9 @@ using Features.Breweries.Commands.DeleteBrewery;
using Features.Breweries.Commands.UpdateBrewery;
using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetAllBreweries;
using Features.Breweries.Queries.GetAllBreweryLocations;
using Features.Breweries.Queries.GetBreweryById;
using Features.Breweries.Queries.GetBreweryLocationsWithinRange;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -64,6 +66,49 @@ public class BreweryController(IMediator mediator) : ControllerBase
);
}
/// <summary>Retrieves all brewery posts that have a set location.</summary>
/// <remarks>Allows anonymous access.</remarks>
[AllowAnonymous]
[HttpGet("locations")]
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetAllLocations()
{
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
new GetAllBreweryLocationsQuery()
);
return Ok(
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
{
Message = "Brewery locations retrieved successfully.",
Payload = breweries,
}
);
}
/// <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>
[AllowAnonymous]
[HttpGet("locations/nearby")]
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetLocationsWithinRange(
[FromQuery] double latitude,
[FromQuery] double longitude,
[FromQuery] double rangeInMetres
)
{
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
);
return Ok(
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
{
Message = "Brewery locations retrieved successfully.",
Payload = breweries,
}
);
}
/// <summary>Creates a new brewery post.</summary>
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
[HttpPost]

View File

@@ -32,3 +32,8 @@ public record BreweryDto(
byte[]? RowVersion,
BreweryLocationDto? Location
);
public record SimplifiedBreweryDto(
Guid BreweryPostId,
string BreweryName,
BreweryLocationDto? Location
);

View File

@@ -40,4 +40,35 @@ public static class BreweryDtoMapper
)
);
}
/// <summary>
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for
/// location-based queries that only need identifying and location details.
/// </summary>
public static SimplifiedBreweryDto ToSimplifiedDto(this BreweryPost brewery)
{
return new SimplifiedBreweryDto(
BreweryPostId: brewery.BreweryPostId,
BreweryName: brewery.BreweryName,
Location: 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
)
);
}
}

View File

@@ -0,0 +1,21 @@
using Domain.Entities;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary>
public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<SimplifiedBreweryDto>>
{
/// <summary>Retrieves every brewery post that has a set location.</summary>
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
GetAllBreweryLocationsQuery request,
CancellationToken cancellationToken
)
{
IEnumerable<BreweryPost> breweries = await repository.GetAllLocations();
return breweries.Select(b => b.ToSimplifiedDto());
}
}

View File

@@ -0,0 +1,10 @@
using Features.Breweries.Dtos;
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>.
/// </summary>
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<SimplifiedBreweryDto>>;

View File

@@ -0,0 +1,26 @@
using Domain.Entities;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary>
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
/// </summary>
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<SimplifiedBreweryDto>>
{
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary>
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
GetBreweryLocationsWithinRangeQuery request,
CancellationToken cancellationToken
)
{
IEnumerable<BreweryPost> breweries = await repository.GetAllLocationsWithinRange(
new CoordinateData(request.Latitude, request.Longitude),
request.RangeInMetres
);
return breweries.Select(b => b.ToSimplifiedDto());
}
}

View File

@@ -0,0 +1,15 @@
using Features.Breweries.Dtos;
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>.
/// </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<SimplifiedBreweryDto>>;

View File

@@ -0,0 +1,23 @@
using FluentValidation;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary>Validates <see cref="GetBreweryLocationsWithinRangeQuery" />.</summary>
public class GetBreweryLocationsWithinRangeValidator
: AbstractValidator<GetBreweryLocationsWithinRangeQuery>
{
public GetBreweryLocationsWithinRangeValidator()
{
RuleFor(x => x.Latitude)
.InclusiveBetween(-90, 90)
.WithMessage("Latitude must be between -90 and 90 degrees.");
RuleFor(x => x.Longitude)
.InclusiveBetween(-180, 180)
.WithMessage("Longitude must be between -180 and 180 degrees.");
RuleFor(x => x.RangeInMetres)
.GreaterThan(0)
.WithMessage("RangeInMetres must be greater than zero.");
}
}

View File

@@ -124,6 +124,99 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
);
}
/// <inheritdoc/>
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,
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
FROM dbo.BreweryPost bp
INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN dbo.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
""",
MapBreweryRow,
param: new
{
coords.Latitude,
coords.Longitude,
RangeInMetres = rangeInMetres
},
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
}
/// <inheritdoc/>
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 dbo.BreweryPost bp
INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN dbo.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)
{
@@ -134,18 +227,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{
bool breweryExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1 FROM
dbo.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
new CommandDefinition(
"""
SELECT 1 FROM
dbo.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
)
)
is not null;
is not null;
if (!breweryExists)
throw new NotFoundException("Brewery not found.");
@@ -154,13 +247,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{
bool cityExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
new { brewery.Location.CityId },
transaction
new CommandDefinition(
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
new { brewery.Location.CityId },
transaction
)
)
)
is not null;
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
@@ -212,17 +305,17 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{
bool locationExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
new CommandDefinition(
"""
SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
)
)
is not null;
is not null;
if (locationExists)
await connection.ExecuteAsync(
@@ -303,9 +396,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
return await GetByIdAsync(brewery.BreweryPostId)
?? 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/>
@@ -339,38 +432,38 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{
bool userExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM
dbo.UserAccount
WHERE
UserAccountID = @PostedById
""",
new { brewery.PostedById },
transaction
new CommandDefinition(
"""
SELECT 1
FROM
dbo.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
dbo.City
WHERE
CityID = @CityId
""",
new { brewery.Location.CityId },
transaction
new CommandDefinition(
"""
SELECT 1
FROM
dbo.City
WHERE
CityID = @CityId
""",
new { brewery.Location.CityId },
transaction
)
)
)
is not null;
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
@@ -490,4 +583,4 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
post.Location = location;
return post;
}
}
}

View File

@@ -21,6 +21,20 @@ public interface IBreweryRepository
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</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.
/// </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);
/// <summary>
/// Retrieves all brewery posts that have a set location, ordered by <c>BreweryPostId</c>. Posts
/// without coordinates are excluded.
/// </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
@@ -48,4 +62,4 @@ public interface IBreweryRepository
/// Thrown when <paramref name="brewery" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
/// </exception>
Task CreateAsync(BreweryPost brewery);
}
}