feat(infra): implement RefreshTokenRepository with Dapper and add GetByIdAsync to UsuarioRepository

This commit is contained in:
2026-04-14 13:28:29 -03:00
parent e405c0453b
commit 0c809da633
2 changed files with 166 additions and 2 deletions

View File

@@ -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<RefreshToken?> 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<RefreshTokenRow>(sql, new { TokenHash = tokenHash });
return row is null ? null : MapRow(row);
}
public async Task<int> 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<int>(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<int> 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<int>(sql, new { FamilyId = familyId, RevokedAt = revokedAt });
}
public async Task<int> 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<int>(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;
}
}
}

View File

@@ -32,7 +32,32 @@ public sealed class UsuarioRepository : IUsuarioRepository
if (row is null) return null;
return new Usuario(
return MapRow(row);
}
public async Task<Usuario?> 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<UsuarioRow>(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(