Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)

* Integrate Spectre.Console into seed application, use async data loading

* Replace stored-procedure repositories with Dapper-based repositories

Removes the USP_* stored procedures for Auth, Location, and Brewery
data access in favour of services built with Dapper.

Adds a new Features.Locations slice and optimistic concurrency support
for the Brewery slice.

* Add new documentation for Dapper migration/database schema

* Update FluentAssertions to most recent open source ver

* Update XML doc comment in backend projects

* Modify brewery location dto to include denormalized data

* - 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.

* updates

* Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts

* Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture

* Remove repository tests, usages of registration dto used in repo

* archive old stored procs/functions

* update stale docs

* Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
This commit is contained in:
2026-08-21 13:09:13 -04:00
committed by Aaron Po
parent b27b5bdb40
commit de1e9ac02b
151 changed files with 2790 additions and 1988 deletions

View File

@@ -14,6 +14,9 @@ namespace API.Core.Controllers;
[Route("error")] // required
public class NotFoundController : ControllerBase
{
/// <summary>
/// Returns a 404 response for any request that did not match a route.
/// </summary>
[HttpGet("404")] //required
public IActionResult Handle404()
{

View File

@@ -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/"]

View File

@@ -11,12 +11,12 @@ using Features.UserManagement.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Jwt;
using Infrastructure.Sql;
using Infrastructure.Sql.DependencyInjection;
using Shared.Application.Behaviors;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter
// Add current controllers to the exception filter
builder
.Services.AddControllers(options =>
{
@@ -37,7 +37,7 @@ builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddFluentValidationAutoValidation();
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
// Add MediatR.
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
builder.Services.AddMediatR(cfg =>
{
@@ -60,15 +60,14 @@ if (!builder.Environment.IsProduction())
// Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
builder.Services.AddInfrastructureSql();
builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails();
// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because
// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly.
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
// Register the exception filter

View File

@@ -12,7 +12,7 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": ""
"Default": ""
},
"Jwt": {
"SecretKey": "",

View File

@@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="dbup" Version="5.0.41" />
<!-- Reqnroll core, xUnit adapter and code-behind generator -->

View File

@@ -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/"]

View File

@@ -14,6 +14,7 @@ Scenario: Successful registration with valid details
Scenario: Registration fails with existing username
Given the API is running
And I have an existing account
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
@@ -21,6 +22,7 @@ Scenario: Registration fails with existing username
Scenario: Registration fails with existing email
Given the API is running
And I have an existing account
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |

View File

@@ -83,10 +83,59 @@ public class AuthSteps(ScenarioContext scenario)
);
}
private const string FixtureUsername = "test.user";
private const string FixturePassword = "Password1!";
private const string FixtureEmail = "test.user@thebiergarten.app";
// Scenarios run in parallel across feature classes, and this fixture is shared by name.
// Serializing provisioning avoids two scenarios racing to INSERT the same username/email,
// which would otherwise surface as a raw SQL unique-constraint error instead of a clean 409.
private static readonly SemaphoreSlim FixtureProvisioningLock = new(1, 1);
[Given("I have an existing account")]
public void GivenIHaveAnExistingAccount()
public async Task GivenIHaveAnExistingAccount()
{
scenario[TestUserKey] = ("test.user", "password");
scenario[TestUserKey] = (FixtureUsername, FixturePassword);
HttpClient client = GetClient();
var registrationData = new
{
username = FixtureUsername,
firstName = "Test",
lastName = "User",
email = FixtureEmail,
dateOfBirth = "1990-01-01",
password = FixturePassword,
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(
JsonSerializer.Serialize(registrationData),
Encoding.UTF8,
"application/json"
),
};
// Registration is used (rather than relying on seeded data) so the fixture account is
// guaranteed to exist regardless of run order. A 409 means another scenario in this run
// already provisioned it, which is equally fine.
await FixtureProvisioningLock.WaitAsync();
HttpResponseMessage response;
try
{
response = await client.SendAsync(requestMessage);
}
finally
{
FixtureProvisioningLock.Release();
}
if ((int)response.StatusCode is not (201 or 409))
throw new InvalidOperationException(
$"Failed to provision fixture account '{FixtureUsername}': "
+ $"{(int)response.StatusCode} {response.StatusCode}"
);
}
[Given("I do not have an existing account")]
@@ -320,7 +369,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task GivenIAmLoggedIn()
{
HttpClient client = GetClient();
var loginData = new { username = "test.user", password = "password" };
var loginData = new { username = FixtureUsername, password = FixturePassword };
string body = JsonSerializer.Serialize(loginData);
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")

View File

@@ -27,6 +27,8 @@
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
<Project Path="Features/Features.Emails/Features.Emails.csproj" />
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
<Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
</Folder>
<Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />

View File

@@ -10,7 +10,10 @@
<ItemGroup>
<PackageReference Include="dbup" Version="5.0.41" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -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

View File

@@ -2,6 +2,7 @@
using System.Reflection;
using DbUp;
using DbUp.Engine;
using Infrastructure.Sql;
using Microsoft.Data.SqlClient;
namespace Database.Migrations;
@@ -96,12 +97,6 @@ public static class Program
Console.WriteLine($"Database '{settings.DatabaseName}' is present.");
}
/// <summary>
/// Opens a connection, executes a non-query batch parameterised by database name, and
/// disposes both the command and the connection.
/// </summary>
/// <param name="connectionString">The connection string to open.</param>
/// <param name="sql">The Transact-SQL batch to execute.</param>
/// <param name="databaseName">The value bound to the <c>@databaseName</c> parameter.</param>
private static async Task ExecuteNonQueryAsync(
string connectionString,
@@ -171,16 +166,11 @@ public static class Program
/// </exception>
public static DatabaseSettings FromEnvironment()
{
string server = Required("DB_SERVER");
string databaseName = Required("DB_NAME");
string user = Required("DB_USER");
string password = Required("DB_PASSWORD");
bool trustServerCertificate =
!bool.TryParse(
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
out bool parsedTrust
) || parsedTrust;
string databaseName =
Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException(
"The DB_NAME environment variable is not set."
);
bool clearDatabase = string.Equals(
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
@@ -189,28 +179,11 @@ public static class Program
);
return new DatabaseSettings(
Build(databaseName),
Build("master"),
SqlConnectionStringHelper.BuildConnectionString(databaseName),
SqlConnectionStringHelper.BuildMasterConnectionString(),
databaseName,
clearDatabase
);
string Build(string initialCatalog) =>
new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = initialCatalog,
UserID = user,
Password = password,
Encrypt = true,
TrustServerCertificate = trustServerCertificate,
}.ConnectionString;
}
private static string Required(string variableName) =>
Environment.GetEnvironmentVariable(variableName)
?? throw new InvalidOperationException(
$"The {variableName} environment variable is not set."
);
}
}

View File

@@ -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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion 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,
RowVersion ROWVERSION,
CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID),

View File

@@ -1,15 +0,0 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode(
@CountryCode NVARCHAR(2)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId;
END;

View File

@@ -1,13 +0,0 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode(
@StateProvinceCode NVARCHAR(6)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;
RETURN @StateProvinceId;
END;

View File

@@ -1,31 +0,0 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
DECLARE
@Inserted TABLE
(
UserAccountID UNIQUEIDENTIFIER
);
INSERT INTO UserAccount
(Username,
FirstName,
LastName,
DateOfBirth,
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
RETURN
END
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -1,15 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Email = @Email;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId;
END

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR(64)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Username = @Username;
END;

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
UPDATE UserAccount
SET Username = @Username,
FirstName = @FirstName,
LastName = @LastName,
DateOfBirth = @DateOfBirth,
Email = @Email
WHERE UserAccountId = @UserAccountId;
IF
@@ROWCOUNT = 0
BEGIN
THROW
50001, 'UserAccount with the specified ID does not exist.', 1;
END
END;

View File

@@ -1,18 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1;
COMMIT TRANSACTION;
END;

View File

@@ -1,50 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT,
@Username = @Username,
@FirstName = @FirstName,
@LastName = @LastName,
@DateOfBirth = @DateOfBirth,
@Email = @Email;
IF
@UserAccountId_ IS NULL
BEGIN
THROW
50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF
@@ROWCOUNT = 0
BEGIN
THROW
50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
END

View File

@@ -1,31 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
IF @@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials -- set them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_;
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
END;

View File

@@ -1,27 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE();
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification
(UserAccountId, VerificationDateTime)
VALUES (@UserAccountID_, @VerificationDateTime);
COMMIT TRANSACTION;
END

View File

@@ -1,38 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City
(StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION
END;

View File

@@ -1,24 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF
EXISTS (SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES (@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
END;

View File

@@ -1,32 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -1,59 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@CityID UNIQUEIDENTIFIER,
@AddressLine1 NVARCHAR(256),
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID;
END

View File

@@ -1,17 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET
NOCOUNT ON;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT 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
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -1,9 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *
FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID;
END

View File

@@ -1,77 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -10,13 +10,8 @@
<ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<!--
remove this override once microsoft ships official fix to vulnerability
https:
-->
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="Spectre.Console" Version="0.57.2" />
</ItemGroup>
<ItemGroup>
@@ -24,14 +19,9 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
ConnectionStrings.SqliteConnectionString at runtime. -->
<None
CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"
/>
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
#region
#region
using Microsoft.Data.SqlClient;
using Infrastructure.Sql;
#endregion
@@ -8,41 +8,7 @@ namespace Database.Seed.DatabaseHelpers;
public class ConnectionStrings
{
public static string SqlServerConnectionString => GetSqlServerConnectionString();
public static string SqlServerConnectionString => SqlConnectionStringHelper.BuildConnectionString();
public static string SqliteConnectionString =>
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
private static string GetSqlServerConnectionString()
{
string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
string dbName =
Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
string trustServerCertificate =
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true,
};
return builder.ConnectionString;
}
}

View File

@@ -7,112 +7,257 @@ using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Breweries.DependencyInjection;
using Features.Breweries.Repository;
using Features.Locations.DependencyInjection;
using Features.Locations.Dtos;
using Features.Locations.Repository;
using idunno.Password;
using Infrastructure.PasswordHashing;
using Infrastructure.Sql;
using Microsoft.Data.Sqlite;
using Infrastructure.Sql.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
static async Task<int> main()
return await RunAsync();
static async Task<int> RunAsync()
{
IReadOnlyList<BreweryRecord> breweries;
IReadOnlyList<UserRecord> users;
try
{
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
breweries = reader.ReadBreweryRecords();
users = reader.ReadUserRecords();
ServiceCollection services = [];
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().Build());
services.AddInfrastructureSql();
services.AddFeaturesBreweries();
services.AddFeaturesLocations();
services.AddFeaturesAuth();
await using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository =
provider.GetRequiredService<IBreweryRepository>();
ILocationRepository locationRepository =
provider.GetRequiredService<ILocationRepository>();
IAuthRepository authRepository =
provider.GetRequiredService<IAuthRepository>();
IPasswordInfrastructure passwordInfrastructure =
provider.GetRequiredService<IPasswordInfrastructure>();
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]")
.LeftJustified());
AnsiConsole.MarkupLine(
"[grey]Connecting to SQLite source and loading seed data...[/]");
AnsiConsole.WriteLine();
PipelineSeedDataReader reader =
new(ConnectionStrings.SqliteConnectionString);
SeedData seedData = null!;
IReadOnlyList<Guid> postedByIds = [];
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading seed data...",
async ctx =>
{
seedData = await reader.ReadSeedDataAsync();
ctx.Status(
$"Loaded {seedData.Breweries.Count} breweries and {seedData.Users.Count} users."
);
}
);
AnsiConsole.MarkupLine(
$"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries."
);
AnsiConsole.Write(BuildBreweryTable(seedData.Breweries));
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(
$"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
AnsiConsole.Write(BuildUserTable(seedData.Users));
AnsiConsole.WriteLine();
AnsiConsole.WriteLine("Seed data loaded successfully.");
AnsiConsole.Write(
new Rule("[bold green]Loading seed data into target database.[/]")
.LeftJustified()
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading user data into target database...",
async ctx =>
{
postedByIds = await LoadUsersIntoDatabaseAsync(
authRepository,
passwordInfrastructure,
seedData.Users
);
ctx.Status("User data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading brewery data into target database...",
async ctx =>
{
await LoadBreweriesIntoDatabaseAsync(
breweryRepository,
locationRepository,
seedData.Breweries,
postedByIds
);
ctx.Status("Brewery data loaded into target database.");
}
);
return 0;
}
catch (SqliteException ex)
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error opening database connection: {ex.Message}");
Console.ResetColor();
AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
AnsiConsole.WriteException(ex);
return 1;
}
}
ServiceCollection services = [];
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
services.AddFeaturesBreweries();
services.AddFeaturesAuth();
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
IAuthRepository authRepository,
IPasswordInfrastructure passwordInfrastructure,
IReadOnlyList<UserRecord> users
)
{
List<Guid> userAccountIds = [];
using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository;
IAuthRepository authRepository;
IPasswordInfrastructure passwordInfrastructure;
try
foreach (UserRecord userRecord in users)
{
breweryRepository = provider.GetRequiredService<IBreweryRepository>();
authRepository = provider.GetRequiredService<IAuthRepository>();
passwordInfrastructure = provider.GetRequiredService<IPasswordInfrastructure>();
}
catch (InvalidOperationException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error configuring database connection: {ex.Message}");
Console.ResetColor();
return 1;
UserAccount userAccount = await authRepository.RegisterUserAsync(
new UserAccount()
{
FirstName = userRecord.User.FirstName,
LastName = userRecord.User.LastName,
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
Username = userRecord.User.Username,
Email = userRecord.Email,
UserCredential = new UserCredential
{
Hash = passwordInfrastructure.Hash(
password: PasswordGenerator.Generate(64, 12, 12))
}
}
);
userAccountIds.Add(userAccount.UserAccountId);
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {breweries.Count} breweries.");
Console.ResetColor();
return userAccountIds;
}
/// <summary>
/// Persists each <paramref name="breweries" /> record, resolving (and creating, if needed) its
/// Country/StateProvince/City chain via <paramref name="locationRepository" />. The pipeline
/// data has no street-level address or a specific user tied to each brewery, so a placeholder
/// address is used and <c>PostedById</c> is assigned round-robin from <paramref name="posterUserIds" />
/// to keep seeding deterministic across runs.
/// </summary>
static async Task LoadBreweriesIntoDatabaseAsync(
IBreweryRepository breweryRepository,
ILocationRepository locationRepository,
IReadOnlyList<BreweryRecord> breweries,
IReadOnlyList<Guid> posterUserIds
)
{
if (posterUserIds.Count == 0)
throw new InvalidOperationException(
"Cannot load breweries without any registered users.");
for (int i = 0; i < breweries.Count; i++)
{
BreweryRecord brewery = breweries[i];
Console.WriteLine(
$"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"
BreweryRecord breweryRecord = breweries[i];
Guid cityId = await locationRepository.GetOrCreateCityIdAsync(
new CityLocation(
breweryRecord.Address.City.CityName,
breweryRecord.Address.City.StateProvince,
breweryRecord.Address.City.Iso31662,
breweryRecord.Address.City.Country,
breweryRecord.Address.City.Iso31661
)
);
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,
},
}
);
}
}
static Table BuildBreweryTable(IReadOnlyList<BreweryRecord> breweries)
{
Table table = new Table()
.AddColumn("Brewery Name (EN)")
.AddColumn("Brewery Name (Local)")
.AddColumn("City")
.AddColumn("State/Province")
.AddColumn("Country")
.AddColumn("Postal Code");
foreach (BreweryRecord brewery in breweries)
{
table.AddRow(
brewery.Brewery.NameEn,
brewery.Brewery.NameLocal,
brewery.Address.City.CityName,
brewery.Address.City.StateProvince,
brewery.Address.City.Country,
brewery.Address.PostalCode
);
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {users.Count} users.");
Console.ResetColor();
return table;
}
static Table BuildUserTable(IReadOnlyList<UserRecord> users)
{
Table table = new Table()
.AddColumn("Username")
.AddColumn("First Name")
.AddColumn("Last Name")
.AddColumn("Email")
.AddColumn("Date of Birth");
foreach (UserRecord user in users)
{
string username = user.User.Username;
string firstName = user.User.FirstName;
string lastName = user.User.LastName;
string email = user.Email;
DateTime dateOfBirth = DateTime.Parse(user.DateOfBirth);
string generatedPassword = PasswordGenerator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
table.AddRow(
user.User.Username,
user.User.FirstName,
user.User.LastName,
user.Email,
user.DateOfBirth.ToString()
);
string hashedPassword = passwordInfrastructure.Hash(generatedPassword);
UserRegistrationDto registrationDto = new(
username,
firstName,
lastName,
email,
dateOfBirth,
hashedPassword
);
try
{
UserAccount createdUser = await authRepository.RegisterUserAsync(registrationDto);
Console.WriteLine(
$"Created user: {createdUser.Username} ({createdUser.UserAccountId})"
);
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error creating user {username}: {ex.Message}");
Console.ResetColor();
}
}
return 0;
}
return await main();
return table;
}

View File

@@ -1,13 +1,14 @@
#region
using System.Text.Json;
using Database.Seed.PipelineData;
using Microsoft.Data.Sqlite;
#endregion
namespace Database.Seed.Sqlite;
public sealed record SeedData(
IReadOnlyList<BreweryRecord> Breweries,
IReadOnlyList<UserRecord> Users
);
public sealed class PipelineSeedDataReader
{
private readonly string _connectionString;
@@ -17,13 +18,40 @@ public sealed class PipelineSeedDataReader
_connectionString = connectionString;
}
public IReadOnlyList<BreweryRecord> ReadBreweryRecords()
/// <summary>
/// Reads breweries and users in a single connection, loading the cities
/// lookup only once and sharing it between both reads.
/// </summary>
public async Task<SeedData> ReadSeedDataAsync(CancellationToken cancellationToken = default)
{
using SqliteConnection connection = new(_connectionString);
connection.Open();
await using SqliteConnection connection = new(_connectionString);
await connection.OpenAsync(cancellationToken);
IReadOnlyDictionary<long, City> cities = ReadCities(connection);
IReadOnlyDictionary<long, City> cities = await ReadCitiesAsync(
connection,
cancellationToken
);
IReadOnlyList<BreweryRecord> breweries = await ReadBreweryRecordsAsync(
connection,
cities,
cancellationToken
);
IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync(
connection,
cities,
cancellationToken
);
return new SeedData(breweries, users);
}
private static async Task<IReadOnlyList<BreweryRecord>> ReadBreweryRecordsAsync(
SqliteConnection connection,
IReadOnlyDictionary<long, City> cities,
CancellationToken cancellationToken
)
{
const string sql = """
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
ba.city_id, ba.postal_code
@@ -31,25 +59,32 @@ public sealed class PipelineSeedDataReader
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
""";
using SqliteCommand command = new(sql, connection);
using SqliteDataReader reader = command.ExecuteReader();
await using SqliteCommand command = new(sql, connection);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
const int nameEnIndex = 0;
const int descriptionEnIndex = 1;
const int nameLocalIndex = 2;
const int descriptionLocalIndex = 3;
const int cityIdIndex = 4;
const int postalCodeIndex = 5;
List<BreweryRecord> records = [];
while (reader.Read())
while (await reader.ReadAsync(cancellationToken))
records.Add(
new BreweryRecord
{
Brewery = new BreweryResult
{
NameEn = reader.GetString(0),
DescriptionEn = reader.GetString(1),
NameLocal = reader.GetString(2),
DescriptionLocal = reader.GetString(3),
NameEn = reader.GetString(nameEnIndex),
DescriptionEn = reader.GetString(descriptionEnIndex),
NameLocal = reader.GetString(nameLocalIndex),
DescriptionLocal = reader.GetString(descriptionLocalIndex),
},
Address = new BreweryAddress
{
City = cities[reader.GetInt64(4)],
PostalCode = reader.GetString(5),
City = cities[reader.GetInt64(cityIdIndex)],
PostalCode = reader.GetString(postalCodeIndex),
},
}
);
@@ -57,13 +92,12 @@ public sealed class PipelineSeedDataReader
return records;
}
public IReadOnlyList<UserRecord> ReadUserRecords()
private static async Task<IReadOnlyList<UserRecord>> ReadUserRecordsAsync(
SqliteConnection connection,
IReadOnlyDictionary<long, City> cities,
CancellationToken cancellationToken
)
{
using SqliteConnection connection = new(_connectionString);
connection.Open();
IReadOnlyDictionary<long, City> cities = ReadCities(connection);
const string sql = """
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio,
u.activity_weight, u.email, u.date_of_birth,
@@ -72,29 +106,40 @@ public sealed class PipelineSeedDataReader
JOIN user_addresses ua ON ua.user_id = u.id;
""";
using SqliteCommand command = new(sql, connection);
using SqliteDataReader reader = command.ExecuteReader();
await using SqliteCommand command = new(sql, connection);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
const int firstNameIndex = 0;
const int lastNameIndex = 1;
const int genderIndex = 2;
const int usernameIndex = 3;
const int bioIndex = 4;
const int activityWeightIndex = 5;
const int emailIndex = 6;
const int dateOfBirthIndex = 7;
const int cityIdIndex = 8;
const int postalCodeIndex = 9;
List<UserRecord> records = [];
while (reader.Read())
while (await reader.ReadAsync(cancellationToken))
records.Add(
new UserRecord
{
User = new UserResult
{
FirstName = reader.GetString(0),
LastName = reader.GetString(1),
Gender = reader.GetString(2),
Username = reader.GetString(3),
Bio = reader.GetString(4),
ActivityWeight = (float)reader.GetDouble(5),
FirstName = reader.GetString(firstNameIndex),
LastName = reader.GetString(lastNameIndex),
Gender = reader.GetString(genderIndex),
Username = reader.GetString(usernameIndex),
Bio = reader.GetString(bioIndex),
ActivityWeight = (float)reader.GetDouble(activityWeightIndex),
},
Email = reader.GetString(6),
DateOfBirth = reader.GetString(7),
Email = reader.GetString(emailIndex),
DateOfBirth = reader.GetString(dateOfBirthIndex),
Address = new UserAddress
{
City = cities[reader.GetInt64(8)],
PostalCode = reader.GetString(9),
City = cities[reader.GetInt64(cityIdIndex)],
PostalCode = reader.GetString(postalCodeIndex),
},
}
);
@@ -102,7 +147,10 @@ public sealed class PipelineSeedDataReader
return records;
}
private static IReadOnlyDictionary<long, City> ReadCities(SqliteConnection connection)
private static async Task<IReadOnlyDictionary<long, City>> ReadCitiesAsync(
SqliteConnection connection,
CancellationToken cancellationToken
)
{
const string sql = """
SELECT
@@ -118,33 +166,34 @@ public sealed class PipelineSeedDataReader
FROM cities;
""";
using SqliteCommand command = new(sql, connection);
using SqliteDataReader reader = command.ExecuteReader();
await using SqliteCommand command = new(sql, connection);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
Dictionary<long, City> cities = [];
const int cityNameBindIndex = 1;
const int stateProvinceBindIndex = 2;
const int iso31662BindIndex = 3;
const int countryBindIndex = 4;
const int iso31661BindIndex = 5;
const int localLanguagesBindIndex = 6;
const int countryFormatRegexBindIndex = 7;
const int cityRegexesBindIndex = 8;
const int idIndex = 0;
const int cityNameIndex = 1;
const int stateProvinceIndex = 2;
const int iso31662Index = 3;
const int countryIndex = 4;
const int iso31661Index = 5;
const int localLanguagesIndex = 6;
const int countryFormatRegexIndex = 7;
const int cityRegexesIndex = 8;
while (reader.Read())
cities[reader.GetInt64(0)] = new City
while (await reader.ReadAsync(cancellationToken))
cities[reader.GetInt64(idIndex)] = new City
{
CityName = reader.GetString(cityNameBindIndex),
StateProvince = reader.GetString(stateProvinceBindIndex),
Iso31662 = reader.GetString(iso31662BindIndex),
Country = reader.GetString(countryBindIndex),
Iso31661 = reader.GetString(iso31661BindIndex),
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesBindIndex)),
CityName = reader.GetString(cityNameIndex),
StateProvince = reader.GetString(stateProvinceIndex),
Iso31662 = reader.GetString(iso31662Index),
Country = reader.GetString(countryIndex),
Iso31661 = reader.GetString(iso31661Index),
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesIndex)),
PostalCode = new PostalCodeSpec
{
CountryFormatRegex = reader.GetString(countryFormatRegexBindIndex),
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesBindIndex)),
CountryFormatRegex = reader.GetString(countryFormatRegexIndex),
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)),
},
};

