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 static readonly HashSet _allowedMimeTypes = new() { "application/pdf", "image/jpeg", "image/png" }; public DigitizationBatchesController(IBatchService batches, IDocumentStorageService storage) { _batches = batches; _storage = storage; } /// /// Uploads a scanned document and creates a new digitization batch. /// [HttpPost] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] [RequestSizeLimit(25 * 1024 * 1024)] public async Task Create( IFormFile file, [FromForm] CreateBatchRequest req) { if (file is null || file.Length == 0) return BadRequest(ApiResponse.Fail(400, "File is required.", "EMPTY_FILE")); if (!_allowedMimeTypes.Contains(file.ContentType)) return BadRequest(ApiResponse.Fail(400, "Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE")); var parsedBatchType = BatchTypeExtensions.FromDbString(req.BatchType.ToUpperInvariant()); var parsedTrack = string.IsNullOrEmpty(req.Track) ? BatchTrack.Backfill : BatchTrackExtensions.FromDbString(req.Track.ToUpperInvariant()); var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); using var stream = file.OpenReadStream(); var batch = await _batches.CreateAsync( stream, file.ContentType, parsedBatchType, parsedTrack, req.PatientId, actorUserId); return StatusCode(201, ApiResponse.Created(BatchDetailResponse.FromEntity(batch))); } /// /// 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.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)] 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 { items = result.Items, page = result.Page, pageSize = result.PageSize, totalCount = result.TotalCount, totalPages = 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.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))); } }