diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index 7567388c..28ac9b4d 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -27,6 +27,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] +COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] +COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile index f1556b99..2937ff6f 100644 --- a/web/backend/API/API.Specs/Dockerfile +++ b/web/backend/API/API.Specs/Dockerfile @@ -17,6 +17,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] +COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] +COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] diff --git a/web/backend/Database/Database.Migrations/Dockerfile b/web/backend/Database/Database.Migrations/Dockerfile index 2fc184d8..7878561e 100644 --- a/web/backend/Database/Database.Migrations/Dockerfile +++ b/web/backend/Database/Database.Migrations/Dockerfile @@ -2,14 +2,15 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src -# Copy everything from the context (src/Core/Database) +# Copy everything from the context (src/Core), matching Database.Seed/Dockerfile's context so +# ProjectReference paths outside Database/ (e.g. Infrastructure.Sql) resolve. COPY . . -RUN dotnet restore "./Database.Migrations/Database.Migrations.csproj" -RUN dotnet build "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build +RUN dotnet restore "./Database/Database.Migrations/Database.Migrations.csproj" +RUN dotnet build "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet publish "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final WORKDIR /app diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql index f3ffb1a7..c6e25187 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql @@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount UserAccountID UNIQUEIDENTIFIER CONSTRAINT DF_UserAccountID DEFAULT NEWID(), - Username VARCHAR(64) NOT NULL, + Username VARCHAR(64) NOT NULL, - FirstName NVARCHAR(128) NOT NULL, + FirstName NVARCHAR(128) NOT NULL, - LastName NVARCHAR(128) NOT NULL, + LastName NVARCHAR(128) NOT NULL, - Email VARCHAR(128) NOT NULL, + Email VARCHAR(128) NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME, + UpdatedAt DATETIME, - DateOfBirth DATE NOT NULL, + DateOfBirth DATE NOT NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_UserAccount PRIMARY KEY (UserAccountID), @@ -56,18 +56,18 @@ CREATE TABLE dbo.UserAccount CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos ( - PhotoID UNIQUEIDENTIFIER + PhotoID UNIQUEIDENTIFIER CONSTRAINT DF_PhotoID DEFAULT NEWID(), - Hyperlink NVARCHAR(256), + Hyperlink NVARCHAR(256), -- storage is handled via filesystem or cloud service UploadedByID UNIQUEIDENTIFIER NOT NULL, - UploadedAt DATETIME NOT NULL + UploadedAt DATETIME NOT NULL CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(), - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_Photo PRIMARY KEY (PhotoID), @@ -87,14 +87,14 @@ NONCLUSTERED INDEX IX_Photo_UploadedByID CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted ( - UserAvatarID UNIQUEIDENTIFIER + UserAvatarID UNIQUEIDENTIFIER CONSTRAINT DF_UserAvatarID DEFAULT NEWID(), UserAccountID UNIQUEIDENTIFIER NOT NULL, - PhotoID UNIQUEIDENTIFIER NOT NULL, + PhotoID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID), @@ -120,15 +120,15 @@ NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE TABLE UserVerification -- delete verification data when user account is deleted ( - UserVerificationID UNIQUEIDENTIFIER + UserVerificationID UNIQUEIDENTIFIER CONSTRAINT DF_UserVerificationID DEFAULT NEWID(), - UserAccountID UNIQUEIDENTIFIER NOT NULL, + UserAccountID UNIQUEIDENTIFIER NOT NULL, - VerificationDateTime DATETIME NOT NULL + VerificationDateTime DATETIME NOT NULL CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(), - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_UserVerification PRIMARY KEY (UserVerificationID), @@ -154,23 +154,23 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted UserCredentialID UNIQUEIDENTIFIER CONSTRAINT DF_UserCredentialID DEFAULT NEWID(), - UserAccountID UNIQUEIDENTIFIER NOT NULL, + UserAccountID UNIQUEIDENTIFIER NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(), - Expiry DATETIME NOT NULL + Expiry DATETIME NOT NULL CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()), - Hash NVARCHAR(256) NOT NULL, + Hash NVARCHAR(256) NOT NULL, -- uses argon2 - IsRevoked BIT NOT NULL + IsRevoked BIT NOT NULL CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0, - RevokedAt DATETIME NULL, + RevokedAt DATETIME NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_UserCredential PRIMARY KEY (UserCredentialID), @@ -195,17 +195,17 @@ NONCLUSTERED INDEX IX_UserCredential_Account_Active CREATE TABLE UserFollow ( - UserFollowID UNIQUEIDENTIFIER + UserFollowID UNIQUEIDENTIFIER CONSTRAINT DF_UserFollowID DEFAULT NEWID(), UserAccountID UNIQUEIDENTIFIER NOT NULL, - FollowingID UNIQUEIDENTIFIER NOT NULL, + FollowingID UNIQUEIDENTIFIER NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(), - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_UserFollow PRIMARY KEY (UserFollowID), @@ -223,7 +223,7 @@ CREATE TABLE UserFollow CONSTRAINT CK_CannotFollowOwnAccount CHECK (UserAccountID != FollowingID ) - ); +); CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID @@ -238,14 +238,14 @@ NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount CREATE TABLE Country ( - CountryID UNIQUEIDENTIFIER + CountryID UNIQUEIDENTIFIER CONSTRAINT DF_CountryID DEFAULT NEWID(), CountryName NVARCHAR(100) NOT NULL, - ISO3166_1 CHAR(2) NOT NULL, + ISO3166_1 CHAR(2) NOT NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_Country PRIMARY KEY (CountryID), @@ -259,17 +259,17 @@ CREATE TABLE Country CREATE TABLE StateProvince ( - StateProvinceID UNIQUEIDENTIFIER + StateProvinceID UNIQUEIDENTIFIER CONSTRAINT DF_StateProvinceID DEFAULT NEWID(), StateProvinceName NVARCHAR(100) NOT NULL, - ISO3166_2 CHAR(6) NOT NULL, + ISO3166_2 CHAR(6) NOT NULL, -- eg 'US-CA' for California, 'CA-ON' for Ontario - CountryID UNIQUEIDENTIFIER NOT NULL, + CountryID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_StateProvince PRIMARY KEY (StateProvinceID), @@ -291,14 +291,14 @@ NONCLUSTERED INDEX IX_StateProvince_Country CREATE TABLE City ( - CityID UNIQUEIDENTIFIER + CityID UNIQUEIDENTIFIER CONSTRAINT DF_CityID DEFAULT NEWID(), - CityName NVARCHAR(100) NOT NULL, + CityName NVARCHAR(100) NOT NULL, StateProvinceID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_City PRIMARY KEY (CityID), @@ -320,18 +320,18 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post BreweryPostID UNIQUEIDENTIFIER CONSTRAINT DF_BreweryPostID DEFAULT NEWID(), - BreweryName NVARCHAR(256) NOT NULL, + BreweryName NVARCHAR(256) NOT NULL, - PostedByID UNIQUEIDENTIFIER NOT NULL, + PostedByID UNIQUEIDENTIFIER NOT NULL, - Description NVARCHAR(512) NOT NULL, + Description NVARCHAR(MAX) NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME NULL, + UpdatedAt DATETIME NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_BreweryPost PRIMARY KEY (BreweryPostID), @@ -354,19 +354,19 @@ CREATE TABLE BreweryPostLocation BreweryPostLocationID UNIQUEIDENTIFIER CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(), - BreweryPostID UNIQUEIDENTIFIER NOT NULL, + BreweryPostID UNIQUEIDENTIFIER NOT NULL, - AddressLine1 NVARCHAR(256) NOT NULL, + AddressLine1 NVARCHAR(256) NOT NULL, - AddressLine2 NVARCHAR(256), + AddressLine2 NVARCHAR(256), - PostalCode NVARCHAR(20) NOT NULL, + PostalCode NVARCHAR(20) NOT NULL, - CityID UNIQUEIDENTIFIER NOT NULL, + CityID UNIQUEIDENTIFIER NOT NULL, - Coordinates GEOGRAPHY NULL, + Coordinates GEOGRAPHY NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_BreweryPostLocation PRIMARY KEY (BreweryPostLocationID), @@ -412,14 +412,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the BreweryPostPhotoID UNIQUEIDENTIFIER CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(), - BreweryPostID UNIQUEIDENTIFIER NOT NULL, + BreweryPostID UNIQUEIDENTIFIER NOT NULL, - PhotoID UNIQUEIDENTIFIER NOT NULL, + PhotoID UNIQUEIDENTIFIER NOT NULL, - LinkedAt DATETIME NOT NULL + LinkedAt DATETIME NOT NULL CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(), - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_BreweryPostPhoto PRIMARY KEY (BreweryPostPhotoID), @@ -451,11 +451,11 @@ CREATE TABLE BeerStyle BeerStyleID UNIQUEIDENTIFIER CONSTRAINT DF_BeerStyleID DEFAULT NEWID(), - StyleName NVARCHAR(100) NOT NULL, + StyleName NVARCHAR(100) NOT NULL, Description NVARCHAR(MAX), - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_BeerStyle PRIMARY KEY (BeerStyleID), @@ -469,31 +469,31 @@ CREATE TABLE BeerStyle CREATE TABLE BeerPost ( - BeerPostID UNIQUEIDENTIFIER + BeerPostID UNIQUEIDENTIFIER CONSTRAINT DF_BeerPostID DEFAULT NEWID(), - Name NVARCHAR(100) NOT NULL, + Name NVARCHAR(100) NOT NULL, Description NVARCHAR(MAX) NOT NULL, - ABV DECIMAL(4, 2) NOT NULL, + ABV DECIMAL(4, 2) NOT NULL, -- Alcohol By Volume (typically 0-67%) - IBU INT NOT NULL, + IBU INT NOT NULL, -- International Bitterness Units (typically 0-120) - PostedByID UNIQUEIDENTIFIER NOT NULL, + PostedByID UNIQUEIDENTIFIER NOT NULL, BeerStyleID UNIQUEIDENTIFIER NOT NULL, - BrewedByID UNIQUEIDENTIFIER NOT NULL, + BrewedByID UNIQUEIDENTIFIER NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME, + UpdatedAt DATETIME, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_BeerPost PRIMARY KEY (BeerPostID), @@ -538,14 +538,14 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th BeerPostPhotoID UNIQUEIDENTIFIER CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(), - BeerPostID UNIQUEIDENTIFIER NOT NULL, + BeerPostID UNIQUEIDENTIFIER NOT NULL, - PhotoID UNIQUEIDENTIFIER NOT NULL, + PhotoID UNIQUEIDENTIFIER NOT NULL, - LinkedAt DATETIME NOT NULL + LinkedAt DATETIME NOT NULL CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(), - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_BeerPostPhoto PRIMARY KEY (BeerPostPhotoID), @@ -577,20 +577,20 @@ CREATE TABLE BeerPostComment BeerPostCommentID UNIQUEIDENTIFIER CONSTRAINT DF_BeerPostComment DEFAULT NEWID(), - Comment NVARCHAR(250) NOT NULL, + Comment NVARCHAR(250) NOT NULL, - BeerPostID UNIQUEIDENTIFIER NOT NULL, + BeerPostID UNIQUEIDENTIFIER NOT NULL, - CommentedByID UNIQUEIDENTIFIER NOT NULL, + CommentedByID UNIQUEIDENTIFIER NOT NULL, - Rating INT NOT NULL, + Rating INT NOT NULL, - CreatedAt DATETIME NOT NULL + CreatedAt DATETIME NOT NULL CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(), - UpdatedAt DATETIME NULL, + UpdatedAt DATETIME NULL, - Timer ROWVERSION, + Timer ROWVERSION, CONSTRAINT PK_BeerPostComment PRIMARY KEY (BeerPostCommentID), diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index 0edd9b37..1054360b 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -186,11 +186,13 @@ static async Task LoadBreweriesIntoDatabaseAsync( await breweryRepository.CreateAsync( new BreweryPost { + BreweryPostId = Guid.NewGuid(), BreweryName = breweryRecord.Brewery.NameEn, Description = breweryRecord.Brewery.DescriptionEn, PostedById = posterUserIds[i % posterUserIds.Count], Location = new BreweryPostLocation { + BreweryPostLocationId = Guid.NewGuid(), CityId = cityId, AddressLine1 = "Address unavailable", PostalCode = breweryRecord.Address.PostalCode, diff --git a/web/backend/Database/Program.cs b/web/backend/Database/Program.cs deleted file mode 100644 index 1eff3e2c..00000000 --- a/web/backend/Database/Program.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Database.Seed; - -public interface IExportDatabase -{ - void SeedLocations(IEnumerable locations); - void SeedUsers(IEnumerable users); - - void SeedBreweries(IEnumerable breweries); -} - -public interface IImportDatabase -{ - IEnumerable GetLocations(); - IEnumerable GetUsers(); - - IEnumerable GetBreweries(); -} diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs index da60d5bd..c0d130e6 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -8,6 +8,7 @@ public class BreweryPostLocation public string? AddressLine2 { get; set; } public string PostalCode { get; set; } = string.Empty; public Guid CityId { get; set; } + public City? City { get; set; } public byte[]? Coordinates { get; set; } public byte[]? Timer { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/City.cs b/web/backend/Domain/Domain.Entities/Entities/City.cs index 6a43b1cc..b15b8f57 100644 --- a/web/backend/Domain/Domain.Entities/Entities/City.cs +++ b/web/backend/Domain/Domain.Entities/Entities/City.cs @@ -5,4 +5,34 @@ public class City public Guid CityId { get; set; } public string CityName { get; set; } = string.Empty; public Guid StateProvinceId { get; set; } + + public StateProvince? StateProvince { get; set; } + + public byte[]? Timer { get; set; } +} + + +public class StateProvince +{ + public Guid StateProvinceId { get; set; } + public string StateProvinceName { get; set; } = string.Empty; + + // this is the iso 3166-2 code for the state/province, e.g. "CA" for California, "ON" for Ontario, etc. + // and is rendered like US-CA for California, CA-ON for Ontario, etc. + public string ISO3166_2 { get; set; } = string.Empty; + public Guid CountryId { get; set; } + + public Country? Country { get; set; } + + public byte[]? Timer { get; set; } +} + +public class Country +{ + public Guid CountryId { get; set; } + public string CountryName { get; set; } = string.Empty; + + public string ISO3166_1 { get; set; } = string.Empty; // this is the iso 3166-1 alpha-2 code for the country, e.g. "US" for United States, "CA" for Canada, etc. + + public byte[]? Timer { get; set; } } diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs index 76f12dc3..07ea746f 100644 --- a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs @@ -20,8 +20,11 @@ public class BreweryRepositoryTests MockDbConnection conn = new(); Guid locationId = Guid.NewGuid(); + Guid cityId = Guid.NewGuid(); + Guid stateProvinceId = Guid.NewGuid(); + Guid countryId = Guid.NewGuid(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation")) + conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation")) .ReturnsTable( MockTable .WithColumns( @@ -37,7 +40,14 @@ public class BreweryRepositoryTests ("AddressLine1", typeof(string)), ("AddressLine2", typeof(string)), ("PostalCode", typeof(string)), - ("Coordinates", typeof(byte[])) + ("Coordinates", typeof(byte[])), + ("CityName", typeof(string)), + ("StateProvinceId", typeof(Guid)), + ("StateProvinceName", typeof(string)), + ("ISO3166_2", typeof(string)), + ("CountryId", typeof(Guid)), + ("CountryName", typeof(string)), + ("ISO3166_1", typeof(string)) ) .AddRow( breweryId, @@ -48,11 +58,18 @@ public class BreweryRepositoryTests null, null, locationId, - Guid.NewGuid(), + cityId, "123 Main St", null, "12345", - null + null, + "Portland", + stateProvinceId, + "Oregon", + "US-OR", + countryId, + "United States", + "US" ) ); @@ -62,13 +79,19 @@ public class BreweryRepositoryTests result!.BreweryPostId.Should().Be(breweryId); result.Location.Should().NotBeNull(); result.Location!.BreweryPostLocationId.Should().Be(locationId); + result.Location.City.Should().NotBeNull(); + result.Location.City.CityName.Should().Be("Portland"); + result.Location.City.StateProvince.Should().NotBeNull(); + result.Location.City.StateProvince.StateProvinceName.Should().Be("Oregon"); + result.Location.City.StateProvince.Country.Should().NotBeNull(); + result.Location.City.StateProvince.Country.CountryName.Should().Be("United States"); } [Fact] public async Task GetByIdAsync_ReturnsNull_WhenNotExists() { MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation")) + conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation")) .ReturnsTable(MockTable.Empty()); BreweryRepository repo = CreateRepo(conn); BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid()); @@ -225,7 +248,7 @@ public class BreweryRepositoryTests .ReturnsScalar(1); // Repository re-fetches the row after a successful update to return the new Timer. - conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation")) + conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation")) .ReturnsTable( MockTable .WithColumns( @@ -241,7 +264,14 @@ public class BreweryRepositoryTests ("AddressLine1", typeof(string)), ("AddressLine2", typeof(string)), ("PostalCode", typeof(string)), - ("Coordinates", typeof(byte[])) + ("Coordinates", typeof(byte[])), + ("CityName", typeof(string)), + ("StateProvinceId", typeof(Guid)), + ("StateProvinceName", typeof(string)), + ("ISO3166_2", typeof(string)), + ("CountryId", typeof(Guid)), + ("CountryName", typeof(string)), + ("ISO3166_1", typeof(string)) ) .AddRow( breweryId, @@ -256,7 +286,14 @@ public class BreweryRepositoryTests "123 Main St", null, "12345", - null + null, + "Portland", + Guid.NewGuid(), + "Oregon", + "US-OR", + Guid.NewGuid(), + "United States", + "US" ) ); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index 97e15e6a..f0e19abc 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -4,10 +4,16 @@ namespace Features.Breweries.Dtos; public class BreweryLocationDto { public Guid BreweryPostLocationId { get; set; } - - public Guid BreweryPostId { get; set; } - public Guid CityId { get; set; } + public string CityName { get; set; } = string.Empty; + + public string StateProvinceName { get; set; } = string.Empty; + + public string StateProvinceCode { get; set; } = string.Empty; + + public string CountryName { get; set; } = string.Empty; + + public string CountryCode { get; set; } = string.Empty; public string AddressLine1 { get; set; } = string.Empty; @@ -41,3 +47,5 @@ public class BreweryDto /// Gets or sets the brewery's location, or if none has been set. public BreweryLocationDto? Location { get; set; } } + + diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index 7ba60bb8..dd635792 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -25,8 +25,12 @@ public static class BreweryDtoMapper : new BreweryLocationDto { BreweryPostLocationId = brewery.Location.BreweryPostLocationId, - BreweryPostId = brewery.Location.BreweryPostId, CityId = brewery.Location.CityId, + CityName = brewery.Location.City?.CityName ?? string.Empty, + StateProvinceName = brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty, + StateProvinceCode = brewery.Location.City?.StateProvince?.ISO3166_2 ?? string.Empty, + CountryName = brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty, + CountryCode = brewery.Location.City?.StateProvince?.Country?.ISO3166_1 ?? string.Empty, AddressLine1 = brewery.Location.AddressLine1, AddressLine2 = brewery.Location.AddressLine2, PostalCode = brewery.Location.PostalCode, diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 0423b0ca..872aa384 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -23,14 +23,45 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) IBreweryRepository { private const string SelectColumns = - "bp.BreweryPostID, bp.PostedByID, bp.BreweryName, bp.Description, bp.CreatedAt, bp.UpdatedAt, bp.Timer, " - + "bpl.BreweryPostLocationID, bpl.CityID, bpl.AddressLine1, bpl.AddressLine2, bpl.PostalCode, bpl.Coordinates"; + """ + bp.BreweryPostID, + bp.PostedByID, + bp.BreweryName, + bp.Description, + bp.CreatedAt, + bp.UpdatedAt, + bp.Timer, + bpl.BreweryPostLocationID, + bpl.CityID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + bpl.Coordinates, + c.CityName, + c.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2, + sp.CountryID, + co.CountryName, + co.ISO3166_1 + """; + + /// + /// Joins from dbo.BreweryPost down to dbo.Country so a row carries everything needed + /// to populate 's full City → StateProvince → + /// Country chain. All joins are LEFT JOIN (matching the previous stored procedure's + /// behavior), since a brewery post may have no location. + /// + private const string FromJoins = + """ + dbo.BreweryPost bp + LEFT 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 + """; /// - /// - /// Joins to the location table with an INNER JOIN (matching the previous stored procedure's - /// behavior), which is why a brewery with no location row is treated as not found. - /// public async Task GetByIdAsync(Guid id) { await using DbConnection connection = await CreateConnection(); @@ -38,8 +69,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) command.CommandText = $""" SELECT {SelectColumns} - FROM dbo.BreweryPost bp - INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + FROM {FromJoins} WHERE bp.BreweryPostID = @BreweryPostId """; AddParameter(command, "@BreweryPostId", id); @@ -56,8 +86,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) command.CommandText = $""" SELECT {SelectColumns} - FROM dbo.BreweryPost bp - LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + FROM {FromJoins} ORDER BY bp.CreatedAt DESC OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY """; @@ -339,6 +368,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) BreweryPostLocationId = reader.GetGuid(ordLocationId), BreweryPostId = brewery.BreweryPostId, CityId = reader.GetGuid(reader.GetOrdinal("CityId")), + City = MapCity(reader), AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")), AddressLine2 = reader.IsDBNull(ordAddressLine2) ? null @@ -353,6 +383,34 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) return brewery; } + /// + /// Maps the current row's City → StateProvince → Country columns. The schema + /// enforces NOT NULL foreign keys down this whole chain, so whenever a location row was + /// joined in (the caller only calls this when it was), these columns are always populated too. + /// + private static City MapCity(DbDataReader reader) + { + return new City + { + CityId = reader.GetGuid(reader.GetOrdinal("CityId")), + CityName = reader.GetString(reader.GetOrdinal("CityName")), + StateProvinceId = reader.GetGuid(reader.GetOrdinal("StateProvinceId")), + StateProvince = new StateProvince + { + StateProvinceId = reader.GetGuid(reader.GetOrdinal("StateProvinceId")), + StateProvinceName = reader.GetString(reader.GetOrdinal("StateProvinceName")), + ISO3166_2 = reader.GetString(reader.GetOrdinal("ISO3166_2")), + CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")), + Country = new Country + { + CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")), + CountryName = reader.GetString(reader.GetOrdinal("CountryName")), + ISO3166_1 = reader.GetString(reader.GetOrdinal("ISO3166_1")), + }, + }, + }; + } + private static void AddParameter(DbCommand command, string name, object? value) { DbParameter p = command.CreateParameter(); diff --git a/web/docker-compose.db.yaml b/web/docker-compose.db.yaml index 2681819a..7653b7bf 100644 --- a/web/docker-compose.db.yaml +++ b/web/docker-compose.db.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.dev.yaml b/web/docker-compose.dev.yaml index 1882fd1a..760386e9 100644 --- a/web/docker-compose.dev.yaml +++ b/web/docker-compose.dev.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.min.yaml b/web/docker-compose.min.yaml index a8a56ac9..3e73c2a5 100644 --- a/web/docker-compose.min.yaml +++ b/web/docker-compose.min.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.prod.yaml b/web/docker-compose.prod.yaml index a2267a74..62c57608 100644 --- a/web/docker-compose.prod.yaml +++ b/web/docker-compose.prod.yaml @@ -31,8 +31,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000 diff --git a/web/docker-compose.test.yaml b/web/docker-compose.test.yaml index 74a95db2..16dbe330 100644 --- a/web/docker-compose.test.yaml +++ b/web/docker-compose.test.yaml @@ -32,8 +32,8 @@ services: sqlserver: condition: service_healthy build: - context: ./backend/Database - dockerfile: Database.Migrations/Dockerfile + context: ./backend + dockerfile: Database/Database.Migrations/Dockerfile args: BUILD_CONFIGURATION: Release APP_UID: 1000