using System.Data; using System.Data.Common; using Domain.Entities; using Features.Auth.Dtos; using Infrastructure.Sql; using Microsoft.Data.SqlClient; namespace Features.Auth.Repository; /// /// ADO.NET-based implementation of backed by SQL Server stored procedures, /// handling user registration, credential lookup/rotation, and account verification. /// public class AuthRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IAuthRepository { /// /// Registers a new user account and initial credential using the USP_RegisterUser stored /// procedure, then fetches and returns the newly created user. /// /// /// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively: /// it may be returned as a , a parseable , or a 16-byte array. /// If the result cannot be interpreted, is used, which will cause the /// subsequent lookup to fail. /// /// Thrown when the newly registered user cannot be retrieved after registration. public async Task RegisterUserAsync( UserRegistrationDto userRegistrationDto ) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "USP_RegisterUser"; command.CommandType = CommandType.StoredProcedure; var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto; AddParameter(command, "@Username", username); AddParameter(command, "@FirstName", firstName); AddParameter(command, "@LastName", lastName); AddParameter(command, "@Email", email); AddParameter(command, "@DateOfBirth", dateOfBirth); AddParameter(command, "@Hash", passwordHash); object? result = await command.ExecuteScalarAsync(); Guid userAccountId = Guid.Empty; if (result != null && result != DBNull.Value) { if (result is Guid g) userAccountId = g; else if (result is string s && Guid.TryParse(s, out Guid parsed)) userAccountId = parsed; else if (result is byte[] bytes && bytes.Length == 16) userAccountId = new Guid(bytes); else // Fallback: try to convert and parse string representation try { string? str = result.ToString(); if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p)) userAccountId = p; } catch { userAccountId = Guid.Empty; } } return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user."); } /// Retrieves a user account by email using the usp_GetUserAccountByEmail stored procedure. public async Task GetUserByEmailAsync(string email) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "usp_GetUserAccountByEmail"; command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@Email", email); await using DbDataReader reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; } /// Retrieves a user account by username using the usp_GetUserAccountByUsername stored procedure. public async Task GetUserByUsernameAsync(string username) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "usp_GetUserAccountByUsername"; command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@Username", username); await using DbDataReader reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; } /// /// Retrieves the active (non-revoked) credential for a user account using the /// USP_GetActiveUserCredentialByUserAccountId stored procedure. /// public async Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@UserAccountId", userAccountId); await using DbDataReader reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null; } /// /// Rotates a user's credential by invalidating all existing credentials and creating a new one, /// using the USP_RotateUserCredential stored procedure. /// public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "USP_RotateUserCredential"; command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@UserAccountId_", userAccountId); AddParameter(command, "@Hash", newPasswordHash); await command.ExecuteNonQueryAsync(); } /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. public async Task GetUserByIdAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "usp_GetUserAccountById"; command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@UserAccountId", userAccountId); await using DbDataReader reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; } /// /// Marks a user account as confirmed by creating a verification record via the /// USP_CreateUserVerification stored procedure. If the user is already verified, this is a /// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user /// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed. /// /// /// Thrown when the database command fails for a reason other than /// a duplicate verification record. /// public async Task ConfirmUserAccountAsync(Guid userAccountId) { UserAccount? user = await GetUserByIdAsync(userAccountId); if (user == null) return null; // Idempotency: if already verified, treat as successful confirmation. if (await IsUserVerifiedAsync(userAccountId)) return user; await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "USP_CreateUserVerification"; command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@UserAccountID_", userAccountId); try { await command.ExecuteNonQueryAsync(); } catch (SqlException ex) when (IsDuplicateVerificationViolation(ex)) { // A concurrent request verified this user first. Keep behavior idempotent. } // Fetch and return the updated user return await GetUserByIdAsync(userAccountId); } /// Checks whether a user account has been verified by querying the dbo.UserVerification table. public async Task IsUserVerifiedAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); await using DbCommand command = connection.CreateCommand(); command.CommandText = "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID"; command.CommandType = CommandType.Text; AddParameter(command, "@UserAccountID", userAccountId); object? result = await command.ExecuteScalarAsync(); return result != null && result != DBNull.Value; } /// Determines whether a is a duplicate key violation (SQL Server error 2601 or 2627). private static bool IsDuplicateVerificationViolation(SqlException ex) { // 2601/2627 are duplicate key violations in SQL Server. return ex.Number == 2601 || ex.Number == 2627; } protected override UserAccount MapToEntity(DbDataReader reader) { return new UserAccount { UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), Username = reader.GetString(reader.GetOrdinal("Username")), FirstName = reader.GetString(reader.GetOrdinal("FirstName")), LastName = reader.GetString(reader.GetOrdinal("LastName")), Email = reader.GetString(reader.GetOrdinal("Email")), CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt")) ? null : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"], }; } /// /// Maps a data reader row to a UserCredential entity. The Timer column is mapped only if /// present in the reader's schema, allowing this method to support result sets that omit it. /// private static UserCredential MapToCredentialEntity(DbDataReader reader) { UserCredential entity = new() { UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")), UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), Hash = reader.GetString(reader.GetOrdinal("Hash")), CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), }; // Optional columns bool hasTimer = reader .GetSchemaTable() ?.Rows.Cast() .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; } /// Adds a parameter to a database command, converting null values to . private static void AddParameter(DbCommand command, string name, object? value) { DbParameter p = command.CreateParameter(); p.ParameterName = name; p.Value = value ?? DBNull.Value; command.Parameters.Add(p); } }