using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; /// /// Approval and promotion of verified digitization batches to VigilCareClinical live tables. /// [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 _logger; public ApprovalController( IPromotionService promotion, AppDbContext db, IOptions retryOptions, ILogger logger) { _promotion = promotion; _db = db; _retryOptions = retryOptions.Value; _logger = logger; } /// /// 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. /// /// The batch ID to approve. /// Optional request body with alert configuration. /// Promotion result with live IDs for patient, encounter, and observations. [HttpPost("{id:guid}/approve")] [Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status202Accepted)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] public async Task Approve(Guid id, [FromBody] ApproveRequest? request) { var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(idempotencyKey)) return BadRequest(ApiResponse.Fail(400, "Idempotency-Key header is required for promotion operations.", "MISSING_IDEMPOTENCY_KEY")); if (idempotencyKey.Length > 100) return BadRequest(ApiResponse.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.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); } } /// /// Returns the promotion result for an already-promoted batch, including the live IDs /// for Patient, Encounter, and Observations that were created during promotion. /// /// The batch ID to query. /// Promotion result with live clinical entity IDs. [HttpGet("{id:guid}/promotion-result")] [Authorize] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] public async Task GetPromotionResult(Guid id) { var result = await _promotion.GetPromotionResultAsync(id); return Ok(ApiResponse.Ok(result)); } private async Task 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.Fail( 202, "Batch approved but promotion deferred due to infrastructure issue. " + "Automatic retry has been scheduled.", "PROMOTION_DEFERRED")); } }