268 lines
12 KiB
C#
268 lines
12 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
|
/// <summary>
|
|
/// Batch CRUD, document upload, and assignment for the digitization workflow.
|
|
/// </summary>
|
|
[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<string> _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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status201Created)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
|
[Consumes("multipart/form-data")]
|
|
public async Task<IActionResult> Create([FromForm] CreateBatchForm form)
|
|
{
|
|
if (form.File is null || form.File.Length == 0)
|
|
return BadRequest(ApiResponse<object>.Fail(400, "File is required.", "EMPTY_FILE"));
|
|
|
|
if (!_allowedMimeTypes.Contains(form.File.ContentType))
|
|
return BadRequest(ApiResponse<object>.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<object>.Fail(404,
|
|
"Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
|
|
|
if (coverSheet.IsUsed)
|
|
return Conflict(ApiResponse<object>.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<object>.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<BatchDetailResponse>.Created(
|
|
BatchDetailResponse.FromEntity(batch, supersession: result.Supersession)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a batch by ID with a presigned document URL (15-minute expiry).
|
|
/// </summary>
|
|
[HttpGet("{id:guid}")]
|
|
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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<BatchDetailResponse>.Ok(
|
|
BatchDetailResponse.FromEntity(batch, presignedUrl)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists batches with optional filters and pagination.
|
|
/// </summary>
|
|
[HttpGet]
|
|
[ProducesResponseType(typeof(ApiResponse<BatchListResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
|
public async Task<IActionResult> 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<BatchListResponse>.Ok(new BatchListResponse(
|
|
result.Items.Select(b => BatchDetailResponse.FromEntity(b)).ToList(),
|
|
result.Page,
|
|
result.PageSize,
|
|
result.TotalCount,
|
|
result.TotalPages)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigns a batch to an entry clerk. Uses Redis lock to prevent double-assignment.
|
|
/// </summary>
|
|
[HttpPatch("{id:guid}/assign")]
|
|
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
public async Task<IActionResult> 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<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancels a batch permanently. Only batches in UPLOADED, IN_ENTRY, or REJECTED
|
|
/// status can be cancelled. Admin only.
|
|
/// </summary>
|
|
[HttpPost("{id:guid}/cancel")]
|
|
[Authorize(Roles = "ADMINISTRATOR")]
|
|
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
|
public async Task<IActionResult> 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<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Promotes an approved batch to live clinical data. For correction batches,
|
|
/// marks the original batch's live observations as superseded (append-only).
|
|
/// </summary>
|
|
[HttpPost("{id:guid}/promote")]
|
|
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
|
|
[ProducesResponseType(typeof(ApiResponse<PromotionResult>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
|
public async Task<IActionResult> Promote(Guid id)
|
|
{
|
|
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
|
var result = await _promotion.PromoteAsync(id, actorUserId);
|
|
return Ok(ApiResponse<PromotionResult>.Ok(result));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="id">Batch ID.</param>
|
|
/// <param name="after">Cursor: ISO-8601 timestamp from the previous page's nextCursor field.</param>
|
|
/// <param name="pageSize">Number of events per page. Default 50, max 200.</param>
|
|
[HttpGet("{id:guid}/events")]
|
|
[Authorize(Roles = "ADMINISTRATOR,VERIFIER,CLINICAL_APPROVER")]
|
|
[ProducesResponseType(typeof(ApiResponse<CursorPagedResult<BatchEventResponse>>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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<object>.Fail(
|
|
400,
|
|
"Invalid cursor format. Expected ISO-8601 timestamp.",
|
|
"INVALID_CURSOR"));
|
|
|
|
afterCursor = parsed;
|
|
}
|
|
|
|
var result = await _batchEventService.GetEventsAsync(id, afterCursor, pageSize);
|
|
return Ok(ApiResponse<CursorPagedResult<BatchEventResponse>>.Ok(result));
|
|
}
|
|
} |