feat(api): MediosController + SeccionesController + ExceptionFilter mappings — ADM-001 B6
- POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/medios - POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/secciones - ExceptionFilter: add Medio/Seccion 404+409 mappings after RolInUseException - Integration tests: 19 scenarios covering 401/403/201/404/409/idempotency/AuditEvent - All 166 Api.Tests + 458 Application.Tests passing
This commit is contained in:
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Medios.Create;
|
||||
using SIGCM2.Application.Medios.Deactivate;
|
||||
using SIGCM2.Application.Medios.GetById;
|
||||
using SIGCM2.Application.Medios.List;
|
||||
using SIGCM2.Application.Medios.Reactivate;
|
||||
using SIGCM2.Application.Medios.Update;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// ADM-001: Medio management endpoints at /api/v1/admin/medios.
|
||||
/// All endpoints require permission 'administracion:medios:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/medios")]
|
||||
public sealed class MediosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateMedioCommand> _createValidator;
|
||||
private readonly IValidator<UpdateMedioCommand> _updateValidator;
|
||||
|
||||
public MediosController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateMedioCommand> createValidator,
|
||||
IValidator<UpdateMedioCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new medio. Requires administracion:medios:gestionar.</summary>
|
||||
[HttpPost]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(typeof(MedioCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> CreateMedio([FromBody] CreateMedioRequest request)
|
||||
{
|
||||
var command = new CreateMedioCommand(
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Tipo: request.Tipo ?? TipoMedio.Diario,
|
||||
PlataformaEmpresaId: request.PlataformaEmpresaId);
|
||||
|
||||
var validation = await _createValidator.ValidateAsync(command);
|
||||
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<CreateMedioCommand, MedioCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Lists medios with optional filters and pagination.</summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<MedioListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> ListMedios(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] bool? activo = null,
|
||||
[FromQuery] TipoMedio? tipo = null,
|
||||
[FromQuery] string? q = 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 ListMediosQuery(page, pageSize, activo, tipo, q);
|
||||
var result = await _dispatcher.Send<ListMediosQuery, PagedResult<MedioListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a single medio by id.</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetMedioById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetMedioByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetMedioByIdQuery, MedioDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a medio's editable fields.</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(typeof(MedioUpdatedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateMedio([FromRoute] int id, [FromBody] UpdateMedioRequest request)
|
||||
{
|
||||
var command = new UpdateMedioCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Tipo: request.Tipo ?? TipoMedio.Diario,
|
||||
PlataformaEmpresaId: request.PlataformaEmpresaId);
|
||||
|
||||
var validation = await _updateValidator.ValidateAsync(command);
|
||||
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<UpdateMedioCommand, MedioUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivates a medio (idempotent).</summary>
|
||||
[HttpPost("{id:int}/deactivate")]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeactivateMedio([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateMedioCommand(id);
|
||||
await _dispatcher.Send<DeactivateMedioCommand, MedioStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Reactivates a medio (idempotent).</summary>
|
||||
[HttpPost("{id:int}/reactivate")]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ReactivateMedio([FromRoute] int id)
|
||||
{
|
||||
var command = new ReactivateMedioCommand(id);
|
||||
await _dispatcher.Send<ReactivateMedioCommand, MedioStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>ADM-001: Create medio request body.</summary>
|
||||
public sealed record CreateMedioRequest(
|
||||
string? Codigo,
|
||||
string? Nombre,
|
||||
TipoMedio? Tipo,
|
||||
int? PlataformaEmpresaId);
|
||||
|
||||
/// <summary>ADM-001: Update medio request body.</summary>
|
||||
public sealed record UpdateMedioRequest(
|
||||
string? Nombre,
|
||||
TipoMedio? Tipo,
|
||||
int? PlataformaEmpresaId);
|
||||
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Secciones.Create;
|
||||
using SIGCM2.Application.Secciones.Deactivate;
|
||||
using SIGCM2.Application.Secciones.GetById;
|
||||
using SIGCM2.Application.Secciones.List;
|
||||
using SIGCM2.Application.Secciones.Reactivate;
|
||||
using SIGCM2.Application.Secciones.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones.
|
||||
/// All endpoints require permission 'administracion:secciones:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/secciones")]
|
||||
public sealed class SeccionesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateSeccionCommand> _createValidator;
|
||||
private readonly IValidator<UpdateSeccionCommand> _updateValidator;
|
||||
|
||||
public SeccionesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateSeccionCommand> createValidator,
|
||||
IValidator<UpdateSeccionCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new seccion. Requires administracion:secciones:gestionar.</summary>
|
||||
[HttpPost]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(typeof(SeccionCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> CreateSeccion([FromBody] CreateSeccionRequest request)
|
||||
{
|
||||
var command = new CreateSeccionCommand(
|
||||
MedioId: request.MedioId ?? 0,
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Tipo: request.Tipo ?? string.Empty);
|
||||
|
||||
var validation = await _createValidator.ValidateAsync(command);
|
||||
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<CreateSeccionCommand, SeccionCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Lists secciones with optional filters and pagination.</summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<SeccionListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> ListSecciones(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] int? medioId = null,
|
||||
[FromQuery] string? tipo = null,
|
||||
[FromQuery] bool? activo = null,
|
||||
[FromQuery] string? q = 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 ListSeccionesQuery(page, pageSize, medioId, tipo, activo, q);
|
||||
var result = await _dispatcher.Send<ListSeccionesQuery, PagedResult<SeccionListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a single seccion by id.</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSeccionById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetSeccionByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetSeccionByIdQuery, SeccionDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a seccion's editable fields.</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(typeof(SeccionUpdatedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateSeccion([FromRoute] int id, [FromBody] UpdateSeccionRequest request)
|
||||
{
|
||||
var command = new UpdateSeccionCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Tipo: request.Tipo ?? string.Empty);
|
||||
|
||||
var validation = await _updateValidator.ValidateAsync(command);
|
||||
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<UpdateSeccionCommand, SeccionUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivates a seccion (idempotent).</summary>
|
||||
[HttpPost("{id:int}/deactivate")]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeactivateSeccion([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateSeccionCommand(id);
|
||||
await _dispatcher.Send<DeactivateSeccionCommand, SeccionStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Reactivates a seccion (idempotent).</summary>
|
||||
[HttpPost("{id:int}/reactivate")]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ReactivateSeccion([FromRoute] int id)
|
||||
{
|
||||
var command = new ReactivateSeccionCommand(id);
|
||||
await _dispatcher.Send<ReactivateSeccionCommand, SeccionStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>ADM-001: Create seccion request body.</summary>
|
||||
public sealed record CreateSeccionRequest(
|
||||
int? MedioId,
|
||||
string? Codigo,
|
||||
string? Nombre,
|
||||
string? Tipo);
|
||||
|
||||
/// <summary>ADM-001: Update seccion request body.</summary>
|
||||
public sealed record UpdateSeccionRequest(
|
||||
string? Nombre,
|
||||
string? Tipo);
|
||||
@@ -169,6 +169,56 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-001: Medio exceptions
|
||||
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "medio_codigo_duplicado",
|
||||
message = medioCodDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case MedioNotFoundException medioNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "medio_not_found",
|
||||
message = medioNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-001: Seccion exceptions
|
||||
case SeccionCodigoDuplicadoEnMedioException seccionCodDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "seccion_codigo_duplicado_en_medio",
|
||||
message = seccionCodDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case SeccionNotFoundException seccionNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "seccion_not_found",
|
||||
message = seccionNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// UDT-009: permiso override validation errors
|
||||
case InvalidPermisoCodesException ipce:
|
||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
|
||||
Reference in New Issue
Block a user