using System.Data;
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Repository.Sql;
using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Auth;
///
/// 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)
: 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);
}
}