using Dapper; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Domain.Entities; namespace SIGCM2.Infrastructure.Persistence; public sealed class RefreshTokenRepository : IRefreshTokenRepository { private readonly SqlConnectionFactory _connectionFactory; public RefreshTokenRepository(SqlConnectionFactory connectionFactory) { _connectionFactory = connectionFactory; } public async Task GetByHashAsync(string tokenHash, CancellationToken ct = default) { const string sql = """ SELECT Id, UsuarioId, TokenHash, FamilyId, IssuedAt, ExpiresAt, RevokedAt, ReplacedById, CreatedByIp, UserAgent FROM dbo.RefreshToken WHERE TokenHash = @TokenHash """; await using var connection = _connectionFactory.CreateConnection(); await connection.OpenAsync(ct); var row = await connection.QuerySingleOrDefaultAsync(sql, new { TokenHash = tokenHash }); return row is null ? null : MapRow(row); } public async Task AddAsync(RefreshToken token, CancellationToken ct = default) { const string sql = """ INSERT INTO dbo.RefreshToken (UsuarioId, TokenHash, FamilyId, IssuedAt, ExpiresAt, CreatedByIp, UserAgent) VALUES (@UsuarioId, @TokenHash, @FamilyId, @IssuedAt, @ExpiresAt, @CreatedByIp, @UserAgent); SELECT CAST(SCOPE_IDENTITY() AS INT); """; await using var connection = _connectionFactory.CreateConnection(); await connection.OpenAsync(ct); return await connection.QuerySingleAsync(sql, new { token.UsuarioId, token.TokenHash, token.FamilyId, token.IssuedAt, token.ExpiresAt, token.CreatedByIp, token.UserAgent, }); } public async Task RevokeAsync(int id, int? replacedById, DateTime revokedAt, CancellationToken ct = default) { const string sql = """ UPDATE dbo.RefreshToken SET RevokedAt = @RevokedAt, ReplacedById = @ReplacedById WHERE Id = @Id AND RevokedAt IS NULL """; await using var connection = _connectionFactory.CreateConnection(); await connection.OpenAsync(ct); await connection.ExecuteAsync(sql, new { Id = id, ReplacedById = replacedById, RevokedAt = revokedAt }); } public async Task RevokeFamilyAsync(Guid familyId, DateTime revokedAt, CancellationToken ct = default) { const string sql = """ UPDATE dbo.RefreshToken SET RevokedAt = @RevokedAt WHERE FamilyId = @FamilyId AND RevokedAt IS NULL; SELECT @@ROWCOUNT; """; await using var connection = _connectionFactory.CreateConnection(); await connection.OpenAsync(ct); return await connection.QuerySingleAsync(sql, new { FamilyId = familyId, RevokedAt = revokedAt }); } public async Task RevokeAllActiveForUserAsync(int usuarioId, DateTime revokedAt, CancellationToken ct = default) { const string sql = """ UPDATE dbo.RefreshToken SET RevokedAt = @RevokedAt WHERE UsuarioId = @UsuarioId AND RevokedAt IS NULL; SELECT @@ROWCOUNT; """; await using var connection = _connectionFactory.CreateConnection(); await connection.OpenAsync(ct); return await connection.QuerySingleAsync(sql, new { UsuarioId = usuarioId, RevokedAt = revokedAt }); } private static RefreshToken MapRow(RefreshTokenRow row) => RefreshTokenRow.Reconstruct(row); // Flat Dapper DTO private sealed record RefreshTokenRow( int Id, int UsuarioId, string TokenHash, Guid FamilyId, DateTime IssuedAt, DateTime ExpiresAt, DateTime? RevokedAt, int? ReplacedById, string CreatedByIp, string? UserAgent) { public static RefreshToken Reconstruct(RefreshTokenRow r) { // Build an empty token using the rotation factory from a dummy parent, // then we manually set fields via the available setters. // Since RefreshToken uses init-only properties, we use object initializer. var token = new RefreshToken { Id = r.Id, UsuarioId = r.UsuarioId, TokenHash = r.TokenHash, FamilyId = r.FamilyId, IssuedAt = r.IssuedAt, ExpiresAt = r.ExpiresAt, CreatedByIp = r.CreatedByIp, UserAgent = r.UserAgent, }; if (r.RevokedAt.HasValue) token.MarkAsPersistedRevocation(r.RevokedAt.Value, r.ReplacedById); return token; } } }