feat(api): List + GetById usuarios — handlers, repo, endpoints [UDT-008]

This commit is contained in:
2026-04-15 17:46:23 -03:00
parent 9dcd63543e
commit 2925336783
29 changed files with 1210 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
public sealed record ChangeMyPasswordCommand(
int UsuarioId,
string OldPassword,
string NewPassword
);

View File

@@ -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;
}
}

View File

@@ -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.");
}
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.Deactivate;
public sealed record DeactivateUsuarioCommand(int UsuarioId);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.GetById;
public sealed record GetUsuarioByIdQuery(int Id);

View File

@@ -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
);
}
}

View File

@@ -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
);

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Usuarios.List;
public sealed record ListUsuariosQuery(
int Page,
int PageSize,
string? Rol,
bool? Activo,
string? Search
);

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.Reactivate;
public sealed record ReactivateUsuarioCommand(int UsuarioId);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.ResetPassword;
public sealed record ResetUsuarioPasswordCommand(int TargetId, int CallerId);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Usuarios.ResetPassword;
public sealed record ResetUsuarioPasswordResponse(
string TempPassword,
bool MustChangeOnLogin
);

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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.");
}
}