mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add brewery location query endpoints
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Queries.GetAllBreweryLocations;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Queries;
|
||||||
|
|
||||||
|
public class GetAllBreweryLocationsHandlerTests
|
||||||
|
{
|
||||||
|
private readonly GetAllBreweryLocationsHandler _handler;
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
|
public GetAllBreweryLocationsHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetAllBreweryLocationsHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsEmpty_WhenNoBreweriesHaveALocation()
|
||||||
|
{
|
||||||
|
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
|
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||||
|
new GetAllBreweryLocationsQuery(),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
_repoMock.Verify(r => r.GetAllLocations(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsAllLocatedBreweries_FromRepository()
|
||||||
|
{
|
||||||
|
BreweryPost[] breweries =
|
||||||
|
[
|
||||||
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
||||||
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
|
||||||
|
];
|
||||||
|
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);
|
||||||
|
|
||||||
|
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||||
|
new GetAllBreweryLocationsQuery(),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
.Select(b => b.BreweryPostId)
|
||||||
|
.Should()
|
||||||
|
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Queries;
|
||||||
|
|
||||||
|
public class GetBreweryLocationsWithinRangeHandlerTests
|
||||||
|
{
|
||||||
|
private readonly GetBreweryLocationsWithinRangeHandler _handler;
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
|
public GetBreweryLocationsWithinRangeHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetBreweryLocationsWithinRangeHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PassesCoordinatesAndRange_ToRepository()
|
||||||
|
{
|
||||||
|
_repoMock
|
||||||
|
.Setup(r =>
|
||||||
|
r.GetAllLocationsWithinRange(
|
||||||
|
It.Is<CoordinateData>(c => c.Latitude == 43.6532 && c.Longitude == -79.3832),
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
|
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||||
|
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
_repoMock.Verify(
|
||||||
|
r =>
|
||||||
|
r.GetAllLocationsWithinRange(
|
||||||
|
It.Is<CoordinateData>(c => c.Latitude == 43.6532 && c.Longitude == -79.3832),
|
||||||
|
1000
|
||||||
|
),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsNearbyBreweries_FromRepository()
|
||||||
|
{
|
||||||
|
BreweryPost[] breweries =
|
||||||
|
[
|
||||||
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" },
|
||||||
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" },
|
||||||
|
];
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>()))
|
||||||
|
.ReturnsAsync(breweries);
|
||||||
|
|
||||||
|
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||||
|
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
.Select(b => b.BreweryPostId)
|
||||||
|
.Should()
|
||||||
|
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ using Features.Breweries.Commands.DeleteBrewery;
|
|||||||
using Features.Breweries.Commands.UpdateBrewery;
|
using Features.Breweries.Commands.UpdateBrewery;
|
||||||
using Features.Breweries.Dtos;
|
using Features.Breweries.Dtos;
|
||||||
using Features.Breweries.Queries.GetAllBreweries;
|
using Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
using Features.Breweries.Queries.GetAllBreweryLocations;
|
||||||
using Features.Breweries.Queries.GetBreweryById;
|
using Features.Breweries.Queries.GetBreweryById;
|
||||||
|
using Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
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>
|
/// <summary>Creates a new brewery post.</summary>
|
||||||
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|||||||
@@ -32,3 +32,8 @@ public record BreweryDto(
|
|||||||
byte[]? RowVersion,
|
byte[]? RowVersion,
|
||||||
BreweryLocationDto? Location
|
BreweryLocationDto? Location
|
||||||
);
|
);
|
||||||
|
public record SimplifiedBreweryDto(
|
||||||
|
Guid BreweryPostId,
|
||||||
|
string BreweryName,
|
||||||
|
BreweryLocationDto? Location
|
||||||
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/>
|
/// <inheritdoc/>
|
||||||
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
@@ -134,18 +227,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
{
|
{
|
||||||
bool breweryExists =
|
bool breweryExists =
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"""
|
"""
|
||||||
SELECT 1 FROM
|
SELECT 1 FROM
|
||||||
dbo.BreweryPost
|
dbo.BreweryPost
|
||||||
WHERE
|
WHERE
|
||||||
BreweryPostID = @BreweryPostId
|
BreweryPostID = @BreweryPostId
|
||||||
""",
|
""",
|
||||||
new { brewery.BreweryPostId },
|
new { brewery.BreweryPostId },
|
||||||
transaction
|
transaction
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
is not null;
|
||||||
is not null;
|
|
||||||
|
|
||||||
if (!breweryExists)
|
if (!breweryExists)
|
||||||
throw new NotFoundException("Brewery not found.");
|
throw new NotFoundException("Brewery not found.");
|
||||||
@@ -154,13 +247,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
{
|
{
|
||||||
bool cityExists =
|
bool cityExists =
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
|
"SELECT 1 FROM dbo.City WHERE 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.");
|
||||||
@@ -212,17 +305,17 @@ 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 dbo.BreweryPostLocation
|
FROM dbo.BreweryPostLocation
|
||||||
WHERE BreweryPostID = @BreweryPostId
|
WHERE BreweryPostID = @BreweryPostId
|
||||||
""",
|
""",
|
||||||
new { brewery.BreweryPostId },
|
new { brewery.BreweryPostId },
|
||||||
transaction
|
transaction
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
is not null;
|
||||||
is not null;
|
|
||||||
|
|
||||||
if (locationExists)
|
if (locationExists)
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -303,9 +396,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await GetByIdAsync(brewery.BreweryPostId)
|
return await GetByIdAsync(brewery.BreweryPostId)
|
||||||
?? 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/>
|
/// <inheritdoc/>
|
||||||
@@ -339,38 +432,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
|
||||||
dbo.UserAccount
|
dbo.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
|
||||||
dbo.City
|
dbo.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.");
|
||||||
@@ -490,4 +583,4 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
post.Location = location;
|
post.Location = location;
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,20 @@ public interface IBreweryRepository
|
|||||||
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param>
|
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param>
|
||||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
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>
|
/// <summary>
|
||||||
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
|
/// 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
|
/// 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.
|
/// Thrown when <paramref name="brewery" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
Task CreateAsync(BreweryPost brewery);
|
Task CreateAsync(BreweryPost brewery);
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,12 @@ export interface Brewery {
|
|||||||
location: BreweryLocation | null;
|
location: BreweryLocation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimplifiedBrewery {
|
||||||
|
breweryPostId: string;
|
||||||
|
breweryName: string;
|
||||||
|
location: BreweryLocation | null;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchApi = async (path: string): Promise<Response> => {
|
const fetchApi = async (path: string): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
return await fetch(`${API_BASE_URL}${path}`);
|
return await fetch(`${API_BASE_URL}${path}`);
|
||||||
@@ -66,6 +72,20 @@ export const getBreweries = async (limit?: number, offset?: number): Promise<Bre
|
|||||||
return body.payload;
|
return body.payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBreweryLocations = async (): Promise<SimplifiedBrewery[]> => {
|
||||||
|
const res = await fetchApi('/api/brewery/locations');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw data(`Failed to load brewery locations (${res.status}).`, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ApiResponse<SimplifiedBrewery[]> = await res.json();
|
||||||
|
return body.payload;
|
||||||
|
};
|
||||||
|
|
||||||
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
|
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
|
||||||
const res = await fetchApi(`/api/brewery/${encodeURIComponent(id)}`);
|
const res = await fetchApi(`/api/brewery/${encodeURIComponent(id)}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
import ClientOnly from '../../../components/ClientOnly';
|
import ClientOnly from '../../../components/ClientOnly';
|
||||||
import BreweryCard from '../components/BreweryCard';
|
import BreweryCard from '../components/BreweryCard';
|
||||||
import { getBreweries, type Brewery } from '../breweries.server';
|
import { getBreweries, getBreweryLocations, type SimplifiedBrewery } from '../breweries.server';
|
||||||
import type { Route } from './+types/breweries';
|
import type { Route } from './+types/breweries';
|
||||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
import { useFetcher } from 'react-router';
|
import { useFetcher } from 'react-router';
|
||||||
@@ -18,13 +18,16 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const limit = Number(url.searchParams.get('limit')) || PAGE_SIZE;
|
const limit = Number(url.searchParams.get('limit')) || PAGE_SIZE;
|
||||||
const offset = Number(url.searchParams.get('offset')) || 0;
|
const offset = Number(url.searchParams.get('offset')) || 0;
|
||||||
const breweries = await getBreweries(limit, offset);
|
const [breweries, locations] = await Promise.all([
|
||||||
return { breweries, limit, offset };
|
getBreweries(limit, offset),
|
||||||
|
getBreweryLocations(),
|
||||||
|
]);
|
||||||
|
return { breweries, locations, limit, offset };
|
||||||
};
|
};
|
||||||
|
|
||||||
const BreweryMap = lazy(() => import('../components/BreweryMap'));
|
const BreweryMap = lazy(() => import('../components/BreweryMap'));
|
||||||
|
|
||||||
const toPins = (breweries: Brewery[]): BreweryMapPin[] =>
|
const toPins = (breweries: SimplifiedBrewery[]): BreweryMapPin[] =>
|
||||||
breweries
|
breweries
|
||||||
.filter((brewery) => brewery.location?.coordinates)
|
.filter((brewery) => brewery.location?.coordinates)
|
||||||
.map((brewery) => ({
|
.map((brewery) => ({
|
||||||
@@ -64,7 +67,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMore, offset, fetcher]);
|
}, [hasMore, offset, fetcher]);
|
||||||
|
|
||||||
const pins = toPins(allBreweries);
|
const pins = toPins(loaderData.locations);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
|
|||||||
Reference in New Issue
Block a user