190 lines
7.5 KiB
C#
190 lines
7.5 KiB
C#
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
/// <summary>
|
|
/// Approval and promotion of verified digitization batches to VigilCareClinical live tables.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/v1/digitization-batches")]
|
|
[Produces("application/json")]
|
|
[Authorize]
|
|
public class ApprovalController : ControllerBase
|
|
{
|
|
private readonly IPromotionService _promotion;
|
|
private readonly AppDbContext _db;
|
|
private readonly PromotionRetryOptions _retryOptions;
|
|
private readonly ILogger<ApprovalController> _logger;
|
|
|
|
public ApprovalController(
|
|
IPromotionService promotion,
|
|
AppDbContext db,
|
|
IOptions<PromotionRetryOptions> retryOptions,
|
|
ILogger<ApprovalController> logger)
|
|
{
|
|
_promotion = promotion;
|
|
_db = db;
|
|
_retryOptions = retryOptions.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Approves a verified or awaiting-clinical-approval batch and promotes its draft data
|
|
/// into live VigilCareClinical tables (Patient, Encounter, Observations).
|
|
///
|
|
/// Requires the Idempotency-Key header for safe retries. If the same key is resubmitted,
|
|
/// the original response is returned without re-executing the promotion.
|
|
///
|
|
/// If promotion fails due to infrastructure issues, the batch transitions to Approved status
|
|
/// and automatic retry is scheduled. Returns 202 Accepted with PROMOTION_DEFERRED.
|
|
///
|
|
/// Separation of duties: the approver cannot be the entry clerk or verifier of the same batch.
|
|
/// </summary>
|
|
/// <param name="id">The batch ID to approve.</param>
|
|
/// <param name="request">Optional request body with alert configuration.</param>
|
|
/// <returns>Promotion result with live IDs for patient, encounter, and observations.</returns>
|
|
[HttpPost("{id:guid}/approve")]
|
|
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
|
|
[ProducesResponseType(typeof(ApiResponse<PromotionResultResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status202Accepted)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
|
public async Task<IActionResult> Approve(Guid id, [FromBody] ApproveRequest? request)
|
|
{
|
|
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault();
|
|
|
|
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
|
return BadRequest(ApiResponse<object>.Fail(400,
|
|
"Idempotency-Key header is required for promotion operations.",
|
|
"MISSING_IDEMPOTENCY_KEY"));
|
|
|
|
if (idempotencyKey.Length > 100)
|
|
return BadRequest(ApiResponse<object>.Fail(400,
|
|
"Idempotency-Key must be at most 100 characters.",
|
|
"INVALID_IDEMPOTENCY_KEY"));
|
|
|
|
var approverUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
|
var enableRetroactiveAlerts = request?.EnableRetroactiveAlerts ?? false;
|
|
|
|
_logger.LogInformation(
|
|
"Approve request for batch {BatchId} by user {UserId} with idempotency key {Key}",
|
|
id, approverUserId, idempotencyKey);
|
|
|
|
try
|
|
{
|
|
var result = await _promotion.ApproveAndPromoteAsync(
|
|
id, approverUserId, enableRetroactiveAlerts, idempotencyKey);
|
|
|
|
return Ok(ApiResponse<PromotionResultResponse>.Ok(result));
|
|
}
|
|
catch (Exception ex) when (ex is not NotFoundException
|
|
&& ex is not ConflictException
|
|
&& ex is not ValidationException)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Promotion failed for batch {BatchId}, scheduling for retry", id);
|
|
|
|
return await DeferPromotionAsync(id, approverUserId, enableRetroactiveAlerts, ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the promotion result for an already-promoted batch, including the live IDs
|
|
/// for Patient, Encounter, and Observations that were created during promotion.
|
|
/// </summary>
|
|
/// <param name="id">The batch ID to query.</param>
|
|
/// <returns>Promotion result with live clinical entity IDs.</returns>
|
|
[HttpGet("{id:guid}/promotion-result")]
|
|
[Authorize]
|
|
[ProducesResponseType(typeof(ApiResponse<PromotionResultResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
public async Task<IActionResult> GetPromotionResult(Guid id)
|
|
{
|
|
var result = await _promotion.GetPromotionResultAsync(id);
|
|
return Ok(ApiResponse<PromotionResultResponse>.Ok(result));
|
|
}
|
|
|
|
private async Task<IActionResult> DeferPromotionAsync(
|
|
Guid batchId,
|
|
Guid approverUserId,
|
|
bool enableRetroactiveAlerts,
|
|
Exception ex)
|
|
{
|
|
var batch = await _db.DigitizationBatches.FindAsync(batchId);
|
|
if (batch is null)
|
|
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
|
|
|
if (batch.Status is not (BatchStatus.Verified or BatchStatus.AwaitingClinicalApproval))
|
|
{
|
|
throw new ConflictException(
|
|
$"Cannot defer promotion for batch in '{batch.Status.ToDbString()}' status.",
|
|
"ILLEGAL_STATUS_TRANSITION");
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
batch.Status = BatchStatus.Approved;
|
|
batch.ApprovedByUserId = approverUserId;
|
|
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
|
batch.UpdatedAt = now;
|
|
|
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
EventType = DigitizationEventType.Approved,
|
|
ActorUserId = approverUserId,
|
|
OccurredAt = now,
|
|
MetadataJson = JsonSerializer.Serialize(new
|
|
{
|
|
enableRetroactiveAlerts,
|
|
promotionDeferred = true
|
|
})
|
|
});
|
|
|
|
var nextRetryAt = now.Add(TimeSpan.FromSeconds(_retryOptions.InitialDelaySeconds));
|
|
var attempt = new PromotionAttempt
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
AttemptNumber = 1,
|
|
Succeeded = false,
|
|
ErrorMessage = ex.Message.Length > 2000
|
|
? ex.Message[..2000]
|
|
: ex.Message,
|
|
AttemptedAt = now,
|
|
NextRetryAt = nextRetryAt
|
|
};
|
|
|
|
_db.PromotionAttempts.Add(attempt);
|
|
|
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
EventType = DigitizationEventType.PromotionFailed,
|
|
ActorUserId = approverUserId,
|
|
OccurredAt = now,
|
|
MetadataJson = JsonSerializer.Serialize(new
|
|
{
|
|
error = ex.Message,
|
|
nextRetryAt = nextRetryAt.ToString("o"),
|
|
scheduledForRetry = true
|
|
})
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
return StatusCode(202, ApiResponse<object>.Fail(
|
|
202,
|
|
"Batch approved but promotion deferred due to infrastructure issue. " +
|
|
"Automatic retry has been scheduled.",
|
|
"PROMOTION_DEFERRED"));
|
|
}
|
|
}
|