Refactor repository structure

This commit is contained in:
Aaron Po
2026-02-12 10:21:34 -05:00
parent a038a12fca
commit 4f92741b4f
36 changed files with 651 additions and 679 deletions

View File

@@ -19,8 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Core\Service.Core.csproj" />
</ItemGroup>

View File

@@ -10,7 +10,7 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Repository.Core/Repository.Core.csproj", "Infrastructure/Infrastructure.Repository/Repository.Core/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"]

View File

@@ -2,10 +2,10 @@ using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Jwt;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc;
using Repository.Core.Repositories.Auth;
using Repository.Core.Repositories.UserAccount;
using Repository.Core.Sql;
using Service.Core.Auth;
using Service.Core.User;

View File

@@ -4,7 +4,7 @@ WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Repository.Core/Repository.Core.csproj", "Infrastructure/Infrastructure.Repository/Repository.Core/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"]

View File

@@ -12,11 +12,9 @@
</Folder>
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project Path="Infrastructure/Infrastructure.Repository/Repository.Core/Repository.Core.csproj" />
<Project
Path="Infrastructure/Infrastructure.Repository/Repository.Tests/Repository.Tests.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project Path="Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj" />
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
</Folder>
<Folder Name="/Service/">
<Project Path="Service/Service.Core/Service.Core.csproj" />

View File

@@ -19,7 +19,6 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,7 +2,6 @@ using System.Data;
using System.Security.Cryptography;
using System.Text;
using Domain.Core.Entities;
using Repository.Core.Repositories;
using idunno.Password;
using Konscious.Security.Cryptography;
using Microsoft.Data.SqlClient;

View File

@@ -1,7 +1,7 @@
using System.Data;
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Repository.Core.Repositories.Auth;
using Infrastructure.Repository.Auth;
using Repository.Tests.Database;
namespace Repository.Tests.Auth;

View File

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

View File

@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj"
COPY . .
WORKDIR "/src/Infrastructure/Infrastructure.Repository.Tests"
RUN dotnet build "./Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
RUN mkdir -p /app/test-results
WORKDIR /src/Infrastructure/Infrastructure.Repository.Tests
ENTRYPOINT ["dotnet", "test", "./Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"]

View File

@@ -4,7 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Repository.Tests</RootNamespace>
<RootNamespace>Infrastructure.Repository.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
@@ -35,6 +35,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Repository.Core.Repositories.UserAccount;
using Infrastructure.Repository.UserAccount;
using Repository.Tests.Database;
namespace Repository.Tests.UserAccount;

View File

@@ -0,0 +1,198 @@
using System.Data;
using System.Data.Common;
using Domain.Core.Entities;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository.Auth;
public class AuthRepository
: Repository<Domain.Core.Entities.UserAccount>,
IAuthRepository
{
public AuthRepository(ISqlConnectionFactory connectionFactory)
: base(connectionFactory) { }
public async Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
return new Domain.Core.Entities.UserAccount
{
UserAccountId = userAccountId,
Username = username,
FirstName = firstName,
LastName = lastName,
Email = email,
DateOfBirth = dateOfBirth,
CreatedAt = DateTime.UtcNow,
};
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync()
? MapToCredentialEntity(reader)
: null;
}
public async Task RotateCredentialAsync(
Guid userAccountId,
string newPasswordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
protected override Domain.Core.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Core.Entities.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.
/// </summary>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new UserCredential
{
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
var hasTimer =
reader
.GetSchemaTable()
?.Rows.Cast<System.Data.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>
/// Helper method to add a parameter to a database command.
/// </summary>
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -0,0 +1,67 @@
using Domain.Core.Entities;
namespace Infrastructure.Repository.Auth;
/// <summary>
/// Repository for authentication-related database operations including user registration and credential management.
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(
string email
);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(
string username
);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
}

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Repository.Core</RootNamespace>
<RootNamespace>Infrastructure.Repository</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
@@ -18,6 +18,6 @@
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Domain\Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,199 +0,0 @@
using System.Data;
using System.Data.Common;
using Domain.Core.Entities;
using Repository.Core.Sql;
namespace Repository.Core.Repositories.Auth
{
public class AuthRepository
: Repository<Domain.Core.Entities.UserAccount>,
IAuthRepository
{
public AuthRepository(ISqlConnectionFactory connectionFactory)
: base(connectionFactory) { }
public async Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
return new Domain.Core.Entities.UserAccount
{
UserAccountId = userAccountId,
Username = username,
FirstName = firstName,
LastName = lastName,
Email = email,
DateOfBirth = dateOfBirth,
CreatedAt = DateTime.UtcNow,
};
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync()
? MapToCredentialEntity(reader)
: null;
}
public async Task RotateCredentialAsync(
Guid userAccountId,
string newPasswordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
protected override Domain.Core.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Core.Entities.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.
/// </summary>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new UserCredential
{
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
var hasTimer =
reader
.GetSchemaTable()
?.Rows.Cast<System.Data.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>
/// Helper method to add a parameter to a database command.
/// </summary>
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}

View File

@@ -1,68 +0,0 @@
using Domain.Core.Entities;
namespace Repository.Core.Repositories.Auth
{
/// <summary>
/// Repository for authentication-related database operations including user registration and credential management.
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(
string email
);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(
string username
);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
}
}

View File

@@ -1,18 +0,0 @@
using System.Data.Common;
using Repository.Core.Sql;
namespace Repository.Core.Repositories
{
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
return connection;
}
protected abstract T MapToEntity(DbDataReader reader);
}
}

View File

