2026-04-15 10:47:48 -03:00
|
|
|
using FluentValidation;
|
|
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
2026-04-15 16:34:32 -03:00
|
|
|
using SIGCM2.Api.Authorization;
|
2026-04-15 10:47:48 -03:00
|
|
|
using SIGCM2.Application.Abstractions;
|
2026-04-15 17:46:23 -03:00
|
|
|
using SIGCM2.Application.Common;
|
2026-04-15 10:47:48 -03:00
|
|
|
using SIGCM2.Application.Usuarios.Create;
|
2026-04-15 17:46:23 -03:00
|
|
|
using SIGCM2.Application.Usuarios.Deactivate;
|
|
|
|
|
using SIGCM2.Application.Usuarios.GetById;
|
|
|
|
|
using SIGCM2.Application.Usuarios.List;
|
|
|
|
|
using SIGCM2.Application.Usuarios.Reactivate;
|
|
|
|
|
using SIGCM2.Application.Usuarios.Update;
|
|
|
|
|
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
|
|
|
|
using SIGCM2.Application.Usuarios.ResetPassword;
|
|
|
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
|
|
|
using System.Security.Claims;
|
2026-04-15 10:47:48 -03:00
|
|
|
|
|
|
|
|
namespace SIGCM2.Api.Controllers;
|
|
|
|
|
|
2026-04-15 17:46:23 -03:00
|
|
|
/// <summary>
|
|
|
|
|
/// UDT-001/UDT-008: Usuario management endpoints.
|
|
|
|
|
/// RequirePermission moved to method level to allow /me/password with [Authorize] only.
|
|
|
|
|
/// </summary>
|
2026-04-15 10:47:48 -03:00
|
|
|
[ApiController]
|
|
|
|
|
[Route("api/v1/users")]
|
|
|
|
|
public sealed class UsuariosController : ControllerBase
|
|
|
|
|
{
|
|
|
|
|
private readonly IDispatcher _dispatcher;
|
2026-04-15 17:46:23 -03:00
|
|
|
private readonly IValidator<CreateUsuarioCommand> _createValidator;
|
2026-04-15 10:47:48 -03:00
|
|
|
|
2026-04-15 17:46:23 -03:00
|
|
|
public UsuariosController(
|
|
|
|
|
IDispatcher dispatcher,
|
|
|
|
|
IValidator<CreateUsuarioCommand> createValidator)
|
2026-04-15 10:47:48 -03:00
|
|
|
{
|
|
|
|
|
_dispatcher = dispatcher;
|
2026-04-15 17:46:23 -03:00
|
|
|
_createValidator = createValidator;
|
2026-04-15 10:47:48 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 17:46:23 -03:00
|
|
|
/// <summary>Creates a new user. Requires administracion:usuarios:gestionar.</summary>
|
2026-04-15 10:47:48 -03:00
|
|
|
[HttpPost]
|
2026-04-15 17:46:23 -03:00
|
|
|
[RequirePermission("administracion:usuarios:gestionar")]
|
2026-04-15 10:47:48 -03:00
|
|
|
[ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
|
|
|
public async Task<IActionResult> CreateUsuario([FromBody] CreateUsuarioRequest request)
|
|
|
|
|
{
|
|
|
|
|
var command = new CreateUsuarioCommand(
|
|
|
|
|
Username: request.Username ?? string.Empty,
|
|
|
|
|
Password: request.Password ?? string.Empty,
|
|
|
|
|
Nombre: request.Nombre ?? string.Empty,
|
|
|
|
|
Apellido: request.Apellido ?? string.Empty,
|
|
|
|
|
Email: request.Email,
|
|
|
|
|
Rol: request.Rol ?? string.Empty);
|
|
|
|
|
|
2026-04-15 17:46:23 -03:00
|
|
|
var validation = await _createValidator.ValidateAsync(command);
|
2026-04-15 10:47:48 -03:00
|
|
|
if (!validation.IsValid)
|
|
|
|
|
{
|
|
|
|
|
var errors = validation.Errors
|
|
|
|
|
.GroupBy(e => e.PropertyName)
|
|
|
|
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
|
|
|
return BadRequest(new { errors });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result = await _dispatcher.Send<CreateUsuarioCommand, UsuarioCreatedDto>(command);
|
|
|
|
|
|
|
|
|
|
return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result);
|
|
|
|
|
}
|
2026-04-15 17:46:23 -03:00
|
|
|
|
|
|
|
|
/// <summary>Lists usuarios with optional filters and pagination.</summary>
|
|
|
|
|
[HttpGet]
|
|
|
|
|
[RequirePermission("administracion:usuarios:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(PagedResult<UsuarioListItemDto>), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
public async Task<IActionResult> ListUsuarios(
|
|
|
|
|
[FromQuery] int page = 1,
|
|
|
|
|
[FromQuery] int pageSize = 20,
|
|
|
|
|
[FromQuery] string? rol = null,
|
|
|
|
|
[FromQuery] bool? activo = null,
|
|
|
|
|
[FromQuery] string? search = null)
|
|
|
|
|
{
|
|
|
|
|
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
|
|
|
|
|
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
|
|
|
|
|
|
|
|
|
|
var query = new ListUsuariosQuery(page, pageSize, rol, activo, search);
|
|
|
|
|
var result = await _dispatcher.Send<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>(query);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Gets a single usuario by id.</summary>
|
|
|
|
|
[HttpGet("{id:int}")]
|
|
|
|
|
[RequirePermission("administracion:usuarios:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
public async Task<IActionResult> GetUsuarioById([FromRoute] int id)
|
|
|
|
|
{
|
|
|
|
|
var query = new GetUsuarioByIdQuery(id);
|
|
|
|
|
var result = await _dispatcher.Send<GetUsuarioByIdQuery, UsuarioDetailDto>(query);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Updates a usuario's editable fields.</summary>
|
|
|
|
|
[HttpPut("{id:int}")]
|
|
|
|
|
[RequirePermission("administracion:usuarios:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
public async Task<IActionResult> UpdateUsuario([FromRoute] int id, [FromBody] UpdateUsuarioRequest request)
|
|
|
|
|
{
|
|
|
|
|
var command = new UpdateUsuarioCommand(
|
|
|
|
|
Id: id,
|
|
|
|
|
Nombre: request.Nombre ?? string.Empty,
|
|
|
|
|
Apellido: request.Apellido ?? string.Empty,
|
|
|
|
|
Email: request.Email,
|
|
|
|
|
Rol: request.Rol ?? string.Empty,
|
|
|
|
|
Activo: request.Activo ?? true);
|
|
|
|
|
|
|
|
|
|
var result = await _dispatcher.Send<UpdateUsuarioCommand, UsuarioDetailDto>(command);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Deactivates a usuario (idempotent).</summary>
|
|
|
|
|
[HttpPatch("{id:int}/deactivate")]
|
|
|
|
|
[RequirePermission("administracion:usuarios:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
public async Task<IActionResult> DeactivateUsuario([FromRoute] int id)
|
|
|
|
|
{
|
|
|
|
|
var command = new DeactivateUsuarioCommand(id);
|
|
|
|
|
var result = await _dispatcher.Send<DeactivateUsuarioCommand, UsuarioDetailDto>(command);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Reactivates a usuario (idempotent).</summary>
|
|
|
|
|
[HttpPatch("{id:int}/reactivate")]
|
|
|
|
|
[RequirePermission("administracion:usuarios:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
public async Task<IActionResult> ReactivateUsuario([FromRoute] int id)
|
|
|
|
|
{
|
|
|
|
|
var command = new ReactivateUsuarioCommand(id);
|
|
|
|
|
var result = await _dispatcher.Send<ReactivateUsuarioCommand, UsuarioDetailDto>(command);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Changes the authenticated user's own password.
|
|
|
|
|
/// Declared BEFORE /{id:int} route to avoid routing ambiguity (though :int constraint handles it).
|
|
|
|
|
/// Requires only authentication (no specific permission).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpPut("me/password")]
|
|
|
|
|
[Authorize]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
public async Task<IActionResult> ChangeMyPassword([FromBody] ChangeMyPasswordRequest request)
|
|
|
|
|
{
|
|
|
|
|
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
|
|
|
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
|
|
|
?? throw new UnauthorizedAccessException();
|
|
|
|
|
|
|
|
|
|
var command = new ChangeMyPasswordCommand(
|
|
|
|
|
UsuarioId: int.Parse(sub),
|
|
|
|
|
OldPassword: request.OldPassword ?? string.Empty,
|
|
|
|
|
NewPassword: request.NewPassword ?? string.Empty);
|
|
|
|
|
|
|
|
|
|
await _dispatcher.Send<ChangeMyPasswordCommand, Unit>(command);
|
|
|
|
|
return NoContent();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Resets a usuario's password (admin only). Returns a one-time temp password.</summary>
|
|
|
|
|
[HttpPost("{id:int}/password/reset")]
|
|
|
|
|
[RequirePermission("administracion:usuarios:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(ResetUsuarioPasswordResponse), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
public async Task<IActionResult> ResetUsuarioPassword([FromRoute] int id)
|
|
|
|
|
{
|
|
|
|
|
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
|
|
|
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
|
|
|
?? throw new UnauthorizedAccessException();
|
|
|
|
|
|
|
|
|
|
var command = new ResetUsuarioPasswordCommand(
|
|
|
|
|
TargetId: id,
|
|
|
|
|
CallerId: int.Parse(sub));
|
|
|
|
|
|
|
|
|
|
var result = await _dispatcher.Send<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
2026-04-15 10:47:48 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 17:46:23 -03:00
|
|
|
// ── request body records ──────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-15 10:47:48 -03:00
|
|
|
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
|
|
|
|
|
public sealed record CreateUsuarioRequest(
|
|
|
|
|
string? Username,
|
|
|
|
|
string? Password,
|
|
|
|
|
string? Nombre,
|
|
|
|
|
string? Apellido,
|
|
|
|
|
string? Email,
|
|
|
|
|
string? Rol);
|
2026-04-15 17:46:23 -03:00
|
|
|
|
|
|
|
|
public sealed record UpdateUsuarioRequest(
|
|
|
|
|
string? Nombre,
|
|
|
|
|
string? Apellido,
|
|
|
|
|
string? Email,
|
|
|
|
|
string? Rol,
|
|
|
|
|
bool? Activo);
|
|
|
|
|
|
|
|
|
|
public sealed record ChangeMyPasswordRequest(
|
|
|
|
|
string? OldPassword,
|
|
|
|
|
string? NewPassword);
|