using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
///
/// Batch CRUD, document upload, and assignment for the digitization workflow.
///
[ApiController]
[Route("api/v1/digitization-batches")]
[Produces("application/json")]
[Authorize]
public class DigitizationBatchesController : ControllerBase
{
private readonly IBatchService _batches;
private readonly IDocumentStorageService _storage;
private readonly IPromotionService _promotion;
private readonly IBatchEventService _batchEventService;
private static readonly HashSet _allowedMimeTypes = new()
{
"application/pdf", "image/jpeg", "image/png"
};
public DigitizationBatchesController(
IBatchService batches,
IDocumentStorageService storage,
IPromotionService promotion,
IBatchEventService batchEventService)
{
_batches = batches;
_storage = storage;
_promotion = promotion;
_batchEventService = batchEventService;
}
///
/// Uploads a scanned document and creates a new digitization batch.
/// When supersedesBatchId is provided, the batch is treated as a correction
/// that will supersede the erroneous promoted batch upon its own promotion.
///
[HttpPost]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)]
[RequestSizeLimit(25 * 1024 * 1024)]
[Consumes("multipart/form-data")]
public async Task Create([FromForm] CreateBatchForm form)
{
if (form.File is null || form.File.Length == 0)
return BadRequest(ApiResponse.Fail(400, "File is required.", "EMPTY_FILE"));
if (!_allowedMimeTypes.Contains(form.File.ContentType))
return BadRequest(ApiResponse.Fail(400,
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
var parsedTrack = string.IsNullOrEmpty(form.Track)
? BatchTrack.Backfill
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
using var stream = form.File.OpenReadStream();
var result = await _batches.CreateAsync(
stream, form.File.ContentType, parsedBatchType, parsedTrack,
form.PatientId, form.SupersedesBatchId, actorUserId);
return StatusCode(201, ApiResponse.Created(
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession)));
}
///
/// Gets a batch by ID with a presigned document URL (15-minute expiry).
///
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
public async Task Get(Guid id)
{
var batch = await _batches.GetByIdAsync(id);
var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef);
return Ok(ApiResponse.Ok(
BatchDetailResponse.FromEntity(batch, presignedUrl)));
}
///
/// Lists batches with optional filters and pagination.
///
[HttpGet]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)]
public async Task List(
[FromQuery] string? status,
[FromQuery] string? batchType,
[FromQuery] Guid? assignedTo,
[FromQuery] string? track,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
BatchStatus? parsedStatus = string.IsNullOrEmpty(status) ? null : BatchStatusExtensions.FromDbString(status.ToUpperInvariant());
BatchType? parsedBatchType = string.IsNullOrEmpty(batchType) ? null : BatchTypeExtensions.FromDbString(batchType.ToUpperInvariant());
BatchTrack? parsedTrack = string.IsNullOrEmpty(track) ? null : BatchTrackExtensions.FromDbString(track.ToUpperInvariant());
var result = await _batches.ListAsync(parsedStatus, parsedBatchType, assignedTo, parsedTrack, page, pageSize);
return Ok(ApiResponse.Ok(new BatchListResponse(
result.Items.Select(b => BatchDetailResponse.FromEntity(b)).ToList(),
result.Page,
result.PageSize,
result.TotalCount,
result.TotalPages)));
}
///
/// Assigns a batch to an entry clerk. Uses Redis lock to prevent double-assignment.
///
[HttpPatch("{id:guid}/assign")]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)]
public async Task Assign(Guid id, [FromBody] AssignBatchRequest req)
{
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var batch = await _batches.AssignAsync(id, req.EntryClerkUserId, actorUserId);
return Ok(ApiResponse.Ok(BatchDetailResponse.FromEntity(batch)));
}
///
/// Promotes an approved batch to live clinical data. For correction batches,
/// marks the original batch's live observations as superseded (append-only).
///
[HttpPost("{id:guid}/promote")]
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)]
public async Task Promote(Guid id)
{
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var result = await _promotion.PromoteAsync(id, actorUserId);
return Ok(ApiResponse.Ok(result));
}
///
/// Returns cursor-paginated audit trail events for a batch.
/// Events are ordered chronologically (oldest first).
/// Pass the "after" parameter with the cursor from the previous page to paginate.
///
/// Batch ID.
/// Cursor: ISO-8601 timestamp from the previous page's nextCursor field.
/// Number of events per page. Default 50, max 200.
[HttpGet("{id:guid}/events")]
[Authorize(Roles = "ADMINISTRATOR,VERIFIER,CLINICAL_APPROVER")]
[ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
public async Task GetEvents(
Guid id,
[FromQuery] string? after = null,
[FromQuery] int pageSize = 50)
{
DateTimeOffset? afterCursor = null;
if (!string.IsNullOrWhiteSpace(after))
{
if (!DateTimeOffset.TryParse(after, out var parsed))
return BadRequest(ApiResponse.Fail(
400,
"Invalid cursor format. Expected ISO-8601 timestamp.",
"INVALID_CURSOR"));
afterCursor = parsed;
}
var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize);
return Ok(ApiResponse>.Ok(result));
}
}