@@ -1,19 +0,0 @@
using Domain.Core.Entities;
namespace Repository.Core.Repositories.UserAccount
{
public interface IUserAccountRepository
{
Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Domain.Core.Entities.UserAccount>> GetAllAsync(
int? limit,
int? offset
);
Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount);
Task DeleteAsync(Guid id);
Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(
string username
);
Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(string email);
}
}

View File

@@ -1,151 +0,0 @@
using System.Data;
using System.Data.Common;
using Domain.Core.Entities;
using Repository.Core.Sql;
namespace Repository.Core.Repositories.UserAccount
{
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Core.Entities.UserAccount>(connectionFactory),
IUserAccountRepository
{
public async Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(
Guid id
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<
IEnumerable<Domain.Core.Entities.UserAccount>
> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Domain.Core.Entities.UserAccount>();
while (await reader.ReadAsync())
{
users.Add(MapToEntity(reader));
}
return users;
}
public async Task UpdateAsync(
Domain.Core.Entities.UserAccount userAccount
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await command.ExecuteNonQueryAsync();
}
public async Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
protected override Domain.Core.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Core.Entities.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"],
};
}
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}

View File

@@ -1,50 +0,0 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Repository.Core.Sql
{
public class DefaultSqlConnectionFactory(IConfiguration configuration)
: ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(
configuration
);
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
var fullConnectionString = Environment.GetEnvironmentVariable(
"DB_CONNECTION_STRING"
);
if (!string.IsNullOrEmpty(fullConnectionString))
{
return fullConnectionString;
}
// Try to build from individual environment variables (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
var connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
{
return connString;
}
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}
}

View File

@@ -1,9 +0,0 @@
using System.Data.Common;
namespace Repository.Core.Sql
{
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
}
}

View File

@@ -1,62 +0,0 @@
using Microsoft.Data.SqlClient;
namespace Repository.Core.Sql
{
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
public static string BuildConnectionString(string? databaseName = null)
{
var server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException(
"DB_SERVER environment variable is not set"
);
var dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException(
"DB_NAME environment variable is not set"
);
var user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException(
"DB_USER environment variable is not set"
);
var password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException(
"DB_PASSWORD environment variable is not set"
);
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = true,
};
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");
}
}
}

View File

@@ -1,15 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Repository.Core/Repository.Core.csproj", "Infrastructure/Infrastructure.Repository/Repository.Core/"]
COPY ["Infrastructure/Infrastructure.Repository/Repository.Tests/Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository/Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository/Repository.Tests/Repository.Tests.csproj"
COPY . .
WORKDIR "/src/Infrastructure/Infrastructure.Repository/Repository.Tests"
RUN dotnet build "./Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
RUN mkdir -p /app/test-results
WORKDIR /src/Infrastructure/Infrastructure.Repository/Repository.Tests
ENTRYPOINT ["dotnet", "test", "./Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"]

View File

@@ -0,0 +1,17 @@
using System.Data.Common;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository;
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
return connection;
}
protected abstract T MapToEntity(DbDataReader reader);
}

View File

@@ -0,0 +1,49 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Repository.Sql;
public class DefaultSqlConnectionFactory(IConfiguration configuration)
: ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(
configuration
);
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
var fullConnectionString = Environment.GetEnvironmentVariable(
"DB_CONNECTION_STRING"
);
if (!string.IsNullOrEmpty(fullConnectionString))
{
return fullConnectionString;
}
// Try to build from individual environment variables (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
var connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
{
return connString;
}
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}

View File

@@ -0,0 +1,8 @@
using System.Data.Common;
namespace Infrastructure.Repository.Sql;
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Sql;
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
public static string BuildConnectionString(string? databaseName = null)
{
var server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException(
"DB_SERVER environment variable is not set"
);
var dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException(
"DB_NAME environment variable is not set"
);
var user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException(
"DB_USER environment variable is not set"
);
var password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException(
"DB_PASSWORD environment variable is not set"
);
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = true,
};
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");
}
}

View File

@@ -0,0 +1,16 @@
namespace Infrastructure.Repository.UserAccount;
public interface IUserAccountRepository
{
Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Domain.Core.Entities.UserAccount>> GetAllAsync(
int? limit,
int? offset
);
Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount);
Task DeleteAsync(Guid id);
Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(
string username
);
Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(string email);
}

View File

@@ -0,0 +1,149 @@
using System.Data;
using System.Data.Common;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository.UserAccount;
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Core.Entities.UserAccount>(connectionFactory),
IUserAccountRepository
{
public async Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(
Guid id
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<
IEnumerable<Domain.Core.Entities.UserAccount>
> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Domain.Core.Entities.UserAccount>();
while (await reader.ReadAsync())
{
users.Add(MapToEntity(reader));
}
return users;
}
public async Task UpdateAsync(
Domain.Core.Entities.UserAccount userAccount
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await command.ExecuteNonQueryAsync();
}
public async Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
protected override Domain.Core.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Core.Entities.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"],
};
}
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -1,6 +1,6 @@
using Domain.Core.Entities;
using Infrastructure.PasswordHashing;
using Repository.Core.Repositories.Auth;
using Infrastructure.Repository.Auth;
namespace Service.Core.Auth;

View File

@@ -12,8 +12,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Repository.Core\Repository.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
</ItemGroup>

View File

@@ -1,5 +1,5 @@
using Domain.Core.Entities;
using Repository.Core.Repositories.UserAccount;
using Infrastructure.Repository.UserAccount;
namespace Service.Core.User;