View File

@@ -1,17 +0,0 @@
namespace Database.Seed;
public interface IExportDatabase
{
void SeedLocations(IEnumerable<Location> locations);
void SeedUsers(IEnumerable<User> users);
void SeedBreweries(IEnumerable<Brewery> breweries);
}
public interface IImportDatabase
{
IEnumerable<Location> GetLocations();
IEnumerable<User> GetUsers();
IEnumerable<Brewery> GetBreweries();
}

View File

@@ -0,0 +1,29 @@
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; }
public BeerStyle? BeerStyle { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Domain.Entities;
public sealed class BeerStyle
{
// PK
public Guid BeerStyleId { get; set; } = Guid.Empty;
// Attributes
public string BeerStyleName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
// Audit Fields
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public byte[]? RowVersion { get; set; }
}

View File

@@ -1,14 +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 UserAccount PostedBy { get; set; } = null!;
public byte[]? RowVersion { get; set; }
// Navigation Properties
public BreweryPostLocation? Location { get; set; }
public List<BeerPost> BeerPosts { get; set; } = [];
}

View File

@@ -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 byte[]? Coordinates { get; set; }
public byte[]? Timer { get; set; }
public BreweryPost BreweryPost { get; set; } = null!;
// Audit Fields
public byte[]? RowVersion { get; set; }
// Navigation Properties
public City? City { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace Domain.Entities;
public sealed class City
{
// PK
public Guid CityId { get; set; }
// FK References
public Guid StateProvinceId { get; set; }
// Attributes
public string CityName { get; set; } = string.Empty;
// Audit Fields
public byte[]? RowVersion { get; set; }
// Navigation Properties
public StateProvince? StateProvince { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Domain.Entities;
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.
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace Domain.Entities;
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;
// Audit Fields
public byte[]? RowVersion { get; set; }
// Navigation Properties
public Country? Country { get; set; }
}

View File

@@ -1,17 +1,23 @@
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 ICollection<BreweryPost> BreweryPosts { get; set; } = [];
public ICollection<UserCredential> UserCredentials { get; set; } = [];
public UserVerification? Verification { get; set; }
public byte[]? RowVersion { get; set; }
// Navigation properties
public UserCredential? UserCredential { get; set; }
}

View File

@@ -1,12 +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 UserAccount UserAccount { get; set; } = null!;
public bool IsRevoked { get; set; }
public DateTime? RevokedAt { get; set; }
// Audit Fields
public DateTime CreatedAt { get; set; }
public byte[]? RowVersion { get; set; }
}

