From ab265de54d7f748a16293058c290ee62153770a8 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Wed, 19 Aug 2026 16:15:34 -0400 Subject: [PATCH] - Updated column names from 'Timer' to 'RowVersion' in schema.sql for - Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency. - Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'. - Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'. - Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly. - Ensured all repository methods and interfaces are aligned with the new concurrency handling approach. --- .../scripts/01-schema/schema.sql | 32 ++--- web/backend/Database/Database.Seed/Program.cs | 1 - .../Domain.Entities/Entities/BeerPost.cs | 27 ++++ .../Domain.Entities/Entities/BreweryPost.cs | 14 +- .../Entities/BreweryPostLocation.cs | 17 ++- .../Domain/Domain.Entities/Entities/City.cs | 38 ++++-- .../Domain.Entities/Entities/UserAccount.cs | 11 +- .../Entities/UserCredential.cs | 15 ++- .../Entities/UserVerification.cs | 11 +- .../Commands/UpdateBreweryHandlerTests.cs | 2 +- .../Repository/BreweryRepositoryTests.cs | 28 ++-- .../UpdateBrewery/UpdateBreweryCommand.cs | 4 +- .../UpdateBrewery/UpdateBreweryHandler.cs | 4 +- .../UpdateBrewery/UpdateBreweryValidator.cs | 4 +- .../Controllers/BreweryController.cs | 2 +- .../Features.Breweries/Dtos/BreweryDto.cs | 74 +++++------ .../Dtos/BreweryDtoMapper.cs | 61 +++++---- .../Repository/BreweryRepository.cs | 121 +++++++++++------- .../Repository/IBreweryRepository.cs | 8 +- 19 files changed, 290 insertions(+), 184 deletions(-) create mode 100644 web/backend/Domain/Domain.Entities/Entities/BeerPost.cs 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 c6e25187..f024b05a 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql @@ -39,7 +39,7 @@ CREATE TABLE dbo.UserAccount DateOfBirth DATE NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserAccount PRIMARY KEY (UserAccountID), @@ -67,7 +67,7 @@ CREATE TABLE Photo -- All photos must be linked to a user account, you cannot de UploadedAt DATETIME NOT NULL CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_Photo PRIMARY KEY (PhotoID), @@ -94,7 +94,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted PhotoID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID), @@ -128,7 +128,7 @@ CREATE TABLE UserVerification -- delete verification data when user account is d VerificationDateTime DATETIME NOT NULL CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserVerification PRIMARY KEY (UserVerificationID), @@ -170,7 +170,7 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted RevokedAt DATETIME NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserCredential PRIMARY KEY (UserCredentialID), @@ -205,7 +205,7 @@ CREATE TABLE UserFollow CreatedAt DATETIME NOT NULL CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_UserFollow PRIMARY KEY (UserFollowID), @@ -245,7 +245,7 @@ CREATE TABLE Country ISO3166_1 CHAR(2) NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_Country PRIMARY KEY (CountryID), @@ -269,7 +269,7 @@ CREATE TABLE StateProvince CountryID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_StateProvince PRIMARY KEY (StateProvinceID), @@ -298,7 +298,7 @@ CREATE TABLE City StateProvinceID UNIQUEIDENTIFIER NOT NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_City PRIMARY KEY (CityID), @@ -331,7 +331,7 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post UpdatedAt DATETIME NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BreweryPost PRIMARY KEY (BreweryPostID), @@ -366,7 +366,7 @@ CREATE TABLE BreweryPostLocation Coordinates GEOGRAPHY NULL, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BreweryPostLocation PRIMARY KEY (BreweryPostLocationID), @@ -419,7 +419,7 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the LinkedAt DATETIME NOT NULL CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BreweryPostPhoto PRIMARY KEY (BreweryPostPhotoID), @@ -455,7 +455,7 @@ CREATE TABLE BeerStyle Description NVARCHAR(MAX), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BeerStyle PRIMARY KEY (BeerStyleID), @@ -493,7 +493,7 @@ CREATE TABLE BeerPost UpdatedAt DATETIME, - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BeerPost PRIMARY KEY (BeerPostID), @@ -545,7 +545,7 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th LinkedAt DATETIME NOT NULL CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(), - Timer ROWVERSION, + RowVersion ROWVERSION, CONSTRAINT PK_BeerPostPhoto PRIMARY KEY (BeerPostPhotoID), @@ -590,7 +590,7 @@ CREATE TABLE BeerPostComment UpdatedAt DATETIME NULL, - Timer ROWVERSION, + RowVersion 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 1054360b..c268f179 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -86,7 +86,6 @@ static async Task RunAsync() "Loading user data into target database...", async ctx => { - Thread.Sleep(3000); // Simulate some work being done postedByIds = await LoadUsersIntoDatabaseAsync( authRepository, passwordInfrastructure, diff --git a/web/backend/Domain/Domain.Entities/Entities/BeerPost.cs b/web/backend/Domain/Domain.Entities/Entities/BeerPost.cs new file mode 100644 index 00000000..846b43ac --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/BeerPost.cs @@ -0,0 +1,27 @@ +namespace Domain.Entities; + +public sealed class BeerPost +{ + // PK + public Guid BeerPostId { get; set; } = Guid.Empty; + + // FK References + public Guid PostedById { get; set; } = Guid.Empty; + public Guid BeerStyleId { get; set; } = Guid.Empty; + public Guid BrewedById { get; set; } = Guid.Empty; + + // Attributes + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal ABV { get; set; } + public int IBU { get; set; } + + // Audit Fields + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public byte[]? RowVersion { get; set; } + + // Navigation Properties + public UserAccount? PostedBy { get; set; } + public BreweryPost? BrewedBy { get; set; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs index 07a7b780..4812ffa1 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -1,13 +1,23 @@ namespace Domain.Entities; -public class BreweryPost +public sealed class BreweryPost { + // PK public Guid BreweryPostId { get; set; } + + // FK References public Guid PostedById { get; set; } + + // Attributes public string BreweryName { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; + + // Audit Fields public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } - public byte[]? Timer { get; set; } + public byte[]? RowVersion { get; set; } + + // Navigation Properties public BreweryPostLocation? Location { get; set; } + public List BeerPosts { get; set; } = []; } diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs index c0d130e6..5f8936c2 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -1,14 +1,23 @@ namespace Domain.Entities; -public class BreweryPostLocation +public sealed class BreweryPostLocation { + // PK public Guid BreweryPostLocationId { get; set; } + + // FK References public Guid BreweryPostId { get; set; } + public Guid CityId { get; set; } + + // Attributes public string AddressLine1 { get; set; } = string.Empty; 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; } + + // Audit Fields + public byte[]? RowVersion { get; set; } + + // Navigation Properties + public City? City { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/City.cs b/web/backend/Domain/Domain.Entities/Entities/City.cs index b15b8f57..9824aefd 100644 --- a/web/backend/Domain/Domain.Entities/Entities/City.cs +++ b/web/backend/Domain/Domain.Entities/Entities/City.cs @@ -1,38 +1,56 @@ namespace Domain.Entities; -public class City +public sealed class City { + // PK public Guid CityId { get; set; } - public string CityName { get; set; } = string.Empty; + + // FK References public Guid StateProvinceId { get; set; } - public StateProvince? StateProvince { get; set; } + // Attributes + public string CityName { get; set; } = string.Empty; - public byte[]? Timer { get; set; } + // Audit Fields + public byte[]? RowVersion { get; set; } + + // Navigation Properties + public StateProvince? StateProvince { get; set; } } -public class StateProvince +public sealed class StateProvince { + // PK public Guid StateProvinceId { get; set; } + + // FK References + public Guid CountryId { get; set; } + + // Attributes 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; } + // Audit Fields + public byte[]? RowVersion { get; set; } + + // Navigation Properties public Country? Country { get; set; } - - public byte[]? Timer { get; set; } } -public class Country +public sealed class Country { + // PK public Guid CountryId { get; set; } + + // Attributes 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; } + // Audit Fields + public byte[]? RowVersion { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs index 4ae9266b..55b2706c 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs @@ -1,14 +1,19 @@ namespace Domain.Entities; -public class UserAccount +public sealed class UserAccount { + // PK public Guid UserAccountId { get; set; } + + // Attributes public string Username { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } + + // Audit Fields public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } - public DateTime DateOfBirth { get; set; } - public byte[]? Timer { get; set; } + public byte[]? RowVersion { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs index 7b79bbac..19e5fb07 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs @@ -1,11 +1,20 @@ namespace Domain.Entities; -public class UserCredential +public sealed class UserCredential { + // PK public Guid UserCredentialId { get; set; } + + // FK References public Guid UserAccountId { get; set; } - public DateTime CreatedAt { get; set; } + + // Attributes public DateTime Expiry { get; set; } public string Hash { get; set; } = string.Empty; - public byte[]? Timer { get; set; } + public bool IsRevoked { get; set; } + public DateTime? RevokedAt { get; set; } + + // Audit Fields + public DateTime CreatedAt { get; set; } + public byte[]? RowVersion { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs index bd938e4f..4fe229a1 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs @@ -1,9 +1,16 @@ namespace Domain.Entities; -public class UserVerification +public sealed class UserVerification { + // PK public Guid UserVerificationId { get; set; } + + // FK References public Guid UserAccountId { get; set; } + + // Attributes public DateTime VerificationDateTime { get; set; } - public byte[]? Timer { get; set; } + + // Audit Fields + public byte[]? RowVersion { get; set; } } diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs index bdb659fe..ddbe0bc9 100644 --- a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs @@ -43,7 +43,7 @@ public class UpdateBreweryHandlerTests persisted.PostedById.Should().Be(command.PostedById); persisted.BreweryName.Should().Be("Renamed"); persisted.Description.Should().Be("New description"); - persisted.Timer.Should().Equal(command.Timer); + persisted.RowVersion.Should().Equal(command.RowVersion); persisted.UpdatedAt.Should().NotBeNull(); persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); } diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs index 07ea746f..e453e694 100644 --- a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs @@ -34,7 +34,7 @@ public class BreweryRepositoryTests ("Description", typeof(string)), ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), - ("Timer", typeof(byte[])), + ("RowVersion", typeof(byte[])), ("BreweryPostLocationId", typeof(Guid)), ("CityId", typeof(Guid)), ("AddressLine1", typeof(string)), @@ -230,7 +230,7 @@ public class BreweryRepositoryTests public async Task UpdateAsync_ReturnsFreshlyPersistedBrewery_WhenSuccessful() { Guid breweryId = Guid.NewGuid(); - byte[] newTimer = [0x00, 0x02]; + byte[] newRowVersion = [0x00, 0x02]; MockDbConnection conn = new(); conn.Mocks.When(cmd => @@ -239,7 +239,7 @@ public class BreweryRepositoryTests .ReturnsScalar(1); conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")).ReturnsScalar(1); conn.Mocks.When(cmd => - cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND Timer = @Timer") + cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND RowVersion = @RowVersion") ) .ReturnsScalar(1); conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPostLocation")) @@ -247,7 +247,7 @@ public class BreweryRepositoryTests conn.Mocks.When(cmd => cmd.CommandText.Contains("UPDATE dbo.BreweryPostLocation")) .ReturnsScalar(1); - // Repository re-fetches the row after a successful update to return the new Timer. + // Repository re-fetches the row after a successful update to return the new RowVersion. conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation")) .ReturnsTable( MockTable @@ -258,7 +258,7 @@ public class BreweryRepositoryTests ("Description", typeof(string)), ("CreatedAt", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)), - ("Timer", typeof(byte[])), + ("RowVersion", typeof(byte[])), ("BreweryPostLocationId", typeof(Guid)), ("CityId", typeof(Guid)), ("AddressLine1", typeof(string)), @@ -280,7 +280,7 @@ public class BreweryRepositoryTests "Updated description", DateTime.UtcNow, DateTime.UtcNow, - newTimer, + newRowVersion, Guid.NewGuid(), Guid.NewGuid(), "123 Main St", @@ -304,7 +304,7 @@ public class BreweryRepositoryTests PostedById = Guid.NewGuid(), BreweryName = "Renamed Brewery", Description = "Updated description", - Timer = [0x00, 0x01], + RowVersion = [0x00, 0x01], Location = new BreweryPostLocation { CityId = Guid.NewGuid(), @@ -316,7 +316,7 @@ public class BreweryRepositoryTests BreweryPost result = await repo.UpdateAsync(brewery); result.BreweryPostId.Should().Be(breweryId); - result.Timer.Should().Equal(newTimer); + result.RowVersion.Should().Equal(newRowVersion); } [Fact] @@ -329,14 +329,14 @@ public class BreweryRepositoryTests .ReturnsScalar((int?)null); BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), Timer = [0x00, 0x01] }; + BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), RowVersion = [0x00, 0x01] }; Func act = async () => await repo.UpdateAsync(brewery); await act.Should().ThrowAsync(); } [Fact] - public async Task UpdateAsync_ThrowsConflict_WhenTimerStale() + public async Task UpdateAsync_ThrowsConflict_WhenRowVersionStale() { MockDbConnection conn = new(); conn.Mocks.When(cmd => @@ -344,10 +344,10 @@ public class BreweryRepositoryTests ) .ReturnsScalar(1); - // Brewery exists but the conditional UPDATE (WHERE ... AND Timer = @Timer) matches no rows, - // since the caller's Timer no longer matches the stored row. + // Brewery exists but the conditional UPDATE (WHERE ... AND RowVersion = @RowVersion) matches no rows, + // since the caller's RowVersion no longer matches the stored row. conn.Mocks.When(cmd => - cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND Timer = @Timer") + cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND RowVersion = @RowVersion") ) .ReturnsScalar(0); @@ -357,7 +357,7 @@ public class BreweryRepositoryTests BreweryPostId = Guid.NewGuid(), BreweryName = "Renamed Brewery", Description = "Updated description", - Timer = [0x00, 0x01], + RowVersion = [0x00, 0x01], }; Func act = async () => await repo.UpdateAsync(brewery); diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs index 3e33a8c5..489de1d7 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs @@ -19,13 +19,13 @@ public record UpdateBreweryLocation( /// /// Updates an existing brewery post. Bound directly from the request body of PUT /api/brewery/{id}. -/// A null clears the brewery's location. must be the +/// A null clears the brewery's location. must be the /// row-version last read for this brewery (e.g. from a prior GET); the update is rejected with a /// 409 Conflict if the brewery was modified since then. /// public record UpdateBreweryCommand( Guid BreweryPostId, - byte[] Timer, + byte[] RowVersion, Guid PostedById, string BreweryName, string Description, diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs index 821babec..21d4d606 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs @@ -19,7 +19,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository) /// /// /// Thrown when the brewery was modified by another request since . - /// Timer was read. + /// RowVersion was read. /// public async Task Handle( UpdateBreweryCommand request, @@ -32,7 +32,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository) PostedById = request.PostedById, BreweryName = request.BreweryName, Description = request.Description, - Timer = request.Timer, + RowVersion = request.RowVersion, UpdatedAt = DateTime.UtcNow, Location = request.Location is null ? null diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs index f04e349e..a8757fcb 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryValidator.cs @@ -7,8 +7,8 @@ public class UpdateBreweryValidator : AbstractValidator { public UpdateBreweryValidator() { - RuleFor(x => x.Timer) + RuleFor(x => x.RowVersion) .NotEmpty() - .WithMessage("Timer is required to detect conflicting concurrent updates."); + .WithMessage("RowVersion is required to detect conflicting concurrent updates."); } } diff --git a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs index 73b8f151..d53e4764 100644 --- a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs +++ b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs @@ -87,7 +87,7 @@ public class BreweryController(IMediator mediator) : ControllerBase /// /// 200 OK with the updated brewery; 400 Bad Request if the route ID does not match the /// payload ID; 404 Not Found if the brewery or its CityId does not exist; or - /// 409 Conflict if the brewery was modified since command.Timer was read. + /// 409 Conflict if the brewery was modified since command.RowVersion was read. /// [HttpPut("{id:guid}")] public async Task>> Update( diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index f0e19abc..414f7cb0 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -1,51 +1,35 @@ namespace Features.Breweries.Dtos; /// Location details of a brewery, as returned by the API. -public class BreweryLocationDto -{ - public Guid BreweryPostLocationId { get; set; } - public Guid CityId { get; set; } - public string CityName { get; set; } = string.Empty; +/// The brewery's geographic coordinates, in a raw binary representation. +public record BreweryLocationDto( + Guid BreweryPostLocationId, + Guid CityId, + string CityName, + string StateProvinceName, + string StateProvinceCode, + string CountryName, + string CountryCode, + string AddressLine1, + string? AddressLine2, + string PostalCode, + byte[]? Coordinates +); - 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; - - public string? AddressLine2 { get; set; } - - public string PostalCode { get; set; } = string.Empty; - - /// Gets or sets the brewery's geographic coordinates, in a raw binary representation. - public byte[]? Coordinates { get; set; } -} +public record BeerPreviewDto(Guid BeerPostId, string Name, decimal ABV, int IBU); /// A brewery post, as returned by the API. -public class BreweryDto -{ - public Guid BreweryPostId { get; set; } - - public Guid PostedById { get; set; } - - public string BreweryName { get; set; } = string.Empty; - - public string Description { get; set; } = string.Empty; - - public DateTime CreatedAt { get; set; } - - /// Gets or sets the date and time of the last edit, or if never edited. - public DateTime? UpdatedAt { get; set; } - - /// Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates. - public byte[]? Timer { get; set; } - - /// Gets or sets the brewery's location, or if none has been set. - public BreweryLocationDto? Location { get; set; } -} - - +/// The date and time of the last edit, or if never edited. +/// The row-version/concurrency token used to detect conflicting concurrent updates. +/// The brewery's location, or if none has been set. +public record BreweryDto( + Guid BreweryPostId, + Guid PostedById, + string BreweryName, + string Description, + DateTime CreatedAt, + DateTime? UpdatedAt, + byte[]? RowVersion, + BreweryLocationDto? Location, + List BeerPosts +); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index dd635792..d4ef947a 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -11,31 +11,42 @@ public static class BreweryDtoMapper /// 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, - Timer = brewery.Timer, - Location = brewery.Location is null + 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 ? 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?.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, - Coordinates = brewery.Location.Coordinates, - }, - }; + : 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?.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, + Coordinates: brewery.Location.Coordinates + ), + BeerPosts: + [ + .. brewery.BeerPosts.Select(beer => new BeerPreviewDto( + BeerPostId: beer.BeerPostId, + Name: beer.Name, + ABV: beer.ABV, + IBU: beer.IBU + )), + ] + ); } } diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 872aa384..204d666f 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -22,29 +22,28 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) : Repository(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, - c.CityName, - c.StateProvinceID, - sp.StateProvinceName, - sp.ISO3166_2, - sp.CountryID, - co.CountryName, - co.ISO3166_1 - """; + private const string SelectColumns = """ + bp.BreweryPostID, + bp.PostedByID, + bp.BreweryName, + bp.Description, + bp.CreatedAt, + bp.UpdatedAt, + bp.RowVersion, + 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 @@ -52,22 +51,20 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) /// 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 - """; + 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 + """; /// public async Task GetByIdAsync(Guid id) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); - command.CommandText = - $""" + command.CommandText = $""" SELECT {SelectColumns} FROM {FromJoins} WHERE bp.BreweryPostID = @BreweryPostId @@ -83,8 +80,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); - command.CommandText = - $""" + command.CommandText = $""" SELECT {SelectColumns} FROM {FromJoins} ORDER BY bp.CreatedAt DESC @@ -117,7 +113,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new { brewery.BreweryPostId }, transaction ) - ) is not null; + ) + is not null; if (!breweryExists) throw new NotFoundException("Brewery not found."); @@ -131,7 +128,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new { brewery.Location.CityId }, transaction ) - ) is not null; + ) + is not null; if (!cityExists) throw new NotFoundException("City not found."); @@ -142,14 +140,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) """ UPDATE dbo.BreweryPost SET BreweryName = @BreweryName, Description = @Description, UpdatedAt = GETDATE() - WHERE BreweryPostID = @BreweryPostId AND Timer = @Timer + WHERE BreweryPostID = @BreweryPostId AND RowVersion = @RowVersion """, new { brewery.BreweryPostId, brewery.BreweryName, brewery.Description, - brewery.Timer, + brewery.RowVersion, }, transaction ) @@ -180,7 +178,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new { brewery.BreweryPostId }, transaction ) - ) is not null; + ) + is not null; if (locationExists) await connection.ExecuteAsync( @@ -208,8 +207,21 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new CommandDefinition( """ INSERT INTO dbo.BreweryPostLocation - (BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates) - VALUES (@BreweryPostLocationId, @BreweryPostId, @CityId, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates) + (BreweryPostLocationID, + BreweryPostID, + CityID, + AddressLine1, + AddressLine2, + PostalCode, + Coordinates) + VALUES + (@BreweryPostLocationId, + @BreweryPostId, + @CityId, + @AddressLine1, + @AddressLine2, + @PostalCode, + @Coordinates) """, new { @@ -271,7 +283,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new { brewery.PostedById }, transaction ) - ) is not null; + ) + is not null; if (!userExists) throw new NotFoundException("User not found."); @@ -283,14 +296,26 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new { brewery.Location.CityId }, transaction ) - ) is not null; + ) + is not null; if (!cityExists) throw new NotFoundException("City not found."); await connection.ExecuteAsync( new CommandDefinition( - "INSERT INTO dbo.BreweryPost (BreweryPostID, BreweryName, Description, PostedByID) VALUES (@BreweryPostId, @BreweryName, @Description, @PostedById)", + """ + INSERT INTO dbo.BreweryPost + (BreweryPostID, + BreweryName, + Description, + PostedByID) + VALUES + (@BreweryPostId, + @BreweryName, + @Description, + @PostedById) + """, new { brewery.BreweryPostId, @@ -344,7 +369,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) int ordDescription = reader.GetOrdinal("Description"); int ordCreatedAt = reader.GetOrdinal("CreatedAt"); int ordUpdatedAt = reader.GetOrdinal("UpdatedAt"); - int ordTimer = reader.GetOrdinal("Timer"); + int ordRowVersion = reader.GetOrdinal("RowVersion"); BreweryPost brewery = new() { @@ -354,7 +379,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) Description = reader.GetString(ordDescription), CreatedAt = reader.GetDateTime(ordCreatedAt), UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt), - Timer = reader.IsDBNull(ordTimer) ? null : reader.GetFieldValue(ordTimer), + RowVersion = reader.IsDBNull(ordRowVersion) + ? null + : reader.GetFieldValue(ordRowVersion), }; int ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); diff --git a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs index a56f525d..e22c8520 100644 --- a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs @@ -23,17 +23,17 @@ public interface IBreweryRepository /// /// Updates a brewery post's name and description, and upserts or clears its location, enforcing - /// optimistic concurrency via 's Timer: the update is rejected - /// if the row was modified since Timer was read. When 's + /// optimistic concurrency via 's RowVersion: the update is rejected + /// if the row was modified since RowVersion was read. When 's /// Location is null, any existing location for the brewery is removed. - /// Returns the freshly persisted brewery, including its new Timer. + /// Returns the freshly persisted brewery, including its new RowVersion. /// /// /// Thrown when no brewery exists with the given ID, or the given location's CityId does not /// exist. /// /// - /// Thrown when 's Timer no longer matches the stored row (it was + /// Thrown when 's RowVersion no longer matches the stored row (it was /// modified by another request since it was last read). /// Task UpdateAsync(BreweryPost brewery);