using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; /// /// 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 readonly AppDbContext _db; private static readonly HashSet _allowedMimeTypes = new() { "application/pdf", "image/jpeg", "image/png" }; public DigitizationBatchesController( IBatchService batches, IDocumentStorageService storage, IPromotionService promotion, IBatchEventService batchEventService, AppDbContext db) { _batches = batches; _storage = storage; _promotion = promotion; _batchEventService = batchEventService; _db = db; } /// /// 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); var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var cutoff = DateTimeOffset.UtcNow.AddMinutes(-5); var recentAccess = await _db.DigitizationEvents.AnyAsync(e => e.BatchId == id && e.EventType == DigitizationEventType.DocumentAccessed && e.ActorUserId == userId && e.OccurredAt >= cutoff); if (!recentAccess) { _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = id, EventType = DigitizationEventType.DocumentAccessed, ActorUserId = userId, OccurredAt = DateTimeOffset.UtcNow }); await _db.SaveChangesAsync(); } 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)); } }