View File

@@ -1,10 +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; }
public UserAccount UserAccount { get; set; } = null!;
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -68,13 +68,13 @@ public class RegisterUserHandlerTests
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
new UserRegistrationDto(
command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
It.Is<UserAccount>(ua =>
ua.FirstName == command.FirstName
&& ua.LastName == command.LastName
&& ua.Username == command.Username
&& ua.Email == command.Email
&& ua.DateOfBirth == command.DateOfBirth
&& ua.UserCredential!.Hash == hashedPassword
)
)
)
@@ -141,7 +141,7 @@ public class RegisterUserHandlerTests
.WithMessage("Username or email already exists");
_authRepoMock.Verify(
x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()),
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
Times.Never
);
}
@@ -186,7 +186,7 @@ public class RegisterUserHandlerTests
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword)
It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
)
)
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
@@ -204,13 +204,13 @@ public class RegisterUserHandlerTests
_authRepoMock.Verify(
x =>
x.RegisterUserAsync(
new UserRegistrationDto(
command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
It.Is<UserAccount>(ua =>
ua.FirstName == command.FirstName
&& ua.LastName == command.LastName
&& ua.Username == command.Username
&& ua.DateOfBirth == command.DateOfBirth
&& ua.Email == command.Email
&& ua.UserCredential!.Hash == hashedPassword
)
),
Times.Once
@@ -230,7 +230,7 @@ public class RegisterUserHandlerTests
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()))
.Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
.ReturnsAsync(
new UserAccount
{

View File

@@ -12,8 +12,7 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,7 +1,7 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.Login;
using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
@@ -68,7 +68,7 @@ public class LoginHandlerTests
.Returns("refresh-token");
LoginPayload result = await _handler.Handle(
new LoginQuery(username, "any-password"),
new LoginCommand(username, "any-password"),
CancellationToken.None
);
@@ -88,7 +88,7 @@ public class LoginHandlerTests
.ReturnsAsync((UserAccount?)null);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(
@@ -110,7 +110,7 @@ public class LoginHandlerTests
.ReturnsAsync((UserCredential?)null);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
await act.Should()
.ThrowAsync<UnauthorizedException>()
@@ -144,7 +144,7 @@ public class LoginHandlerTests
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(
new LoginQuery(username, "wrong-password"),
new LoginCommand(username, "wrong-password"),
CancellationToken.None
);

View File

@@ -1,246 +0,0 @@
using Apps72.Dev.Data.DbMocker;
using Domain.Entities;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using FluentAssertions;
namespace Features.Auth.Tests.Repository;
public class AuthRepositoryTests
{
private static AuthRepository CreateRepo(MockDbConnection conn)
{
return new AuthRepository(new TestConnectionFactory(conn));
}
[Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{
Guid expectedUserId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
// Mock the subsequent read for the newly created user by id
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
expectedUserId,
"testuser",
"Test",
"User",
"test@example.com",
DateTime.UtcNow,
null,
new DateTime(1990, 1, 1),
null
)
);
AuthRepository repo = CreateRepo(conn);
UserAccount result = await repo.RegisterUserAsync(
new UserRegistrationDto(
"testuser",
"Test",
"User",
"test@example.com",
new DateTime(1990, 1, 1),
"hashedpassword123"
)
);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId);
result.Username.Should().Be("testuser");
result.FirstName.Should().Be("Test");
result.LastName.Should().Be("User");
result.Email.Should().Be("test@example.com");
result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1));
}
[Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
userId,
"emailuser",
"Email",
"User",
"emailuser@example.com",
DateTime.UtcNow,
null,
new DateTime(1990, 5, 15),
null
)
);
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
result.Username.Should().Be("emailuser");
result.Email.Should().Be("emailuser@example.com");
result.FirstName.Should().Be("Email");
result.LastName.Should().Be("User");
}
[Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty());
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull();
}
[Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
userId,
"usernameuser",
"Username",
"User",
"username@example.com",
DateTime.UtcNow,
null,
new DateTime(1985, 8, 20),
null
)
);
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
result.Username.Should().Be("usernameuser");
result.Email.Should().Be("username@example.com");
}
[Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.Empty());
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull();
}
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{
Guid userId = Guid.NewGuid();
Guid credentialId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(
MockTable
.WithColumns(
("UserCredentialId", typeof(Guid)),
("UserAccountId", typeof(Guid)),
("Hash", typeof(string)),
("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
);
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId);
result.UserAccountId.Should().Be(userId);
result.Hash.Should().Be("hashed_password_value");
}
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.Empty());
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull();
}
[Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully()
{
Guid userId = Guid.NewGuid();
string newPasswordHash = "new_hashed_password";
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
AuthRepository repo = CreateRepo(conn);
// Should not throw
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync();
}
}

