using System.Data.Common; using Dapper; using Domain.Entities; using Domain.Exceptions; using Features.Auth.Dtos; using Infrastructure.Sql; using Microsoft.Data.SqlClient; namespace Features.Auth.Repository; /// /// Dapper-based implementation of , handling user registration, /// credential lookup/rotation, and account verification. /// public class AuthRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IAuthRepository { private const string SelectColumns = "UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer"; /// /// Registers a new user account and its initial credential in a single transaction, then fetches /// and returns the newly created user. /// /// 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 DbTransaction transaction = await connection.BeginTransactionAsync(); var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto; Guid userAccountId; try { userAccountId = await connection.ExecuteScalarAsync( new CommandDefinition( """ INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email) OUTPUT INSERTED.UserAccountID VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email); """, new { username, firstName, lastName, dateOfBirth, email }, transaction ) ); int credentialRows = await connection.ExecuteAsync( new CommandDefinition( "INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash);", new { UserAccountId = userAccountId, Hash = passwordHash }, transaction ) ); if (credentialRows == 0) throw new InvalidOperationException("Failed to create user credential."); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user."); } /// Retrieves a user account by email. public async Task GetUserByEmailAsync(string email) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Email = @Email", new { Email = email } ); } /// Retrieves a user account by username. public async Task GetUserByUsernameAsync(string username) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Username = @Username", new { Username = username } ); } /// Retrieves the active (non-revoked) credential for a user account. public async Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( """ SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, Timer FROM dbo.UserCredential WHERE UserAccountId = @UserAccountId AND IsRevoked = 0 """, new { UserAccountId = userAccountId } ); } /// /// Rotates a user's credential by revoking all existing credentials and creating a new one. /// /// Thrown when no account with exists. public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash) { await using DbConnection connection = await CreateConnection(); await using DbTransaction transaction = await connection.BeginTransactionAsync(); try { bool exists = await connection.ExecuteScalarAsync( new CommandDefinition( "SELECT 1 FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId", new { UserAccountId = userAccountId }, transaction ) ) is not null; if (!exists) throw new NotFoundException("User account not found."); await connection.ExecuteAsync( new CommandDefinition( "UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @UserAccountId", new { UserAccountId = userAccountId }, transaction ) ); await connection.ExecuteAsync( new CommandDefinition( "INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash)", new { UserAccountId = userAccountId, Hash = newPasswordHash }, transaction ) ); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } } /// Retrieves a user account by ID. public async Task GetUserByIdAsync(Guid userAccountId) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId", new { UserAccountId = userAccountId } ); } /// /// Marks a user account as confirmed by inserting a verification record. 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(); try { await connection.ExecuteAsync( "INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) VALUES (@UserAccountId, GETDATE())", new { UserAccountId = userAccountId } ); } 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(); int? result = await connection.ExecuteScalarAsync( "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId", new { UserAccountId = userAccountId } ); return result.HasValue; } /// 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; } }