diff --git a/.gitignore b/.gitignore
index ecede2b..2dc761b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -483,9 +483,6 @@ FodyWeavers.xsd
*.feature.cs
-
-database
-
.env
.env.dev
.env.test
diff --git a/README.md b/README.md
index f1d8087..5304939 100644
--- a/README.md
+++ b/README.md
@@ -2,46 +2,6 @@
A social platform for craft beer enthusiasts to discover breweries, share reviews, and connect with fellow beer lovers.
-
-## Table of Contents
-
-- [Project Status](#project-status)
-- [Repository Structure](#repository-structure)
-- [Technology Stack](#technology-stack)
-- [Getting Started](#getting-started)
- - [Prerequisites](#prerequisites)
- - [Quick Start (Development Environment)](#quick-start-development-environment)
- - [Manual Setup (Without Docker)](#manual-setup-without-docker)
-- [Environment Variables](#environment-variables)
- - [Overview](#overview)
- - [Backend Variables (.NET API)](#backend-variables-net-api)
- - [Frontend Variables (Next.js)](#frontend-variables-nextjs)
- - [Docker Variables](#docker-variables)
- - [External Services](#external-services)
- - [Generating Secrets](#generating-secrets)
- - [Environment File Structure](#environment-file-structure)
- - [Variable Reference Table](#variable-reference-table)
-- [Testing](#testing)
-- [Database Schema](#database-schema)
-- [Authentication & Security](#authentication--security)
-- [Architecture Patterns](#architecture-patterns)
-- [Docker & Containerization](#docker--containerization)
- - [Container Architecture](#container-architecture)
- - [Docker Compose Environments](#docker-compose-environments)
- - [Service Dependencies](#service-dependencies)
- - [Health Checks](#health-checks)
- - [Volumes](#volumes)
- - [Networks](#networks)
- - [Environment Variables](#environment-variables)
- - [Container Lifecycle](#container-lifecycle)
-- [Docker Tips & Troubleshooting](#docker-tips--troubleshooting)
-- [Roadmap](#roadmap)
-- [License](#license)
-- [Contact & Support](#contact--support)
-
----
-
-
## Project Status
This project is in active development, transitioning from a full-stack Next.js application to a **multi-project monorepo** with:
@@ -49,10 +9,10 @@ This project is in active development, transitioning from a full-stack Next.js a
- **Frontend**: Next.js with TypeScript
- **Architecture**: SQL-first approach using stored procedures
-**Current State** (February 2025):
+**Current State** (February 2026):
- Core authentication and user management APIs functional
- Database schema and migrations established
-- Repository and service layers implemented
+- Domain, Infrastructure, Repository, and Service layers implemented
- Frontend integration with .NET API in progress
- Migrating remaining features from Next.js serverless functions
@@ -65,18 +25,26 @@ This project is in active development, transitioning from a full-stack Next.js a
```
src/Core/
├── API/
-│ ├── API.Core/ # ASP.NET Core Web API with Swagger/OpenAPI
-│ └── API.Specs/ # Integration tests using Reqnroll (BDD)
+│ ├── API.Core/ # ASP.NET Core Web API with Swagger/OpenAPI
+│ └── API.Specs/ # Integration tests using Reqnroll (BDD)
├── Database/
-│ ├── Database.Migrations/ # DbUp migrations (embedded SQL scripts)
-│ └── Database.Seed/ # Database seeding for development/testing
-├── Repository/
-│ ├── Repository.Core/ # Data access layer (stored procedure-based)
-│ └── Repository.Tests/ # Unit tests for repositories
+│ ├── Database.Migrations/ # DbUp migrations (embedded SQL scripts)
+│ └── Database.Seed/ # Database seeding for development/testing
+├── Domain/
+│ └── Domain.csproj # Domain entities and models
+│ └── Entities/ # Core domain entities (UserAccount, UserCredential, etc.)
+├── Infrastructure/
+│ ├── Infrastructure.Jwt/ # JWT token generation and validation
+│ ├── Infrastructure.PasswordHashing/ # Argon2id password hashing
+│ └── Infrastructure.Repository/
+│ ├── Infrastructure.Repository/ # Data access layer (stored procedure-based)
+│ └── Infrastructure.Repository.Tests/ # Unit tests for repositories
└── Service/
- └── Service.Core/ # Business logic layer
+ └── Service.Core/ # Business logic layer
-Website/ # Next.js frontend application
+Website/ # Next.js frontend application
+misc/
+└── raw-data/ # Sample data files (breweries, beers)
```
### Key Components
@@ -86,6 +54,7 @@ Website/ # Next.js frontend application
- Controllers: `AuthController`, `UserController`
- Configured with Swagger UI for API exploration
- Health checks and structured logging
+- Middleware for error handling and request processing
**Database Layer**
- SQL Server with stored procedures for all data operations
@@ -93,7 +62,19 @@ Website/ # Next.js frontend application
- Comprehensive schema including users, breweries, beers, locations, and social features
- Seeders for development data (users, locations across US/Canada/Mexico)
-**Repository Layer** (`Repository.Core`)
+**Domain Layer** (`Domain`)
+- Core business entities and models
+- Entities: `UserAccount`, `UserCredential`, `UserVerification`
+- Shared domain logic and value objects
+- No external dependencies - pure domain model
+
+**Infrastructure Layer**
+- **Infrastructure.Jwt**: JWT token generation, validation, and configuration
+- **Infrastructure.PasswordHashing**: Argon2id password hashing with configurable parameters
+- **Infrastructure.Password**: Password utilities and validation
+- **Infrastructure.Repository**: Repository pattern infrastructure and base classes
+
+**Repository Layer** (`Infrastructure.Repository`)
- Abstraction over SQL Server using ADO.NET
- `ISqlConnectionFactory` for connection management
- Repositories: `AuthRepository`, `UserAccountRepository`
@@ -101,9 +82,9 @@ Website/ # Next.js frontend application
**Service Layer** (`Service.Core`)
- Business logic and orchestration
-- Services: `AuthService`, `UserService`, `JwtService`
-- Password hashing with Argon2id
-- JWT token generation
+- Services: `AuthService`, `UserService`
+- Integration with infrastructure components
+- Transaction management and business rule enforcement
**Frontend** (`Website`)
- Next.js 14+ with TypeScript
@@ -334,7 +315,7 @@ Provide a complete SQL Server connection string:
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
```
-The connection factory checks for `DB_CONNECTION_STRING` first, then falls back to building from components. See [DefaultSqlConnectionFactory.cs](src/Core/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs).
+The connection factory checks for `DB_CONNECTION_STRING` first, then falls back to building from components. See [DefaultSqlConnectionFactory.cs](src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs).
#### JWT Authentication
@@ -578,7 +559,7 @@ docker compose -f docker-compose.test.yaml up --abort-on-container-exit
This runs:
- **API.Specs** - BDD integration tests
-- **Repository.Tests** - Unit tests for data access
+- **Infrastructure.Repository.Tests** - Unit tests for data access
Test results are output to `./test-results/`.
@@ -590,10 +571,10 @@ cd src/Core
dotnet test API/API.Specs/API.Specs.csproj
```
-**Unit Tests (Repository.Tests)**
+**Unit Tests (Infrastructure.Repository.Tests)**
```bash
cd src/Core
-dotnet test Repository/Repository.Tests/Repository.Tests.csproj
+dotnet test Infrastructure/Infrastructure.Repository/Infrastructure.Repository.Tests/Repository.Tests.csproj
```
### Test Features
diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml
index 4da36ef..06aff2f 100644
--- a/docker-compose.test.yaml
+++ b/docker-compose.test.yaml
@@ -101,7 +101,7 @@ services:
condition: service_completed_successfully
build:
context: ./src/Core
- dockerfile: Infrastructure/Infrastructure.Repository/Repository.Tests/Dockerfile
+ dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:
diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj
index 79d1cc4..4ddfa09 100644
--- a/src/Core/API/API.Core/API.Core.csproj
+++ b/src/Core/API/API.Core/API.Core.csproj
@@ -19,8 +19,7 @@
-
+
diff --git a/src/Core/API/API.Core/Dockerfile b/src/Core/API/API.Core/Dockerfile
index 0d7c7e1..cd61359 100644
--- a/src/Core/API/API.Core/Dockerfile
+++ b/src/Core/API/API.Core/Dockerfile
@@ -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/"]
diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs
index 33e20d6..640dbb6 100644
--- a/src/Core/API/API.Core/Program.cs
+++ b/src/Core/API/API.Core/Program.cs
@@ -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;
diff --git a/src/Core/API/API.Specs/Dockerfile b/src/Core/API/API.Specs/Dockerfile
index 2324cf4..b934157 100644
--- a/src/Core/API/API.Specs/Dockerfile
+++ b/src/Core/API/API.Specs/Dockerfile
@@ -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/"]
diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx
index 00eb33e..293b22b 100644
--- a/src/Core/Core.slnx
+++ b/src/Core/Core.slnx
@@ -12,11 +12,9 @@
-
-
-
+
+
+
diff --git a/src/Core/Database/Database.Seed/Database.Seed.csproj b/src/Core/Database/Database.Seed/Database.Seed.csproj
index 0deeae4..9a44208 100644
--- a/src/Core/Database/Database.Seed/Database.Seed.csproj
+++ b/src/Core/Database/Database.Seed/Database.Seed.csproj
@@ -19,7 +19,6 @@
-
+
diff --git a/src/Core/Database/Database.Seed/UserSeeder.cs b/src/Core/Database/Database.Seed/UserSeeder.cs
index 13465b9..3dfa52c 100644
--- a/src/Core/Database/Database.Seed/UserSeeder.cs
+++ b/src/Core/Database/Database.Seed/UserSeeder.cs
@@ -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;
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Auth/AuthRepository.test.cs b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs
similarity index 99%
rename from src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Auth/AuthRepository.test.cs
rename to src/Core/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs
index efd8eac..f8bf9d8 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Auth/AuthRepository.test.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs
@@ -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;
diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs
new file mode 100644
index 0000000..f455382
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs
@@ -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;
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
new file mode 100644
index 0000000..2013eb1
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
@@ -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"]
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Repository.Tests.csproj b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj
similarity index 88%
rename from src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Repository.Tests.csproj
rename to src/Core/Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj
index d3755e2..a71f674 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Repository.Tests.csproj
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj
@@ -4,7 +4,7 @@
enable
enable
false
- Repository.Tests
+ Infrastructure.Repository.Tests
@@ -35,6 +35,6 @@
-
+
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs b/src/Core/Infrastructure/Infrastructure.Repository.Tests/UserAccount/UserAccountRepository.test.cs
similarity index 99%
rename from src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs
rename to src/Core/Infrastructure/Infrastructure.Repository.Tests/UserAccount/UserAccountRepository.test.cs
index 65bdf6c..d395658 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/UserAccount/UserAccountRepository.test.cs
@@ -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;
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs
new file mode 100644
index 0000000..65a5501
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs
@@ -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,
+ IAuthRepository
+{
+ public AuthRepository(ISqlConnectionFactory connectionFactory)
+ : base(connectionFactory) { }
+
+ public async Task 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 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 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 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();
+ }
+
+ ///
+ /// Maps a data reader row to a UserAccount entity.
+ ///
+ 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"],
+ };
+ }
+
+ ///
+ /// Maps a data reader row to a UserCredential entity.
+ ///
+ 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()
+ .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;
+ }
+
+ ///
+ /// Helper method to add a parameter to a database command.
+ ///
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs
new file mode 100644
index 0000000..619276d
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs
@@ -0,0 +1,67 @@
+using Domain.Core.Entities;
+
+namespace Infrastructure.Repository.Auth;
+
+///
+/// Repository for authentication-related database operations including user registration and credential management.
+///
+public interface IAuthRepository
+{
+ ///
+ /// Registers a new user with account details and initial credential.
+ /// Uses stored procedure: USP_RegisterUser
+ ///
+ /// Unique username for the user
+ /// User's first name
+ /// User's last name
+ /// User's email address
+ /// User's date of birth
+ /// Hashed password
+ /// The newly created UserAccount with generated ID
+ Task RegisterUserAsync(
+ string username,
+ string firstName,
+ string lastName,
+ string email,
+ DateTime dateOfBirth,
+ string passwordHash
+ );
+
+ ///
+ /// Retrieves a user account by email address (typically used for login).
+ /// Uses stored procedure: usp_GetUserAccountByEmail
+ ///
+ /// Email address to search for
+ /// UserAccount if found, null otherwise
+ Task GetUserByEmailAsync(
+ string email
+ );
+
+ ///
+ /// Retrieves a user account by username (typically used for login).
+ /// Uses stored procedure: usp_GetUserAccountByUsername
+ ///
+ /// Username to search for
+ /// UserAccount if found, null otherwise
+ Task GetUserByUsernameAsync(
+ string username
+ );
+
+ ///
+ /// Retrieves the active (non-revoked) credential for a user account.
+ /// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
+ ///
+ /// ID of the user account
+ /// Active UserCredential if found, null otherwise
+ Task GetActiveCredentialByUserAccountIdAsync(
+ Guid userAccountId
+ );
+
+ ///
+ /// Rotates a user's credential by invalidating all existing credentials and creating a new one.
+ /// Uses stored procedure: USP_RotateUserCredential
+ ///
+ /// ID of the user account
+ /// New hashed password
+ Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
+}
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repository.Core.csproj b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
similarity index 83%
rename from src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repository.Core.csproj
rename to src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
index 9268fc3..178bda8 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repository.Core.csproj
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
@@ -3,7 +3,7 @@
net10.0
enable
enable
- Repository.Core
+ Infrastructure.Repository
@@ -18,6 +18,6 @@
/>
-
+
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/AuthRepository.cs
deleted file mode 100644
index 3b7c536..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/AuthRepository.cs
+++ /dev/null
@@ -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,
- IAuthRepository
- {
- public AuthRepository(ISqlConnectionFactory connectionFactory)
- : base(connectionFactory) { }
-
- public async Task 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 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 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 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();
- }
-
- ///
- /// Maps a data reader row to a UserAccount entity.
- ///
- 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"],
- };
- }
-
- ///
- /// Maps a data reader row to a UserCredential entity.
- ///
- 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()
- .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;
- }
-
- ///
- /// Helper method to add a parameter to a database command.
- ///
- 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);
- }
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/IAuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/IAuthRepository.cs
deleted file mode 100644
index 2ebd49d..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/IAuthRepository.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using Domain.Core.Entities;
-
-namespace Repository.Core.Repositories.Auth
-{
- ///
- /// Repository for authentication-related database operations including user registration and credential management.
- ///
- public interface IAuthRepository
- {
- ///
- /// Registers a new user with account details and initial credential.
- /// Uses stored procedure: USP_RegisterUser
- ///
- /// Unique username for the user
- /// User's first name
- /// User's last name
- /// User's email address
- /// User's date of birth
- /// Hashed password
- /// The newly created UserAccount with generated ID
- Task RegisterUserAsync(
- string username,
- string firstName,
- string lastName,
- string email,
- DateTime dateOfBirth,
- string passwordHash
- );
-
- ///
- /// Retrieves a user account by email address (typically used for login).
- /// Uses stored procedure: usp_GetUserAccountByEmail
- ///
- /// Email address to search for
- /// UserAccount if found, null otherwise
- Task GetUserByEmailAsync(
- string email
- );
-
- ///
- /// Retrieves a user account by username (typically used for login).
- /// Uses stored procedure: usp_GetUserAccountByUsername
- ///
- /// Username to search for
- /// UserAccount if found, null otherwise
- Task GetUserByUsernameAsync(
- string username
- );
-
- ///
- /// Retrieves the active (non-revoked) credential for a user account.
- /// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
- ///
- /// ID of the user account
- /// Active UserCredential if found, null otherwise
- Task GetActiveCredentialByUserAccountIdAsync(
- Guid userAccountId
- );
-
- ///
- /// Rotates a user's credential by invalidating all existing credentials and creating a new one.
- /// Uses stored procedure: USP_RotateUserCredential
- ///
- /// ID of the user account
- /// New hashed password
- Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Repository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Repository.cs
deleted file mode 100644
index 6ac9eab..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Repository.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Data.Common;
-using Repository.Core.Sql;
-
-namespace Repository.Core.Repositories
-{
- public abstract class Repository(ISqlConnectionFactory connectionFactory)
- where T : class
- {
- protected async Task CreateConnection()
- {
- var connection = connectionFactory.CreateConnection();
- await connection.OpenAsync();
- return connection;
- }
-
- protected abstract T MapToEntity(DbDataReader reader);
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs
deleted file mode 100644
index d578bb3..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Domain.Core.Entities;
-
-namespace Repository.Core.Repositories.UserAccount
-{
- public interface IUserAccountRepository
- {
- Task GetByIdAsync(Guid id);
- Task> GetAllAsync(
- int? limit,
- int? offset
- );
- Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount);
- Task DeleteAsync(Guid id);
- Task GetByUsernameAsync(
- string username
- );
- Task GetByEmailAsync(string email);
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs
deleted file mode 100644
index 7b4eb19..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs
+++ /dev/null
@@ -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(connectionFactory),
- IUserAccountRepository
- {
- public async Task 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
- > 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();
-
- 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 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 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);
- }
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs
deleted file mode 100644
index e12e1b1..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/ISqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/ISqlConnectionFactory.cs
deleted file mode 100644
index c8be898..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/ISqlConnectionFactory.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System.Data.Common;
-
-namespace Repository.Core.Sql
-{
- public interface ISqlConnectionFactory
- {
- DbConnection CreateConnection();
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs
deleted file mode 100644
index 42a991c..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using Microsoft.Data.SqlClient;
-
-namespace Repository.Core.Sql
-{
- public static class SqlConnectionStringHelper
- {
- ///
- /// Builds a SQL Server connection string from environment variables.
- /// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
- ///
- /// Optional override for the database name. If null, uses DB_NAME env var.
- /// A properly formatted SQL Server connection string.
- 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;
- }
-
- ///
- /// Builds a connection string to the master database using environment variables.
- ///
- /// A connection string for the master database.
- public static string BuildMasterConnectionString()
- {
- return BuildConnectionString("master");
- }
- }
-}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Dockerfile b/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Dockerfile
deleted file mode 100644
index be8f45c..0000000
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Dockerfile
+++ /dev/null
@@ -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"]
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs
new file mode 100644
index 0000000..30672d9
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs
@@ -0,0 +1,17 @@
+using System.Data.Common;
+using Infrastructure.Repository.Sql;
+
+namespace Infrastructure.Repository;
+
+public abstract class Repository(ISqlConnectionFactory connectionFactory)
+ where T : class
+{
+ protected async Task CreateConnection()
+ {
+ var connection = connectionFactory.CreateConnection();
+ await connection.OpenAsync();
+ return connection;
+ }
+
+ protected abstract T MapToEntity(DbDataReader reader);
+}
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs
new file mode 100644
index 0000000..8d5bf62
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs
new file mode 100644
index 0000000..40a6eed
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs
@@ -0,0 +1,8 @@
+using System.Data.Common;
+
+namespace Infrastructure.Repository.Sql;
+
+public interface ISqlConnectionFactory
+{
+ DbConnection CreateConnection();
+}
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs
new file mode 100644
index 0000000..27daeff
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs
@@ -0,0 +1,61 @@
+using Microsoft.Data.SqlClient;
+
+namespace Infrastructure.Repository.Sql;
+
+public static class SqlConnectionStringHelper
+{
+ ///
+ /// Builds a SQL Server connection string from environment variables.
+ /// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
+ ///
+ /// Optional override for the database name. If null, uses DB_NAME env var.
+ /// A properly formatted SQL Server connection string.
+ 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;
+ }
+
+ ///
+ /// Builds a connection string to the master database using environment variables.
+ ///
+ /// A connection string for the master database.
+ public static string BuildMasterConnectionString()
+ {
+ return BuildConnectionString("master");
+ }
+}
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs
new file mode 100644
index 0000000..e1929ca
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs
@@ -0,0 +1,16 @@
+namespace Infrastructure.Repository.UserAccount;
+
+public interface IUserAccountRepository
+{
+ Task GetByIdAsync(Guid id);
+ Task> GetAllAsync(
+ int? limit,
+ int? offset
+ );
+ Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount);
+ Task DeleteAsync(Guid id);
+ Task GetByUsernameAsync(
+ string username
+ );
+ Task GetByEmailAsync(string email);
+}
\ No newline at end of file
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs
new file mode 100644
index 0000000..6d9252b
--- /dev/null
+++ b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs
@@ -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(connectionFactory),
+ IUserAccountRepository
+{
+ public async Task 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
+ > 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();
+
+ 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 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 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);
+ }
+}
\ No newline at end of file
diff --git a/src/Core/Service/Service.Core/Auth/AuthService.cs b/src/Core/Service/Service.Core/Auth/AuthService.cs
index 51dbbc8..738a0ae 100644
--- a/src/Core/Service/Service.Core/Auth/AuthService.cs
+++ b/src/Core/Service/Service.Core/Auth/AuthService.cs
@@ -1,6 +1,6 @@
using Domain.Core.Entities;
using Infrastructure.PasswordHashing;
-using Repository.Core.Repositories.Auth;
+using Infrastructure.Repository.Auth;
namespace Service.Core.Auth;
diff --git a/src/Core/Service/Service.Core/Service.Core.csproj b/src/Core/Service/Service.Core/Service.Core.csproj
index 595fd7d..c01d95b 100644
--- a/src/Core/Service/Service.Core/Service.Core.csproj
+++ b/src/Core/Service/Service.Core/Service.Core.csproj
@@ -12,8 +12,7 @@
-
+
diff --git a/src/Core/Service/Service.Core/User/UserService.cs b/src/Core/Service/Service.Core/User/UserService.cs
index fb249cd..977c94c 100644
--- a/src/Core/Service/Service.Core/User/UserService.cs
+++ b/src/Core/Service/Service.Core/User/UserService.cs
@@ -1,5 +1,5 @@
using Domain.Core.Entities;
-using Repository.Core.Repositories.UserAccount;
+using Infrastructure.Repository.UserAccount;
namespace Service.Core.User;