View File

@@ -1,14 +0,0 @@
using System.Data.Common;
using Infrastructure.Sql;
namespace Features.Auth.Tests.Repository;
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection()
{
return _conn;
}
}

View File

@@ -1,10 +1,10 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Queries.Login;
namespace Features.Auth.Commands.Login;
/// <summary>
/// Authenticates a user using their username and password and issues new tokens. Bound directly
/// from the request body of <c>POST /api/auth/login</c>.
/// </summary>
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
public record LoginCommand(string Username, string Password) : IRequest<LoginPayload>;

View File

@@ -6,22 +6,22 @@ using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR;
namespace Features.Auth.Queries.Login;
namespace Features.Auth.Commands.Login;
/// <summary>
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
/// Handles <see cref="LoginCommand" /> by verifying credentials and issuing access/refresh tokens.
/// </summary>
public class LoginHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService
) : IRequestHandler<LoginQuery, LoginPayload>
) : IRequestHandler<LoginCommand, LoginPayload>
{
/// <exception cref="UnauthorizedException">
/// Thrown when the username does not match any account, the account has no active credential,
/// or the supplied password does not match the stored hash.
/// </exception>
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
{
UserAccount user =
await authRepo.GetUserByUsernameAsync(request.Username)

View File

@@ -1,8 +1,9 @@
using FluentValidation;
namespace Features.Auth.Queries.Login;
namespace Features.Auth.Commands.Login;
public class LoginValidator : AbstractValidator<LoginQuery>
/// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary>
public class LoginValidator : AbstractValidator<LoginCommand>
{
public LoginValidator()
{

View File

@@ -11,6 +11,10 @@ namespace Features.Auth.Commands.RefreshToken;
public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload>
{
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid or expired, or when the user account it refers to no
/// longer exists.
/// </exception>
public async Task<LoginPayload> Handle(
RefreshTokenCommand request,
CancellationToken cancellationToken

View File

@@ -2,6 +2,7 @@ using FluentValidation;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{
public RefreshTokenValidator()

View File

@@ -34,14 +34,18 @@ public class RegisterUserHandler(
string hashed = passwordInfrastructure.Hash(request.Password);
UserAccount createdUser = await authRepo.RegisterUserAsync(
new UserRegistrationDto(
request.Username,
request.FirstName,
request.LastName,
request.Email,
request.DateOfBirth,
hashed
)
new UserAccount
{
Username = request.Username,
FirstName = request.FirstName,
LastName = request.LastName,
DateOfBirth = request.DateOfBirth,
Email = request.Email,
UserCredential = new UserCredential
{
Hash = hashed,
}
}
);
string accessToken = tokenService.GenerateAccessToken(createdUser);

View File

@@ -2,6 +2,10 @@ using FluentValidation;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Validates the field-level constraints on <see cref="RegisterUserCommand" />: username format and
/// length, name lengths, email format, minimum age of 19, and password complexity.
/// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{
public RegisterUserValidator()

View File

@@ -1,9 +1,9 @@
using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Commands.Login;
using Features.Auth.Commands.RefreshToken;
using Features.Auth.Commands.RegisterUser;
using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -23,6 +23,11 @@ namespace Features.Auth.Controllers;
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{
/// <summary>Registers a new user account.</summary>
/// <remarks>
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
/// Returns 409 Conflict if the username or email is already taken.
/// </remarks>
[AllowAnonymous]
[HttpPost("register")]
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
@@ -40,11 +45,15 @@ public class AuthController(IMediator mediator) : ControllerBase
);
}
/// <summary>Authenticates a user by username and password and issues a new access/refresh token pair.</summary>
/// <remarks>
/// Anonymous access. Returns 200 OK with the tokens, or 401 Unauthorized if the credentials are invalid.
/// </remarks>
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginCommand command)
{
LoginPayload payload = await mediator.Send(query);
LoginPayload payload = await mediator.Send(command);
return Ok(
new ResponseBody<LoginPayload>
{
@@ -54,6 +63,11 @@ public class AuthController(IMediator mediator) : ControllerBase
);
}
/// <summary>Confirms a user account using a confirmation token.</summary>
/// <remarks>
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Returns 200 OK on success,
/// or 401 Unauthorized if <paramref name="token" /> is invalid or expired.
/// </remarks>
[HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
[FromQuery] string token
@@ -69,6 +83,12 @@ public class AuthController(IMediator mediator) : ControllerBase
);
}
/// <summary>Resends the account confirmation email for the given user, generating a fresh confirmation token.</summary>
/// <remarks>
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Always returns 200 OK, even
/// if <paramref name="userId" /> does not exist or the account is already confirmed, to avoid user
/// enumeration.
/// </remarks>
[HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{
@@ -76,6 +96,11 @@ public class AuthController(IMediator mediator) : ControllerBase
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
}
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
/// <remarks>
/// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is
/// invalid or expired.
/// </remarks>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(

View File

@@ -5,8 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Auth.DependencyInjection;
/// <summary>Registers the services required by Features.Auth with the dependency injection container.</summary>
public static class FeaturesAuthServiceCollectionExtensions
{
/// <summary>
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, and the Argon2 password hashing
/// infrastructure as scoped services.
/// </summary>
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
{
services.AddScoped<IAuthRepository, AuthRepository>();

View File

@@ -1,10 +0,0 @@
namespace Features.Auth.Dtos;
public record UserRegistrationDto(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string PasswordHash
);

View File

@@ -3,6 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.Auth</RootNamespace>
</PropertyGroup>
@@ -19,4 +20,8 @@
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Queries\" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,7 @@
using System.Data;
using System.Data.Common;
using Dapper;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Infrastructure.Sql;
using Microsoft.Data.SqlClient;
@@ -8,155 +9,184 @@ using Microsoft.Data.SqlClient;
namespace Features.Auth.Repository;
/// <summary>
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification.
/// Dapper-based implementation of <see cref="IAuthRepository" />, handling user registration,
/// credential lookup/rotation, and account verification.
/// </summary>
public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory),
IAuthRepository
{
/// <summary>
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
/// procedure, then fetches and returns the newly created user.
/// </summary>
/// <remarks>
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
/// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail.
/// </remarks>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
/// <inheritdoc />
/// <remarks>The account and credential are inserted within a single database transaction.</remarks>
public async Task<UserAccount> RegisterUserAsync(UserAccount ua)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
await using DbTransaction transaction = await connection.BeginTransactionAsync();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto;
Guid userAccountId;
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
object? result = await command.ExecuteScalarAsync();
Guid userAccountId = Guid.Empty;
if (result != null && result != DBNull.Value)
try
{
if (result is Guid g)
userAccountId = g;
else if (result is string s && Guid.TryParse(s, out Guid parsed))
userAccountId = parsed;
else if (result is byte[] bytes && bytes.Length == 16)
userAccountId = new Guid(bytes);
else
// Fallback: try to convert and parse string representation
try
{
string? str = result.ToString();
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
userAccountId = p;
}
catch
{
userAccountId = Guid.Empty;
}
userAccountId = await connection.ExecuteScalarAsync<Guid>(
new CommandDefinition(
"""
INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
OUTPUT INSERTED.UserAccountID
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
""",
new { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email},
transaction
)
);
int credentialRows = await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
VALUES (@UserAccountId, @Hash);
""",
new { UserAccountId = userAccountId, Hash = ua.UserCredential?.Hash },
transaction
)
);
if (credentialRows == 0)
throw new InvalidOperationException("Failed to create user credential.");
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
// perform round trip to get new User entity
return await GetUserByIdAsync(userAccountId)
?? throw new Exception("Failed to retrieve newly registered user.");
}
/// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
/// <inheritdoc />
public async Task<UserAccount?> GetUserByEmailAsync(string email)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
WHERE Email = @Email
""",
new { Email = email }
);
}
/// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
/// <inheritdoc />
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
WHERE Username = @Username
""",
new { Username = username }
);
}
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account using the
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
/// </summary>
/// <inheritdoc />
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
return await connection.QueryFirstOrDefaultAsync<UserCredential>(
"""
SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, RowVersion
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
""",
new { UserAccountId = userAccountId }
);
}
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
/// using the <c>USP_RotateUserCredential</c> stored procedure.
/// </summary>
/// <inheritdoc />
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
await using DbTransaction transaction = await connection.BeginTransactionAsync();
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
try
{
bool exists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId },
transaction
)
) is not null;
await command.ExecuteNonQueryAsync();
if (!exists)
throw new NotFoundException("User account not found.");
await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE dbo.UserCredential
SET IsRevoked = 1, RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId
""",
new { UserAccountId = userAccountId },
transaction
)
);
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
VALUES (@UserAccountId, @Hash)
""",
new { UserAccountId = userAccountId, Hash = newPasswordHash },
transaction
)
);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
/// <inheritdoc />
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId }
);
}
/// <summary>
/// Marks a user account as confirmed by creating a verification record via the
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
/// </summary>
/// <inheritdoc />
/// <remarks>
/// Confirmation is recorded by inserting a row into <c>dbo.UserVerification</c>. If a concurrent
/// request verifies the same user first, the resulting duplicate-key SQL exception (error 2601/2627)
/// is swallowed to keep the operation idempotent.
/// </remarks>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
/// Thrown when the database command fails for a reason other than
/// a duplicate verification record.
/// Thrown when the database command fails for a reason other than a duplicate verification record.
/// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
{
@@ -169,15 +199,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return user;
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountID_", userAccountId);
try
{
await command.ExecuteNonQueryAsync();
await connection.ExecuteAsync(
"""
INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
VALUES (@UserAccountId, GETDATE())
""",
new { UserAccountId = userAccountId }
);
}
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
{
@@ -188,19 +219,19 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId);
}
/// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
/// <inheritdoc />
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText =
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
command.CommandType = CommandType.Text;
AddParameter(command, "@UserAccountID", userAccountId);
object? result = await command.ExecuteScalarAsync();
return result != null && result != DBNull.Value;
int? result = await connection.ExecuteScalarAsync<int?>(
"""
SELECT TOP 1 1
FROM dbo.UserVerification
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId }
);
return result.HasValue;
}
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
@@ -209,67 +240,4 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
// 2601/2627 are duplicate key violations in SQL Server.
return ex.Number == 2601 || ex.Number == 2627;
}
protected override UserAccount MapToEntity(DbDataReader reader)
{
return new UserAccount
{
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"],
};
}
/// <summary>
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
/// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
UserCredential entity = new()
{
UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
};
// Optional columns
bool hasTimer =
reader
.GetSchemaTable()
?.Rows.Cast<DataRow>()
.Any(r =>
string.Equals(
r["ColumnName"]?.ToString(),
"Timer",
StringComparison.OrdinalIgnoreCase
)
)
?? false;
if (hasTimer)
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"];
return entity;
}
/// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
private static void AddParameter(DbCommand command, string name, object? value)
{
DbParameter p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -8,21 +8,32 @@ namespace Features.Auth.Repository;
/// </summary>
public interface IAuthRepository
{
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
/// <summary>
/// Registers a new user account and its initial credential as a single atomic operation, then
/// returns the created account.
/// </summary>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
Task<UserAccount> RegisterUserAsync(UserAccount ua);
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
Task<UserAccount?> GetUserByEmailAsync(string email);
/// <summary>Retrieves a user account by username, or <see langword="null" /> if none exists.</summary>
Task<UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>Retrieves the active (non-revoked) credential for a user account, or <see langword="null" /> if none exists.</summary>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
/// <summary>Retrieves a user account by ID, or <see langword="null" /> if none exists.</summary>
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>Determines whether a user account has been verified.</summary>
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
}

View File

@@ -29,6 +29,7 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
/// </summary>
public static class TokenServiceExpirationHours
{
/// <summary>1 hour.</summary>
public const double AccessTokenHours = 1;
/// <summary>21 days.</summary>
@@ -43,10 +44,22 @@ public static class TokenServiceExpirationHours
/// </summary>
public interface ITokenService
{
/// <summary>
/// Generates a signed access token for the given user, expiring after
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary>
string GenerateAccessToken(UserAccount user);
/// <summary>
/// Generates a signed refresh token for the given user, expiring after
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary>
string GenerateRefreshToken(UserAccount user);
/// <summary>
/// Generates a signed confirmation token for the given user, expiring after
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary>
string GenerateConfirmationToken(UserAccount user);
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
@@ -54,12 +67,28 @@ public interface ITokenService
string GenerateToken<T>(UserAccount user)
where T : struct, Enum;
/// <summary>Validates an access token's signature and expiration and extracts the caller's identity.</summary>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
/// <summary>Validates a refresh token's signature and expiration and extracts the caller's identity.</summary>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
/// <summary>Validates a confirmation token's signature and expiration and extracts the caller's identity.</summary>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid or expired, or when the user account it refers to no
/// longer exists.
/// </exception>
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
}

View File

@@ -48,6 +48,7 @@ public class TokenService : ITokenService
);
}
/// <inheritdoc />
public string GenerateAccessToken(UserAccount user)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
@@ -59,6 +60,7 @@ public class TokenService : ITokenService
);
}
/// <inheritdoc />
public string GenerateRefreshToken(UserAccount user)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -72,6 +74,7 @@ public class TokenService : ITokenService
);
}
/// <inheritdoc />
public string GenerateConfirmationToken(UserAccount user)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -85,12 +88,7 @@ public class TokenService : ITokenService
);
}
/// <summary>
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken" />.
/// </summary>
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
/// </exception>
@@ -114,33 +112,25 @@ public class TokenService : ITokenService
};
}
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
/// <inheritdoc />
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
{
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
}
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
/// <inheritdoc />
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
{
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
}
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
/// <inheritdoc />
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
{
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
}
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception>
/// <inheritdoc />
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
{
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.Breweries</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -21,6 +21,7 @@ public class UpdateBreweryHandlerTests
{
UpdateBreweryCommand command = new(
Guid.NewGuid(),
[0x01, 0x02],
Guid.NewGuid(),
"Renamed",
"New description",
@@ -31,7 +32,7 @@ public class UpdateBreweryHandlerTests
_repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
.ReturnsAsync(() => persisted!);
DateTime before = DateTime.UtcNow;
await _handler.Handle(command, CancellationToken.None);
@@ -42,6 +43,7 @@ public class UpdateBreweryHandlerTests
persisted.PostedById.Should().Be(command.PostedById);
persisted.BreweryName.Should().Be("Renamed");
persisted.Description.Should().Be("New description");
persisted.RowVersion.Should().Equal(command.RowVersion);
persisted.UpdatedAt.Should().NotBeNull();
persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
}
@@ -51,6 +53,7 @@ public class UpdateBreweryHandlerTests
{
UpdateBreweryCommand command = new(
Guid.NewGuid(),
[0x01, 0x02],
Guid.NewGuid(),
"Name",
"Description",
@@ -61,7 +64,7 @@ public class UpdateBreweryHandlerTests
_repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
.ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None);
@@ -81,6 +84,7 @@ public class UpdateBreweryHandlerTests
);
UpdateBreweryCommand command = new(
Guid.NewGuid(),
[0x01, 0x02],
Guid.NewGuid(),
"Name",
"Description",
@@ -91,7 +95,7 @@ public class UpdateBreweryHandlerTests
_repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
.ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None);

View File

@@ -12,8 +12,7 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,107 +0,0 @@
using Apps72.Dev.Data.DbMocker;
using Domain.Entities;
using Features.Breweries.Repository;
using FluentAssertions;
namespace Features.Breweries.Tests.Repository;
public class BreweryRepositoryTests
{
private static BreweryRepository CreateRepo(MockDbConnection conn)
{
return new BreweryRepository(new TestConnectionFactory(conn));
}
[Fact]
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
{
Guid breweryId = Guid.NewGuid();
MockDbConnection conn = new();
// Repository calls the stored procedure
const string getByIdSql = "USP_GetBreweryById";
Guid locationId = Guid.NewGuid();
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
.ReturnsTable(
MockTable
.WithColumns(
("BreweryPostId", typeof(Guid)),
("PostedById", typeof(Guid)),
("BreweryName", typeof(string)),
("Description", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("Timer", typeof(byte[])),
("BreweryPostLocationId", typeof(Guid)),
("CityId", typeof(Guid)),
("AddressLine1", typeof(string)),
("AddressLine2", typeof(string)),
("PostalCode", typeof(string)),
("Coordinates", typeof(byte[]))
)
.AddRow(
breweryId,
Guid.NewGuid(),
"Test Brewery",
"A test brewery description",
DateTime.UtcNow,
null,
null,
locationId,
Guid.NewGuid(),
"123 Main St",
null,
"12345",
null
)
);
BreweryRepository repo = CreateRepo(conn);
BreweryPost? result = await repo.GetByIdAsync(breweryId);
result.Should().NotBeNull();
result!.BreweryPostId.Should().Be(breweryId);
result.Location.Should().NotBeNull();
result.Location!.BreweryPostLocationId.Should().Be(locationId);
}
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
.ReturnsTable(MockTable.Empty());
BreweryRepository repo = CreateRepo(conn);
BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
result.Should().BeNull();
}
[Fact]
public async Task CreateAsync_ExecutesSuccessfully()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery").ReturnsScalar(1);
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
BreweryPostId = Guid.NewGuid(),
PostedById = Guid.NewGuid(),
BreweryName = "Test Brewery",
Description = "A test brewery description",
CreatedAt = DateTime.UtcNow,
Location = new BreweryPostLocation
{
BreweryPostLocationId = Guid.NewGuid(),
CityId = Guid.NewGuid(),
AddressLine1 = "123 Main St",
PostalCode = "12345",
Coordinates = [0x00, 0x01],
},
};
// Should not throw
Func<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().NotThrowAsync();
}
}

