feat(api): List + GetById usuarios — handlers, repo, endpoints [UDT-008]
This commit is contained in:
43
src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs
Normal file
43
src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Generates cryptographically secure temporary passwords.
|
||||
/// Excludes visually ambiguous characters (I, O, l, o, 0, 1).
|
||||
/// </summary>
|
||||
public static class TempPasswordGenerator
|
||||
{
|
||||
private const string UpperChars = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O
|
||||
private const string LowerChars = "abcdefghijkmnpqrstuvwxyz"; // no l, o
|
||||
private const string DigitChars = "23456789"; // no 0, 1
|
||||
private const string SymbolChars = "!@#$%&*+-=?"; // copy-paste safe
|
||||
|
||||
private const string Charset = UpperChars + LowerChars + DigitChars + SymbolChars;
|
||||
|
||||
public static string Generate(int length = 12)
|
||||
{
|
||||
if (length < 8)
|
||||
throw new ArgumentOutOfRangeException(nameof(length), "Password length must be at least 8.");
|
||||
|
||||
// SECURITY: NEVER log the result of this method
|
||||
Span<byte> bytes = stackalloc byte[length];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
|
||||
var sb = new StringBuilder(length);
|
||||
for (int i = 0; i < length; i++)
|
||||
sb.Append(Charset[bytes[i] % Charset.Length]);
|
||||
|
||||
var result = sb.ToString();
|
||||
|
||||
// Guarantee diversity: at least 1 upper, 1 lower, 1 digit, 1 symbol
|
||||
return HasDiversity(result) ? result : Generate(length);
|
||||
}
|
||||
|
||||
private static bool HasDiversity(string pwd)
|
||||
=> pwd.Any(c => UpperChars.Contains(c))
|
||||
&& pwd.Any(c => LowerChars.Contains(c))
|
||||
&& pwd.Any(c => DigitChars.Contains(c))
|
||||
&& pwd.Any(c => SymbolChars.Contains(c));
|
||||
}
|
||||
7
src/api/SIGCM2.Application/Common/Unit.cs
Normal file
7
src/api/SIGCM2.Application/Common/Unit.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>Represents the absence of a meaningful return value.</summary>
|
||||
public readonly struct Unit
|
||||
{
|
||||
public static readonly Unit Value = default;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Auth.Login;
|
||||
using SIGCM2.Application.Auth.Logout;
|
||||
using SIGCM2.Application.Auth.Refresh;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Permisos.Assign;
|
||||
using SIGCM2.Application.Permisos.Dtos;
|
||||
using SIGCM2.Application.Permisos.GetByRol;
|
||||
@@ -14,7 +15,14 @@ using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Application.Roles.Get;
|
||||
using SIGCM2.Application.Roles.List;
|
||||
using SIGCM2.Application.Roles.Update;
|
||||
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||
using SIGCM2.Application.Usuarios.Create;
|
||||
using SIGCM2.Application.Usuarios.Deactivate;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Application.Usuarios.List;
|
||||
using SIGCM2.Application.Usuarios.Reactivate;
|
||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||
using SIGCM2.Application.Usuarios.Update;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
|
||||
@@ -40,6 +48,15 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>();
|
||||
|
||||
// Usuarios (UDT-008)
|
||||
services.AddScoped<ICommandHandler<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>, ListUsuariosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetUsuarioByIdQuery, UsuarioDetailDto>, GetUsuarioByIdQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateUsuarioCommand, UsuarioDetailDto>, UpdateUsuarioCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateUsuarioCommand, UsuarioDetailDto>, DeactivateUsuarioCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>, ReactivateUsuarioCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ChangeMyPasswordCommand, Unit>, ChangeMyPasswordCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>, ResetUsuarioPasswordCommandHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||
|
||||
public sealed record ChangeMyPasswordCommand(
|
||||
int UsuarioId,
|
||||
string OldPassword,
|
||||
string NewPassword
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||
|
||||
public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPasswordCommand, Unit>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IPasswordHasher _hasher;
|
||||
|
||||
public ChangeMyPasswordCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IPasswordHasher hasher)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ChangeMyPasswordCommand cmd)
|
||||
{
|
||||
var user = await _repository.GetByIdAsync(cmd.UsuarioId)
|
||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||
|
||||
if (!_hasher.Verify(cmd.OldPassword, user.PasswordHash))
|
||||
throw new InvalidOldPasswordException();
|
||||
|
||||
var newHash = _hasher.Hash(cmd.NewPassword);
|
||||
await _repository.UpdatePasswordAsync(cmd.UsuarioId, newHash, mustChangePassword: false);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
// NOTE: intentionally does NOT revoke own refresh tokens (spec REQ-BCP-05)
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Auth;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||
|
||||
public sealed class ChangeMyPasswordCommandValidator : AbstractValidator<ChangeMyPasswordCommand>
|
||||
{
|
||||
public ChangeMyPasswordCommandValidator(AuthOptions authOptions)
|
||||
{
|
||||
RuleFor(x => x.OldPassword)
|
||||
.NotEmpty()
|
||||
.WithMessage("La contraseña actual es requerida.");
|
||||
|
||||
RuleFor(x => x.NewPassword)
|
||||
.NotEmpty()
|
||||
.WithMessage("La nueva contraseña es requerida.")
|
||||
.MinimumLength(authOptions.PasswordMinLength)
|
||||
.WithMessage($"La contraseña debe tener al menos {authOptions.PasswordMinLength} caracteres.");
|
||||
|
||||
if (authOptions.PasswordRequireLetter)
|
||||
{
|
||||
RuleFor(x => x.NewPassword)
|
||||
.Matches(@"[a-zA-Z]")
|
||||
.WithMessage("La contraseña debe contener al menos una letra.");
|
||||
}
|
||||
|
||||
if (authOptions.PasswordRequireDigit)
|
||||
{
|
||||
RuleFor(x => x.NewPassword)
|
||||
.Matches(@"\d")
|
||||
.WithMessage("La contraseña debe contener al menos un dígito.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.Deactivate;
|
||||
|
||||
public sealed record DeactivateUsuarioCommand(int UsuarioId);
|
||||
@@ -0,0 +1,54 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Deactivate;
|
||||
|
||||
public sealed class DeactivateUsuarioCommandHandler : ICommandHandler<DeactivateUsuarioCommand, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
|
||||
public DeactivateUsuarioCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
{
|
||||
_repository = repository;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> Handle(DeactivateUsuarioCommand cmd)
|
||||
{
|
||||
var target = await _repository.GetByIdAsync(cmd.UsuarioId)
|
||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||
|
||||
// Idempotent: already inactive → return as-is without touching FechaModificacion
|
||||
if (!target.Activo)
|
||||
{
|
||||
return new UsuarioDetailDto(
|
||||
target.Id, target.Username, target.Nombre, target.Apellido,
|
||||
target.Email, target.Rol, target.Activo, target.MustChangePassword,
|
||||
target.UltimoLogin, target.FechaModificacion);
|
||||
}
|
||||
|
||||
// Guard: anti-lockout
|
||||
if (target.Rol == "admin" && await _repository.CountActiveAdminsAsync() <= 1)
|
||||
throw new LastAdminLockoutException();
|
||||
|
||||
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, false);
|
||||
var now = DateTime.UtcNow;
|
||||
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
|
||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
|
||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||
|
||||
return new UsuarioDetailDto(
|
||||
updated.Id, updated.Username, updated.Nombre, updated.Apellido,
|
||||
updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword,
|
||||
updated.UltimoLogin, updated.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.GetById;
|
||||
|
||||
public sealed record GetUsuarioByIdQuery(int Id);
|
||||
@@ -0,0 +1,34 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.GetById;
|
||||
|
||||
public sealed class GetUsuarioByIdQueryHandler : ICommandHandler<GetUsuarioByIdQuery, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
|
||||
public GetUsuarioByIdQueryHandler(IUsuarioRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> Handle(GetUsuarioByIdQuery query)
|
||||
{
|
||||
var usuario = await _repository.GetDetailAsync(query.Id)
|
||||
?? throw new UsuarioNotFoundException(query.Id);
|
||||
|
||||
return new UsuarioDetailDto(
|
||||
Id: usuario.Id,
|
||||
Username: usuario.Username,
|
||||
Nombre: usuario.Nombre,
|
||||
Apellido: usuario.Apellido,
|
||||
Email: usuario.Email,
|
||||
Rol: usuario.Rol,
|
||||
Activo: usuario.Activo,
|
||||
MustChangePassword: usuario.MustChangePassword,
|
||||
UltimoLogin: usuario.UltimoLogin,
|
||||
FechaModificacion: usuario.FechaModificacion
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SIGCM2.Application.Usuarios.GetById;
|
||||
|
||||
/// <summary>
|
||||
/// Full detail projection — excludes PasswordHash and PermisosJson (security).
|
||||
/// </summary>
|
||||
public sealed record UsuarioDetailDto(
|
||||
int Id,
|
||||
string Username,
|
||||
string Nombre,
|
||||
string Apellido,
|
||||
string? Email,
|
||||
string Rol,
|
||||
bool Activo,
|
||||
bool MustChangePassword,
|
||||
DateTime? UltimoLogin,
|
||||
DateTime? FechaModificacion
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.Usuarios.List;
|
||||
|
||||
public sealed record ListUsuariosQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string? Rol,
|
||||
bool? Activo,
|
||||
string? Search
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.List;
|
||||
|
||||
public sealed class ListUsuariosQueryHandler : ICommandHandler<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
|
||||
public ListUsuariosQueryHandler(IUsuarioRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<UsuarioListItemDto>> Handle(ListUsuariosQuery query)
|
||||
{
|
||||
// Clamp paging params
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var repoQuery = new UsuariosQuery(page, pageSize, query.Rol, query.Activo, query.Search);
|
||||
var paged = await _repository.GetPagedAsync(repoQuery);
|
||||
|
||||
var items = paged.Items
|
||||
.Select(u => new UsuarioListItemDto(u.Id, u.Username, u.Nombre, u.Apellido, u.Email, u.Rol, u.Activo, u.UltimoLogin))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<UsuarioListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Application.Usuarios.List;
|
||||
|
||||
public sealed record UsuarioListItemDto(
|
||||
int Id,
|
||||
string Username,
|
||||
string Nombre,
|
||||
string Apellido,
|
||||
string? Email,
|
||||
string Rol,
|
||||
bool Activo,
|
||||
DateTime? UltimoLogin
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.Reactivate;
|
||||
|
||||
public sealed record ReactivateUsuarioCommand(int UsuarioId);
|
||||
@@ -0,0 +1,45 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Reactivate;
|
||||
|
||||
public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
|
||||
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> Handle(ReactivateUsuarioCommand cmd)
|
||||
{
|
||||
var target = await _repository.GetByIdAsync(cmd.UsuarioId)
|
||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||
|
||||
// Idempotent: already active → return as-is without touching FechaModificacion
|
||||
if (target.Activo)
|
||||
{
|
||||
return new UsuarioDetailDto(
|
||||
target.Id, target.Username, target.Nombre, target.Apellido,
|
||||
target.Email, target.Rol, target.Activo, target.MustChangePassword,
|
||||
target.UltimoLogin, target.FechaModificacion);
|
||||
}
|
||||
|
||||
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, true);
|
||||
var now = DateTime.UtcNow;
|
||||
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
|
||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||
|
||||
return new UsuarioDetailDto(
|
||||
updated.Id, updated.Username, updated.Nombre, updated.Apellido,
|
||||
updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword,
|
||||
updated.UltimoLogin, updated.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.ResetPassword;
|
||||
|
||||
public sealed record ResetUsuarioPasswordCommand(int TargetId, int CallerId);
|
||||
@@ -0,0 +1,44 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.ResetPassword;
|
||||
|
||||
public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IPasswordHasher _hasher;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
|
||||
public ResetUsuarioPasswordCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IPasswordHasher hasher,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
}
|
||||
|
||||
public async Task<ResetUsuarioPasswordResponse> Handle(ResetUsuarioPasswordCommand cmd)
|
||||
{
|
||||
// Cannot self-reset: admin must use /me/password
|
||||
if (cmd.CallerId == cmd.TargetId)
|
||||
throw new CannotSelfResetException();
|
||||
|
||||
var target = await _repository.GetByIdAsync(cmd.TargetId)
|
||||
?? throw new UsuarioNotFoundException(cmd.TargetId);
|
||||
|
||||
var temp = TempPasswordGenerator.Generate(12);
|
||||
// SECURITY: NEVER log tempPassword
|
||||
var hash = _hasher.Hash(temp);
|
||||
|
||||
await _repository.UpdatePasswordAsync(cmd.TargetId, hash, mustChangePassword: true);
|
||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.TargetId, DateTime.UtcNow);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
return new ResetUsuarioPasswordResponse(temp, MustChangeOnLogin: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Usuarios.ResetPassword;
|
||||
|
||||
public sealed record ResetUsuarioPasswordResponse(
|
||||
string TempPassword,
|
||||
bool MustChangeOnLogin
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Usuarios.Update;
|
||||
|
||||
public sealed record UpdateUsuarioCommand(
|
||||
int Id,
|
||||
string Nombre,
|
||||
string Apellido,
|
||||
string? Email,
|
||||
string Rol,
|
||||
bool Activo
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Update;
|
||||
|
||||
public sealed class UpdateUsuarioCommandHandler : ICommandHandler<UpdateUsuarioCommand, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IRolRepository _rolRepository;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
|
||||
public UpdateUsuarioCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IRolRepository rolRepository,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
{
|
||||
_repository = repository;
|
||||
_rolRepository = rolRepository;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> 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 = DateTime.UtcNow;
|
||||
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);
|
||||
}
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Update;
|
||||
|
||||
public sealed class UpdateUsuarioCommandValidator : AbstractValidator<UpdateUsuarioCommand>
|
||||
{
|
||||
public UpdateUsuarioCommandValidator(IRolRepository rolRepository)
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.EmailAddress()
|
||||
.When(x => x.Email is not null)
|
||||
.WithMessage("El formato del email es inválido.");
|
||||
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty()
|
||||
.WithMessage("El nombre es requerido.");
|
||||
|
||||
RuleFor(x => x.Apellido)
|
||||
.NotEmpty()
|
||||
.WithMessage("El apellido es requerido.");
|
||||
|
||||
RuleFor(x => x.Rol)
|
||||
.NotEmpty()
|
||||
.WithMessage("El rol es requerido.")
|
||||
.MustAsync(async (rol, ct) => await rolRepository.ExistsActiveByCodigoAsync(rol, ct))
|
||||
.WithMessage(x => $"El rol '{x.Rol}' no existe o está inactivo.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user