using System.Data; using System.Data.Common; using Domain.Entities; 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. /// /// The factory used to create database connections. public class AuthRepository(ISqlConnectionFactory connectionFactory) : Infrastructure.Sql.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. /// /// 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 /// Thrown when the newly registered user cannot be retrieved after registration. /// Thrown when the database command fails. 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(); 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 var 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 { var str = result.ToString(); if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var 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 address (typically used for login) using the /// usp_GetUserAccountByEmail stored procedure. /// /// Email address to search for /// UserAccount if found, null otherwise /// Thrown when the database command fails. 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; } /// /// Retrieves a user account by username (typically used for login) using the /// usp_GetUserAccountByUsername stored procedure. /// /// Username to search for /// UserAccount if found, null otherwise /// Thrown when the database command fails. 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; } /// /// Retrieves the active (non-revoked) credential for a user account using the /// USP_GetActiveUserCredentialByUserAccountId stored procedure. /// /// ID of the user account /// Active UserCredential if found, null otherwise /// Thrown when the database command fails. 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; } /// /// Rotates a user's credential by invalidating all existing credentials and creating a new one, /// using the USP_RotateUserCredential stored procedure. /// /// ID of the user account /// New hashed password /// Thrown when the database command fails. 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(); } /// /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. /// /// ID of the user account /// UserAccount if found, null otherwise /// Thrown when the database command fails. public async Task GetUserByIdAsync( Guid userAccountId ) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); command.CommandText = "usp_GetUserAccountById"; command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@UserAccountId", userAccountId); await using var 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. /// /// ID of the user account to confirm /// The confirmed , or null if the user account does not exist. /// Thrown when the database command fails for a reason other than a duplicate verification record. public async Task ConfirmUserAccountAsync( Guid userAccountId ) { var 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 var connection = await CreateConnection(); await using var 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 for a matching record. /// /// ID of the user account /// True if the user has a verification record, false otherwise /// Thrown when the database command fails. public async Task IsUserVerifiedAsync(Guid userAccountId) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID"; command.CommandType = CommandType.Text; AddParameter(command, "@UserAccountID", userAccountId); var result = await command.ExecuteScalarAsync(); return result != null && result != DBNull.Value; } /// /// Determines whether a represents a duplicate key violation /// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert. /// /// The SQL exception to inspect. /// True if the exception represents a duplicate key violation, false otherwise. private static bool IsDuplicateVerificationViolation(SqlException ex) { // 2601/2627 are duplicate key violations in SQL Server. return ex.Number == 2601 || ex.Number == 2627; } /// /// Maps a data reader row to a UserAccount entity. /// /// The data reader positioned on the row to map. /// The mapped instance. protected override Domain.Entities.UserAccount MapToEntity( DbDataReader reader ) { return new Domain.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. The Timer column is mapped only if /// present in the reader's schema, allowing this method to support result sets that omit it. /// /// The data reader positioned on the row to map. /// The mapped instance. 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, converting null values to /// . /// /// The command to add the parameter to. /// The parameter name (including any prefix, e.g. "@Username"). /// The parameter value, or null to bind . 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); } }