using System.Transactions; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Application.Usuarios.GetById; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Usuarios.Update; public sealed class UpdateUsuarioCommandHandler : ICommandHandler { private readonly IUsuarioRepository _repository; private readonly IRolRepository _rolRepository; private readonly IRefreshTokenRepository _refreshTokenRepository; private readonly IAuditLogger _audit; private readonly TimeProvider _timeProvider; public UpdateUsuarioCommandHandler( IUsuarioRepository repository, IRolRepository rolRepository, IRefreshTokenRepository refreshTokenRepository, IAuditLogger audit, TimeProvider timeProvider) { _repository = repository; _rolRepository = rolRepository; _refreshTokenRepository = refreshTokenRepository; _audit = audit; _timeProvider = timeProvider; } public async Task Handle(UpdateUsuarioCommand cmd) { var target = await _repository.GetByIdAsync(cmd.Id) ?? throw new UsuarioNotFoundException(cmd.Id); // Guard: validate rol exists and is active var rolExists = await _rolRepository.ExistsActiveByCodigoAsync(cmd.Rol); if (!rolExists) throw new FluentValidation.ValidationException( [new FluentValidation.Results.ValidationFailure("Rol", $"El rol '{cmd.Rol}' no existe o está inactivo.")]); // Guard: anti-lockout — cannot remove last active admin if (target.Rol == "admin" && target.Activo) { var isChangingRol = !string.Equals(cmd.Rol, "admin", StringComparison.Ordinal); var isDeactivating = !cmd.Activo; if ((isChangingRol || isDeactivating) && await _repository.CountActiveAdminsAsync() <= 1) { throw new LastAdminLockoutException(); } } var fields = new UpdateUsuarioFields(cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo); var now = _timeProvider.GetUtcNow().UtcDateTime; using (var tx = new TransactionScope( TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }, TransactionScopeAsyncFlowOption.Enabled)) { await _repository.UpdateAsync(cmd.Id, fields, now); // Revoke refresh tokens if rol changed or user deactivated var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal); var justDeactivated = target.Activo && !cmd.Activo; if (rolChanged || justDeactivated) { await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now); } await _audit.LogAsync( action: "usuario.update", targetType: "Usuario", targetId: cmd.Id.ToString(), metadata: new { before = new { target.Nombre, target.Apellido, target.Email, target.Rol, target.Activo }, after = new { cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo }, }); tx.Complete(); } // Post-commit read: outside the scope so SqlClient does not try to enlist a completed transaction. var updated = await _repository.GetDetailAsync(cmd.Id) ?? throw new UsuarioNotFoundException(cmd.Id); return new UsuarioDetailDto( updated.Id, updated.Username, updated.Nombre, updated.Apellido, updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword, updated.UltimoLogin, updated.FechaModificacion); } }