View File

@@ -1,14 +0,0 @@
using System.Data.Common;
using Infrastructure.Sql;
namespace Features.Breweries.Tests.Repository;
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection()
{
return _conn;
}
}

View File

@@ -3,6 +3,8 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Location details for a brewery being created.</summary>
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
public record CreateBreweryLocation(
Guid CityId,
string AddressLine1,

View File

@@ -5,10 +5,14 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post.</summary>
public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto>
{
/// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when <paramref name="request" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
/// </exception>
public async Task<BreweryDto> Handle(
CreateBreweryCommand request,
CancellationToken cancellationToken

View File

@@ -2,6 +2,7 @@ using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Validates <see cref="CreateBreweryCommand" />.</summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{
public CreateBreweryValidator()

View File

@@ -2,4 +2,5 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Deletes a brewery post. Bound directly from the route of <c>DELETE /api/brewery/{id}</c>.</summary>
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;

View File

@@ -3,9 +3,13 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Handles <see cref="DeleteBreweryCommand" /> by deleting the corresponding brewery post.</summary>
public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand>
{
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>.
/// </exception>
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
{
return repository.DeleteAsync(request.BreweryPostId);

View File

@@ -3,6 +3,11 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Location details for a brewery being updated.</summary>
/// <param name="BreweryPostLocationId">
/// Identifier of the existing location row, if any; a new row is created if none exists yet.
/// </param>
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
public record UpdateBreweryLocation(
Guid BreweryPostLocationId,
Guid CityId,
@@ -14,10 +19,13 @@ public record UpdateBreweryLocation(
/// <summary>
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
/// A <c>null</c> <see cref="Location" /> clears the brewery's location.
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="RowVersion" /> must be the
/// row-version last read for this brewery (e.g. from a prior <c>GET</c>); the update is rejected with a
/// <c>409 Conflict</c> if the brewery was modified since then.
/// </summary>
public record UpdateBreweryCommand(
Guid BreweryPostId,
byte[] RowVersion,
Guid PostedById,
string BreweryName,
string Description,

View File

@@ -5,6 +5,7 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.</summary>
public class UpdateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
{
@@ -12,6 +13,14 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
/// the brewery's location is cleared.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>, or its
/// location's <c>CityId</c> does not exist.
/// </exception>
/// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when the brewery was modified by another request since <paramref name="request" />.
/// <c>RowVersion</c> was read.
/// </exception>
public async Task<BreweryDto> Handle(
UpdateBreweryCommand request,
CancellationToken cancellationToken
@@ -23,6 +32,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
RowVersion = request.RowVersion,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null
? null
@@ -38,7 +48,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
},
};
await repository.UpdateAsync(entity);
return entity.ToDto();
BreweryPost updated = await repository.UpdateAsync(entity);
return updated.ToDto();
}
}

View File

@@ -0,0 +1,14 @@
using FluentValidation;
namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Validates <see cref="UpdateBreweryCommand" />.</summary>
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
{
public UpdateBreweryValidator()
{
RuleFor(x => x.RowVersion)
.NotEmpty()
.WithMessage("RowVersion is required to detect conflicting concurrent updates.");
}
}

View File

@@ -11,6 +11,7 @@ using Shared.Contracts;
namespace Features.Breweries.Controllers;
/// <summary>Exposes CRUD operations for brewery posts.</summary>
/// <remarks>
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
/// <c>[AllowAnonymous]</c>.
@@ -20,6 +21,7 @@ namespace Features.Breweries.Controllers;
[Authorize(AuthenticationSchemes = "JWT")]
public class BreweryController(IMediator mediator) : ControllerBase
{
/// <summary>Retrieves a single brewery post by ID.</summary>
/// <remarks>Allows anonymous access.</remarks>
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
[AllowAnonymous]
@@ -39,6 +41,9 @@ public class BreweryController(IMediator mediator) : ControllerBase
);
}
/// <summary>Retrieves brewery posts, newest first.</summary>
/// <param name="limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param>
/// <param name="offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param>
/// <remarks>Allows anonymous access.</remarks>
[AllowAnonymous]
[HttpGet]
@@ -59,6 +64,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
);
}
/// <summary>Creates a new brewery post.</summary>
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
[HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
@@ -76,8 +82,13 @@ public class BreweryController(IMediator mediator) : ControllerBase
);
}
/// <summary>Updates an existing brewery post.</summary>
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
/// <returns><c>200 OK</c> with the updated brewery; or <c>400 Bad Request</c> if the route ID does not match the payload ID.</returns>
/// <returns>
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or
/// <c>409 Conflict</c> if the brewery was modified since <c>command.RowVersion</c> was read.
/// </returns>
[HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id,
@@ -97,7 +108,8 @@ public class BreweryController(IMediator mediator) : ControllerBase
);
}
/// <returns><c>200 OK</c> confirming the deletion.</returns>
/// <summary>Deletes a brewery post.</summary>
/// <returns><c>200 OK</c> confirming the deletion; <c>404 Not Found</c> if no brewery exists with <paramref name="id" />.</returns>
[HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{

View File

@@ -3,11 +3,11 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Breweries.DependencyInjection;
/// <summary>
/// Registers the services owned by the Breweries feature slice.
/// </summary>
/// <summary>Registers the services required by the Breweries feature.</summary>
public static class FeaturesBreweriesServiceCollectionExtensions
{
/// <summary>Registers <see cref="IBreweryRepository" /> and its implementation.</summary>
/// <returns><paramref name="services" />, for chaining.</returns>
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
{
services.AddScoped<IBreweryRepository, BreweryRepository>();

View File

@@ -1,39 +1,32 @@
namespace Features.Breweries.Dtos;
public class BreweryLocationDto
{
public Guid BreweryPostLocationId { get; set; }
/// <summary>Location details of a brewery, as returned by the API.</summary>
/// <param name="Coordinates">The brewery's geographic coordinates, in a raw binary representation.</param>
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 Guid BreweryPostId { get; set; }
public Guid CityId { get; set; }
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
/// <summary>Gets or sets the brewery's geographic coordinates, in a raw binary representation.</summary>
public byte[]? Coordinates { get; set; }
}
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; }
public DateTime? UpdatedAt { get; set; }
/// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
public byte[]? Timer { get; set; }
public BreweryLocationDto? Location { get; set; }
}
/// <summary>A brewery post, as returned by the API.</summary>
/// <param name="UpdatedAt">The date and time of the last edit, or <see langword="null"/> if never edited.</param>
/// <param name="RowVersion">The row-version/concurrency token used to detect conflicting concurrent updates.</param>
/// <param name="Location">The brewery's location, or <see langword="null"/> if none has been set.</param>
public record BreweryDto(
Guid BreweryPostId,
Guid PostedById,
string BreweryName,
string Description,
DateTime CreatedAt,
DateTime? UpdatedAt,
byte[]? RowVersion,
BreweryLocationDto? Location
);

View File

@@ -2,31 +2,42 @@ using Domain.Entities;
namespace Features.Breweries.Dtos;
/// <summary>Maps <see cref="BreweryPost" /> domain entities to <see cref="BreweryDto" />.</summary>
public static class BreweryDtoMapper
{
/// <summary>
/// Converts a <see cref="BreweryPost" /> to its DTO representation, including its
/// <see cref="BreweryDto.Location" /> if the entity has one.
/// </summary>
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,
BreweryPostId = brewery.Location.BreweryPostId,
CityId = brewery.Location.CityId,
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
)
);
}
}

View File

@@ -12,6 +12,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />

View File

@@ -5,9 +5,11 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>Handles <see cref="GetAllBreweriesQuery" /> by retrieving brewery posts, newest first.</summary>
public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{
/// <summary>Retrieves brewery posts, newest first, applying <see cref="GetAllBreweriesQuery" />'s paging.</summary>
public async Task<IEnumerable<BreweryDto>> Handle(
GetAllBreweriesQuery request,
CancellationToken cancellationToken

View File

@@ -3,7 +3,7 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>
/// Retrieves a paginated list of brewery posts.
/// </summary>
/// <summary>Retrieves brewery posts, newest first. Bound directly from the query string of <c>GET /api/brewery</c>.</summary>
/// <param name="Limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param>
/// <param name="Offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param>
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;

View File

@@ -5,9 +5,11 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>Handles <see cref="GetBreweryByIdQuery" /> by retrieving the matching brewery post.</summary>
public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{
/// <summary>Retrieves the brewery post, or <see langword="null"/> if none exists with the given ID.</summary>
public async Task<BreweryDto?> Handle(
GetBreweryByIdQuery request,
CancellationToken cancellationToken

View File

@@ -3,4 +3,6 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>Retrieves a brewery post by ID. Bound directly from the route of <c>GET /api/brewery/{id}</c>.</summary>
/// <remarks>Yields <see langword="null"/> if no brewery exists with the given <c>BreweryPostId</c>.</remarks>
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;

View File

@@ -1,202 +1,458 @@
using System.Data;
using System.Data.Common;
using Dapper;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Sql;
namespace Features.Breweries.Repository;
/// <summary>
/// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
/// procedures.
/// Dapper-based implementation of <see cref="IBreweryRepository" />.
/// </summary>
/// <remarks>
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic
/// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT
/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to <c>byte[]</c>.
/// The read queries below therefore select <c>CONVERT(varbinary(max), bpl.Coordinates)</c>, which
/// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
/// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory),
IBreweryRepository
{
/// <summary>Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.</summary>
/// <inheritdoc/>
public async Task<BreweryPost?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandType = CommandType.StoredProcedure;
IEnumerable<BreweryPost> results = await connection.QueryAsync<
BreweryPost,
BreweryPostLocation,
City,
StateProvince,
Country,
BreweryPost
>(
"""
SELECT
bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.RowVersion,
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,
co.CountryID,
co.CountryName,
co.ISO3166_1
FROM 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
WHERE bp.BreweryPostID = @BreweryPostId
""",
MapBreweryRow,
new { BreweryPostId = id },
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
command.CommandText = "USP_GetBreweryById";
AddParameter(command, "@BreweryPostID", id);
await using DbDataReader reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
return MapToEntity(reader);
return null;
return results.SingleOrDefault();
}
/// <summary>
/// Retrieves all brewery posts, optionally paginated, using the <c>USP_GetAllBreweries</c>
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
/// corresponding argument has a value.
/// </summary>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
/// <inheritdoc/>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit,
int? offset)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetAllBreweries";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using DbDataReader reader = await command.ExecuteReaderAsync();
List<BreweryPost> breweries = new();
while (await reader.ReadAsync())
breweries.Add(MapToEntity(reader));
return breweries;
return await connection.QueryAsync<
BreweryPost,
BreweryPostLocation,
City,
StateProvince,
Country,
BreweryPost
>(
"""
SELECT
bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.RowVersion,
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,
co.CountryID,
co.CountryName,
co.ISO3166_1
FROM 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
ORDER BY bp.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""",
MapBreweryRow,
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
}
/// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, using the
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is
/// <c>null</c>, any existing location for the brewery is removed.
/// </summary>
public async Task UpdateAsync(BreweryPost brewery)
/// <inheritdoc/>
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_UpdateBrewery";
command.CommandType = CommandType.StoredProcedure;
await using DbTransaction transaction =
await connection.BeginTransactionAsync();
AddParameter(command, "@BreweryPostID", brewery.BreweryPostId);
AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description);
AddParameter(command, "@BreweryPostLocationID", brewery.Location?.BreweryPostLocationId);
AddParameter(command, "@CityID", brewery.Location?.CityId);
AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1);
AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2);
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
try
{
bool breweryExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1 FROM
dbo.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
)
is not null;
await command.ExecuteNonQueryAsync();
if (!breweryExists)
throw new NotFoundException("Brewery not found.");
if (brewery.Location is not null)
{
bool cityExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
new { brewery.Location.CityId },
transaction
)
)
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
}
int updatedRows = await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE
BreweryPostID = @BreweryPostId
AND RowVersion = @RowVersion
""",
new
{
brewery.BreweryPostId,
brewery.BreweryName,
brewery.Description,
brewery.RowVersion,
},
transaction
)
);
if (updatedRows == 0)
throw new ConflictException(
"Brewery was modified by another request. Reload and try again."
);
if (brewery.Location is null)
{
// No location supplied: clear any existing location for this brewery.
await connection.ExecuteAsync(
new CommandDefinition(
"""
DELETE FROM
dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
);
}
else
{
bool locationExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
)
is not null;
if (locationExists)
await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE dbo.BreweryPostLocation
SET CityID = @CityId,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostId
""",
new
{
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
brewery.Location.Coordinates,
},
transaction
)
);
else
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID,
BreweryPostID,
CityID,
AddressLine1,
AddressLine2,
PostalCode,
Coordinates)
VALUES
(@BreweryPostLocationId,
@BreweryPostId,
@CityId,
@AddressLine1,
@AddressLine2,
@PostalCode,
@Coordinates)
""",
new
{
brewery.Location.BreweryPostLocationId,
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
brewery.Location.Coordinates,
},
transaction
)
);
}
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
return await GetByIdAsync(brewery.BreweryPostId)
?? throw new InvalidOperationException(
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
);
}
/// <summary>
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
/// and photos are removed via cascading foreign keys.
/// </summary>
/// <inheritdoc/>
public async Task DeleteAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_DeleteBrewery";
command.CommandType = CommandType.StoredProcedure;
int rows = await connection.ExecuteAsync(
"""
DELETE FROM
dbo.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { BreweryPostId = id }
);
AddParameter(command, "@BreweryPostID", id);
await command.ExecuteNonQueryAsync();
if (rows == 0)
throw new NotFoundException("Brewery not found.");
}
/// <summary>Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.</summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception>
/// <inheritdoc/>
public async Task CreateAsync(BreweryPost brewery)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateBrewery";
command.CommandType = CommandType.StoredProcedure;
if (brewery.Location is null)
throw new ArgumentException("Location must be provided when creating a brewery.");
throw new ArgumentException(
"Location must be provided when creating a brewery.");
AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description);
AddParameter(command, "@PostedByID", brewery.PostedById);
AddParameter(command, "@CityID", brewery.Location?.CityId);
AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1);
AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2);
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
await command.ExecuteNonQueryAsync();
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction =
await connection.BeginTransactionAsync();
try
{
bool userExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM
dbo.UserAccount
WHERE
UserAccountID = @PostedById
""",
new { brewery.PostedById },
transaction
)
)
is not null;
if (!userExists)
throw new NotFoundException("User not found.");
bool cityExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM
dbo.City
WHERE
CityID = @CityId
""",
new { brewery.Location.CityId },
transaction
)
)
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)
""",
new
{
brewery.BreweryPostId,
brewery.BreweryName,
brewery.Description,
brewery.PostedById,
},
transaction
)
);
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID,
BreweryPostID,
CityID,
AddressLine1,
AddressLine2,
PostalCode,
Coordinates)
VALUES (@BreweryPostLocationId,
@BreweryPostId,
@CityId,
@AddressLine1,
@AddressLine2,
@PostalCode,
@Coordinates)
""",
new
{
brewery.Location.BreweryPostLocationId,
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
brewery.Location.Coordinates,
},
transaction
)
);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>
/// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
/// associated <see cref="BreweryPostLocation" /> if location columns are present in the result set.
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
/// brewery with no address on file), <paramref name="location" /> is a blank instance produced
/// from all-<c>NULL</c> columns; the schema guarantees that whenever a location row was joined
/// in, its City/StateProvince/Country chain was too.
/// </summary>
protected override BreweryPost MapToEntity(DbDataReader reader)
private static BreweryPost MapBreweryRow(
BreweryPost post,
BreweryPostLocation location,
City city,
StateProvince stateProvince,
Country country
)
{
BreweryPost brewery = new();
if (location.BreweryPostLocationId == Guid.Empty)
return post;
int ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
int ordPostedById = reader.GetOrdinal("PostedById");
int ordBreweryName = reader.GetOrdinal("BreweryName");
int ordDescription = reader.GetOrdinal("Description");
int ordCreatedAt = reader.GetOrdinal("CreatedAt");
int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
int ordTimer = reader.GetOrdinal("Timer");
stateProvince.CountryId = country.CountryId;
stateProvince.Country = country;
brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId);
brewery.PostedById = reader.GetGuid(ordPostedById);
brewery.BreweryName = reader.GetString(ordBreweryName);
brewery.Description = reader.GetString(ordDescription);
brewery.CreatedAt = reader.GetDateTime(ordCreatedAt);
city.StateProvinceId = stateProvince.StateProvinceId;
city.StateProvince = stateProvince;
brewery.UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt);
location.BreweryPostId = post.BreweryPostId;
location.CityId = city.CityId;
location.City = city;
// Read timer (varbinary/rowversion) robustly
if (reader.IsDBNull(ordTimer))
brewery.Timer = null;
else
try
{
brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer);
}
catch
{
long length = reader.GetBytes(ordTimer, 0, null, 0, 0);
byte[] buffer = new byte[length];
reader.GetBytes(ordTimer, 0, buffer, 0, (int)length);
brewery.Timer = buffer;
}
// Map BreweryPostLocation if columns are present
try
{
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
if (!reader.IsDBNull(ordLocationId))
{
BreweryPostLocation location = new()
{
BreweryPostLocationId = reader.GetGuid(ordLocationId),
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2"))
? null
: reader.GetString(reader.GetOrdinal("AddressLine2")),
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates"))
? null
: reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates")),
};
brewery.Location = location;
}
}
catch (IndexOutOfRangeException)
{
// Location columns not present, skip mapping location
}
return brewery;
}
/// <summary>Adds a parameter to <paramref name="command" />, converting <c>null</c> to <see cref="DBNull.Value" />.</summary>
private static void AddParameter(DbCommand command, string name, object? value)
{
DbParameter p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
post.Location = location;
return post;
}
}

View File

@@ -7,16 +7,45 @@ namespace Features.Breweries.Repository;
/// </summary>
public interface IBreweryRepository
{
/// <summary>Retrieves a brewery post by ID, or <c>null</c> if not found.</summary>
/// <summary>
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> if no brewery post
/// exists with the given ID, or if it has no associated location.
/// </summary>
Task<BreweryPost?> GetByIdAsync(Guid id);
/// <summary>
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts
/// without a location are included, with <see cref="BreweryPost.Location" /> left <c>null</c>.
/// </summary>
/// <param name="limit">Maximum number of rows to return. Unbounded 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 UpdateAsync(BreweryPost brewery);
/// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
/// optimistic concurrency via <paramref name="brewery" />'s <c>RowVersion</c>: the update is rejected
/// if the row was modified since <c>RowVersion</c> was read. When <paramref name="brewery" />'s
/// <c>Location</c> is <c>null</c>, any existing location for the brewery is removed.
/// Returns the freshly persisted brewery, including its new <c>RowVersion</c>.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with the given ID, or the given location's <c>CityId</c> does not
/// exist.
/// </exception>
/// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> no longer matches the stored row (it was
/// modified by another request since it was last read).
/// </exception>
Task<BreweryPost> UpdateAsync(BreweryPost brewery);
/// <summary>Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.</summary>
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception>
Task DeleteAsync(Guid id);
/// <summary>Creates a new brewery post, including its location details.</summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when <paramref name="brewery" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
/// </exception>
Task CreateAsync(BreweryPost brewery);
}

View File

@@ -12,7 +12,7 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -5,8 +5,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Emails.DependencyInjection;
/// <summary>
/// Registers the services used by the Features.Emails slice.
/// </summary>
public static class FeaturesEmailsServiceCollectionExtensions
{
/// <summary>
/// Registers the SMTP email provider, template renderer, and <see cref="IEmailDispatcher" />.
/// </summary>
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
public static IServiceCollection AddFeaturesEmails(this IServiceCollection services)
{
services.AddScoped<IEmailProvider, SmtpEmailProvider>();

View File

@@ -22,6 +22,7 @@ public class EmailDispatcher(
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
/// <inheritdoc />
public async Task SendRegistrationEmailAsync(
string firstName,
string email,
@@ -38,6 +39,7 @@ public class EmailDispatcher(
await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
}
/// <inheritdoc />
public async Task SendResendConfirmationEmailAsync(
string firstName,
string email,

View File

@@ -8,10 +8,14 @@ public interface IEmailDispatcher
/// <summary>
/// Sends a welcome email containing an account confirmation link to a newly registered user.
/// </summary>
/// <remarks>The email is sent synchronously; the returned task completes once delivery to the mail server succeeds or fails.</remarks>
/// <param name="confirmationToken">Embedded in the confirmation link sent to <paramref name="email" />.</param>
Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken);
/// <summary>
/// Sends an email containing a fresh account confirmation link to a user who requested a resend.
/// </summary>
/// <remarks>The email is sent synchronously; the returned task completes once delivery to the mail server succeeds or fails.</remarks>
/// <param name="confirmationToken">Embedded in the confirmation link sent to <paramref name="email" />.</param>
Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken);
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.Locations.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.Locations\Features.Locations.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using Features.Locations.Repository;
using Microsoft.Extensions.DependencyInjection;
namespace Features.Locations.DependencyInjection;
/// <summary>
/// Registers the services used by the Features.Locations slice.
/// </summary>
public static class FeaturesLocationsServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="ILocationRepository" /> and its Dapper-based implementation.
/// </summary>
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
public static IServiceCollection AddFeaturesLocations(this IServiceCollection services)
{
services.AddScoped<ILocationRepository, LocationRepository>();
return services;
}
}

Some files were not shown because too many files have changed in this diff Show More