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 ICoverSheetService _coverSheets; 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, ICoverSheetService coverSheets) { _batches = batches; _storage = storage; _promotion = promotion; _batchEventService = batchEventService; _db = db; _coverSheets = coverSheets; } /// /// 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")); CoverSheet? coverSheet = null; BatchType parsedBatchType; BatchTrack parsedTrack; if (!string.IsNullOrWhiteSpace(form.CoverSheetCode)) { coverSheet = await _coverSheets.LookupByCodeAsync(form.CoverSheetCode); if (coverSheet is null) return NotFound(ApiResponse.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND")); if (coverSheet.IsUsed) return Conflict(ApiResponse.Fail(409, $"Cover sheet {coverSheet.Code} has already been used.", "COVER_SHEET_ALREADY_USED")); parsedBatchType = coverSheet.BatchType; parsedTrack = coverSheet.Track; if (coverSheet.PatientId.HasValue) form.PatientId ??= coverSheet.PatientId; } else { if (string.IsNullOrWhiteSpace(form.BatchType)) return BadRequest(ApiResponse.Fail(400, "BatchType is required when no cover sheet code is provided.", "MISSING_BATCH_TYPE")); parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant()); 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); var batch = result.Batch; if (coverSheet is not null) { await _coverSheets.RedeemAsync(coverSheet.Id, batch.Id); if (coverSheet.AssignToUserId.HasValue) batch = await _batches.AssignAsync(batch.Id, coverSheet.AssignToUserId.Value, actorUserId); } return StatusCode(201, ApiResponse.Created( BatchDetailResponse.FromEntity(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, [FromQuery] string sortBy = "createdAt", [FromQuery] string sortDirection = "desc") { 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, sortBy, sortDirection); 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))); } /// /// Cancels a batch permanently. Only batches in UPLOADED, IN_ENTRY, or REJECTED /// status can be cancelled. Admin only. /// [HttpPost("{id:guid}/cancel")] [Authorize(Roles = "ADMINISTRATOR")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] public async Task Cancel(Guid id, [FromBody] CancelBatchRequest req) { var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var batch = await _batches.CancelAsync(id, req.Reason, 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)); } }