feat(api): ChargeableCharConfigController + DI + ExceptionFilter integration (PRC-001)
This commit is contained in:
201
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
201
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
|
||||
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
|
||||
/// Route base: api/v1/admin/chargeable-chars
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/chargeable-chars")]
|
||||
public sealed class ChargeableCharConfigController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
|
||||
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
|
||||
|
||||
public ChargeableCharConfigController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateChargeableCharConfigCommand> createValidator,
|
||||
IValidator<SchedulePriceChangeCommand> scheduleValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_scheduleValidator = scheduleValidator;
|
||||
}
|
||||
|
||||
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated list of ChargeableCharConfig rows.
|
||||
/// Filters: medioId (optional, long?), activeOnly (bool, default true).
|
||||
/// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
|
||||
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] long? medioId,
|
||||
[FromQuery] bool activeOnly = true,
|
||||
[FromQuery] int? page = null,
|
||||
[FromQuery] int? pageSize = null,
|
||||
[FromQuery] int? skip = null,
|
||||
[FromQuery] int? take = null)
|
||||
{
|
||||
// Support both page/pageSize and skip/take query patterns
|
||||
int resolvedPage;
|
||||
int resolvedPageSize;
|
||||
|
||||
if (skip is not null || take is not null)
|
||||
{
|
||||
// Convert skip/take to page/pageSize
|
||||
resolvedPageSize = Math.Min(take ?? 50, 200);
|
||||
resolvedPage = resolvedPageSize > 0
|
||||
? ((skip ?? 0) / resolvedPageSize) + 1
|
||||
: 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPage = page ?? 1;
|
||||
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
|
||||
}
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize);
|
||||
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
|
||||
/// </summary>
|
||||
[HttpGet("{id:long}")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById([FromRoute] long id)
|
||||
{
|
||||
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
|
||||
new GetChargeableCharConfigByIdQuery(id));
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists.
|
||||
/// Returns 201 Created with Location header pointing to GET /{id}.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
|
||||
{
|
||||
var command = new CreateChargeableCharConfigCommand(
|
||||
request.MedioId,
|
||||
request.Symbol,
|
||||
request.Category,
|
||||
request.PricePerUnit,
|
||||
request.ValidFrom);
|
||||
|
||||
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<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a price change for an existing ChargeableCharConfig.
|
||||
/// Closes the current active row and opens a new one with the new price + ValidFrom.
|
||||
/// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only).
|
||||
/// </summary>
|
||||
[HttpPut("{id:long}/price")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> SchedulePriceChange(
|
||||
[FromRoute] long id,
|
||||
[FromBody] SchedulePriceChangeRequest request)
|
||||
{
|
||||
var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom);
|
||||
|
||||
var validation = await _scheduleValidator.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<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
|
||||
/// Idempotent: calling on an already-inactive row is a no-op.
|
||||
/// </summary>
|
||||
[HttpPatch("{id:long}/deactivate")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> Deactivate([FromRoute] long id)
|
||||
{
|
||||
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
|
||||
new DeactivateChargeableCharConfigCommand(id));
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
|
||||
public sealed record CreateChargeableCharConfigRequest(
|
||||
long? MedioId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom);
|
||||
|
||||
/// <summary>PRC-001: Schedule price change request body.</summary>
|
||||
public sealed record SchedulePriceChangeRequest(
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom);
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
using SIGCM2.Domain.Pricing.Exceptions;
|
||||
|
||||
namespace SIGCM2.Api.Filters;
|
||||
|
||||
@@ -645,6 +646,67 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// PRC-001: WordCounter + ChargeableCharConfig exceptions
|
||||
case EmojiDetectedException emojiEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "emoji_not_allowed",
|
||||
code = "EMOJI_NOT_ALLOWED",
|
||||
message = emojiEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case WordCountValidationException wordEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "word_count_validation",
|
||||
code = "WORD_COUNT_VALIDATION",
|
||||
field = wordEx.Field,
|
||||
reason = wordEx.Reason,
|
||||
message = wordEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ChargeableCharConfigInvalidException configInvalidEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "chargeable_char_invalid",
|
||||
code = "CHARGEABLE_CHAR_INVALID",
|
||||
field = configInvalidEx.Field,
|
||||
reason = configInvalidEx.Reason,
|
||||
message = configInvalidEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "chargeable_char_forward_only",
|
||||
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
|
||||
medioId = forwardOnlyCharEx.MedioId,
|
||||
symbol = forwardOnlyCharEx.Symbol,
|
||||
newValidFrom = forwardOnlyCharEx.NewValidFrom,
|
||||
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
|
||||
message = forwardOnlyCharEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ValidationException validationEx:
|
||||
var errors = validationEx.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
|
||||
Reference in New Issue
Block a user