feat(infra): implement RefreshTokenRepository with Dapper and add GetByIdAsync to UsuarioRepository
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,32 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
|||||||
|
|
||||||
if (row is null) return null;
|
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,
|
id: row.Id,
|
||||||
username: row.Username,
|
username: row.Username,
|
||||||
passwordHash: row.PasswordHash,
|
passwordHash: row.PasswordHash,
|
||||||
@@ -43,7 +68,6 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
|||||||
permisosJson: row.PermisosJson,
|
permisosJson: row.PermisosJson,
|
||||||
activo: row.Activo
|
activo: row.Activo
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes)
|
// Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes)
|
||||||
private sealed record UsuarioRow(
|
private sealed record UsuarioRow(
|
||||||
|
|||||||
Reference in New Issue
Block a user