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 static readonly HashSet _allowedMimeTypes = new() { "application/pdf", "image/jpeg", "image/png" }; public DigitizationBatchesController( IBatchService batches, IDocumentStorageService storage, IPromotionService promotion) { _batches = batches; _storage = storage; _promotion = promotion; } /// /// 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)); } }