diff --git a/web/backend/API/API.Core/GlobalException.cs b/web/backend/API/API.Core/GlobalException.cs index 1ab60148..0d21c692 100644 --- a/web/backend/API/API.Core/GlobalException.cs +++ b/web/backend/API/API.Core/GlobalException.cs @@ -13,16 +13,16 @@ namespace API.Core; /// public class GlobalExceptionFilter(ILogger logger) : IExceptionFilter { - /// - /// Logs the exception and sets to an appropriate error response. - /// - /// - /// Maps to 400, to 409, - /// to 404, to 401, - /// to 403, to 503, - /// to 400, and anything else to 500. - /// - public void OnException(ExceptionContext context) + /// + /// Logs the exception and sets to an appropriate error response. + /// + /// + /// Maps to 400, to 409, + /// to 404, to 401, + /// to 403, to 503, + /// to 400, and anything else to 500. + /// + public void OnException(ExceptionContext context) { logger.LogError(context.Exception, "Unhandled exception occurred"); diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index 9b1499ec..6a38c939 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -160,10 +160,7 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() { HttpClient client = GetClient(); - (string username, string password) = scenario.TryGetValue<( - string username, - string password - )>(TestUserKey, out (string username, string password) user) + (string username, string password) = scenario.TryGetValue(TestUserKey, out (string username, string password) user) ? user : ("test.user", "password"); diff --git a/web/backend/Database/Database.Connection/Database.Connection.csproj b/web/backend/Database/Database.Connection/Database.Connection.csproj index ef78601b..e8957e92 100644 --- a/web/backend/Database/Database.Connection/Database.Connection.csproj +++ b/web/backend/Database/Database.Connection/Database.Connection.csproj @@ -7,6 +7,8 @@ + + - /// Performs the shared validation logic for access, refresh, and confirmation tokens: - /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims. - /// - /// A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages. - /// - /// Thrown when required claims are missing, the user ID claim is not a valid , - /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). - /// - private async Task ValidateTokenInternalAsync( + /// + /// Performs the shared validation logic for access, refresh, and confirmation tokens: + /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims. + /// + /// A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages. + /// + /// Thrown when required claims are missing, the user ID claim is not a valid , + /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). + /// + private async Task ValidateTokenInternalAsync( string token, string secret, string tokenType diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs index 6522accc..6b4f71b7 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs @@ -1,3 +1,4 @@ +using Domain.Entities; using Features.Breweries.Dtos; using MediatR; @@ -10,7 +11,7 @@ public record CreateBreweryLocation( string AddressLine1, string? AddressLine2, string PostalCode, - byte[]? Coordinates + CoordinateData? Coordinates ); /// diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs index 489de1d7..a86e2459 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs @@ -1,3 +1,4 @@ +using Domain.Entities; using Features.Breweries.Dtos; using MediatR; @@ -14,7 +15,7 @@ public record UpdateBreweryLocation( string AddressLine1, string? AddressLine2, string PostalCode, - byte[]? Coordinates + CoordinateData? Coordinates ); /// diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index 621aceee..58de1a98 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -1,3 +1,5 @@ +using Domain.Entities; + namespace Features.Breweries.Dtos; /// Location details of a brewery, as returned by the API. @@ -13,7 +15,7 @@ public record BreweryLocationDto( string AddressLine1, string? AddressLine2, string PostalCode, - byte[]? Coordinates + CoordinateData? Coordinates ); /// A brewery post, as returned by the API. diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 42a45f52..52862175 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -17,9 +17,16 @@ namespace Features.Breweries.Repository; /// yields the UDT's serialized bytes as a plain varbinary value that Dapper can bind directly. /// public class BreweryRepository(ISqlConnectionFactory connectionFactory) - : Database.Connection.DapperRepository(connectionFactory), + : DapperRepository(connectionFactory), IBreweryRepository { + private const int GeographySrid = 4326; + + static BreweryRepository() + { + SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler()); + } + /// public async Task GetByIdAsync(Guid id) { @@ -226,7 +233,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) AddressLine1 = @AddressLine1, AddressLine2 = @AddressLine2, PostalCode = @PostalCode, - Coordinates = @Coordinates + Coordinates = CASE + WHEN @Latitude IS NULL THEN NULL + ELSE geography::Point(@Latitude, @Longitude, @Srid) + END WHERE BreweryPostID = @BreweryPostId """, new @@ -236,7 +246,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) brewery.Location.AddressLine1, brewery.Location.AddressLine2, brewery.Location.PostalCode, - brewery.Location.Coordinates, + Latitude = brewery.Location.Coordinates?.Latitude, + Longitude = brewery.Location.Coordinates?.Longitude, + Srid = GeographySrid, }, transaction ) @@ -260,7 +272,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) @AddressLine1, @AddressLine2, @PostalCode, - @Coordinates) + CASE + WHEN @Latitude IS NULL THEN NULL + ELSE geography::Point(@Latitude, @Longitude, @Srid) + END) """, new { @@ -270,7 +285,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) brewery.Location.AddressLine1, brewery.Location.AddressLine2, brewery.Location.PostalCode, - brewery.Location.Coordinates, + Latitude = brewery.Location.Coordinates?.Latitude, + Longitude = brewery.Location.Coordinates?.Longitude, + Srid = GeographySrid, }, transaction ) @@ -394,7 +411,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) @AddressLine1, @AddressLine2, @PostalCode, - @Coordinates) + CASE + WHEN @Latitude IS NULL THEN NULL + ELSE geography::Point(@Latitude, @Longitude, @Srid) + END) """, new { @@ -404,7 +424,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) brewery.Location.AddressLine1, brewery.Location.AddressLine2, brewery.Location.PostalCode, - brewery.Location.Coordinates, + Latitude = brewery.Location.Coordinates?.Latitude, + Longitude = brewery.Location.Coordinates?.Longitude, + Srid = GeographySrid, }, transaction ) diff --git a/web/backend/Features/Features.Breweries/Repository/CoordinateDataTypeHandler.cs b/web/backend/Features/Features.Breweries/Repository/CoordinateDataTypeHandler.cs new file mode 100644 index 00000000..2068aceb --- /dev/null +++ b/web/backend/Features/Features.Breweries/Repository/CoordinateDataTypeHandler.cs @@ -0,0 +1,29 @@ +using System.Data; +using Dapper; +using Domain.Entities; +using NetTopologySuite.Geometries; +using NetTopologySuite.IO; + +namespace Features.Breweries.Repository; + +/// +/// Dapper that deserializes a SQL Server GEOGRAPHY +/// column (read as varbinary(max), see ) into a +/// . +/// +public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler +{ + private readonly SqlServerBytesReader _reader = new() { IsGeography = true }; + + public override CoordinateData Parse(object value) + { + var point = (Point)_reader.Read((byte[])value); + return new CoordinateData(point.Y, point.X); + } + + public override void SetValue(IDbDataParameter parameter, CoordinateData? value) => + throw new NotSupportedException( + $"{nameof(CoordinateData)} cannot be bound as a query parameter directly; " + + "pass Latitude/Longitude and build the GEOGRAPHY value in SQL text." + ); +} diff --git a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs index 4ee8b809..8121b21f 100644 --- a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs +++ b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs @@ -10,7 +10,7 @@ namespace Features.Locations.Repository; /// Dapper-based implementation of . /// public class LocationRepository(ISqlConnectionFactory connectionFactory) - : Database.Connection.DapperRepository(connectionFactory), + : DapperRepository(connectionFactory), ILocationRepository { /// diff --git a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs index a994dd4f..13d56011 100644 --- a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs @@ -10,7 +10,7 @@ namespace Features.UserManagement.Repository; /// Dapper-based implementation of . /// public class UserAccountRepository(ISqlConnectionFactory connectionFactory) - : Database.Connection.DapperRepository(connectionFactory), + : DapperRepository(connectionFactory), IUserAccountRepository { ///