From 0c809da633c47c2b3c41092a360ac60b1d905d35 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 14 Apr 2026 13:28:29 -0300 Subject: [PATCH] feat(infra): implement RefreshTokenRepository with Dapper and add GetByIdAsync to UsuarioRepository --- .../Persistence/RefreshTokenRepository.cs | 140 ++++++++++++++++++ .../Persistence/UsuarioRepository.cs | 28 +++- 2 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/RefreshTokenRepository.cs diff --git a/src/api/SIGCM2.Infrastructure/Persistence/RefreshTokenRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/RefreshTokenRepository.cs new file mode 100644 index 0000000..ade49b8 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/RefreshTokenRepository.cs @@ -0,0 +1,140 @@ +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; + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs index 00d0275..845c135 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs @@ -32,7 +32,32 @@ public sealed class UsuarioRepository : IUsuarioRepository if (row is null) return null; - return new Usuario( + return MapRow(row); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT + Id, Username, PasswordHash, + Nombre, Apellido, Email, + Rol, PermisosJson, Activo + FROM dbo.Usuario + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + + if (row is null) return null; + + return MapRow(row); + } + + private static Usuario MapRow(UsuarioRow row) + => new( id: row.Id, username: row.Username, passwordHash: row.PasswordHash, @@ -43,7 +68,6 @@ public sealed class UsuarioRepository : IUsuarioRepository permisosJson: row.PermisosJson, activo: row.Activo ); - } // Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes) private sealed record UsuarioRow(