From 4f92741b4f34fbfec77c0ddeefbf003c787e1e70 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 12 Feb 2026 10:21:34 -0500 Subject: [PATCH] Refactor repository structure --- .gitignore | 3 - README.md | 99 ++++----- docker-compose.test.yaml | 2 +- src/Core/API/API.Core/API.Core.csproj | 3 +- src/Core/API/API.Core/Dockerfile | 2 +- src/Core/API/API.Core/Program.cs | 6 +- src/Core/API/API.Specs/Dockerfile | 2 +- src/Core/Core.slnx | 8 +- .../Database.Seed/Database.Seed.csproj | 3 +- src/Core/Database/Database.Seed/UserSeeder.cs | 1 - .../Auth/AuthRepository.test.cs | 2 +- .../Database/TestConnectionFactory.cs | 11 + .../Dockerfile | 15 ++ .../Repository.Tests.csproj | 4 +- .../UserAccount/UserAccountRepository.test.cs | 2 +- .../Auth/AuthRepository.cs | 198 +++++++++++++++++ .../Auth/IAuthRepository.cs | 67 ++++++ ...sproj => Infrastructure.Repository.csproj} | 4 +- .../Repositories/Auth/AuthRepository.cs | 199 ------------------ .../Repositories/Auth/IAuthRepository.cs | 68 ------ .../Repositories/Repository.cs | 18 -- .../UserAccount/IUserAccountRepository.cs | 19 -- .../UserAccount/UserAccountRepository.cs | 151 ------------- .../Sql/DefaultSqlConnectionFactory.cs | 50 ----- .../Sql/ISqlConnectionFactory.cs | 9 - .../Sql/SqlConnectionStringHelper.cs | 62 ------ .../Repository.Tests/Dockerfile | 15 -- .../Infrastructure.Repository/Repository.cs | 17 ++ .../Sql/DefaultSqlConnectionFactory.cs | 49 +++++ .../Sql/ISqlConnectionFactory.cs | 8 + .../Sql/SqlConnectionStringHelper.cs | 61 ++++++ .../UserAccount/IUserAccountRepository.cs | 16 ++ .../UserAccount/UserAccountRepository.cs | 149 +++++++++++++ .../Service/Service.Core/Auth/AuthService.cs | 2 +- .../Service/Service.Core/Service.Core.csproj | 3 +- .../Service/Service.Core/User/UserService.cs | 2 +- 36 files changed, 651 insertions(+), 679 deletions(-) rename src/Core/Infrastructure/{Infrastructure.Repository/Repository.Tests => Infrastructure.Repository.Tests}/Auth/AuthRepository.test.cs (99%) create mode 100644 src/Core/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs create mode 100644 src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile rename src/Core/Infrastructure/{Infrastructure.Repository/Repository.Tests => Infrastructure.Repository.Tests}/Repository.Tests.csproj (88%) rename src/Core/Infrastructure/{Infrastructure.Repository/Repository.Tests => Infrastructure.Repository.Tests}/UserAccount/UserAccountRepository.test.cs (99%) create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs rename src/Core/Infrastructure/Infrastructure.Repository/{Repository.Core/Repository.Core.csproj => Infrastructure.Repository.csproj} (83%) delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/AuthRepository.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Auth/IAuthRepository.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/Repository.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/ISqlConnectionFactory.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs delete mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.Tests/Dockerfile create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Repository.cs create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs create mode 100644 src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs 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;