Do Pagination missing sortBy and sortDirection parameters
This commit is contained in:
@@ -124,13 +124,15 @@ public class DigitizationBatchesController : ControllerBase
|
||||
[FromQuery] Guid? assignedTo,
|
||||
[FromQuery] string? track,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
[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);
|
||||
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,
|
||||
@@ -154,6 +156,23 @@ public class DigitizationBatchesController : ControllerBase
|
||||
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).
|
||||
|
||||
@@ -29,9 +29,11 @@ public class WorkQueueController : ControllerBase
|
||||
[ProducesResponseType(typeof(ApiResponse<WorkQueueResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetVerificationQueue(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string sortBy = "updatedAt",
|
||||
[FromQuery] string sortDirection = "asc")
|
||||
{
|
||||
var result = await _workQueue.GetVerificationQueueAsync(page, pageSize);
|
||||
var result = await _workQueue.GetVerificationQueueAsync(page, pageSize, sortBy, sortDirection);
|
||||
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
||||
}
|
||||
|
||||
@@ -46,9 +48,11 @@ public class WorkQueueController : ControllerBase
|
||||
[ProducesResponseType(typeof(ApiResponse<WorkQueueResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetEntryQueue(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string sortBy = "updatedAt",
|
||||
[FromQuery] string sortDirection = "asc")
|
||||
{
|
||||
var result = await _workQueue.GetEntryQueueAsync(page, pageSize);
|
||||
var result = await _workQueue.GetEntryQueueAsync(page, pageSize, sortBy, sortDirection);
|
||||
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
||||
}
|
||||
|
||||
@@ -63,9 +67,11 @@ public class WorkQueueController : ControllerBase
|
||||
[ProducesResponseType(typeof(ApiResponse<WorkQueueResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetClinicalApprovalQueue(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string sortBy = "updatedAt",
|
||||
[FromQuery] string sortDirection = "asc")
|
||||
{
|
||||
var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize);
|
||||
var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize, sortBy, sortDirection);
|
||||
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ public class DigitizationBatchConfiguration : IEntityTypeConfiguration<Digitizat
|
||||
builder.ToTable("digitization_batches", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_batches_status",
|
||||
"status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
|
||||
"status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED', 'CANCELLED')");
|
||||
t.HasCheckConstraint("chk_batches_batch_type",
|
||||
"batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
|
||||
t.HasCheckConstraint("chk_batches_track",
|
||||
|
||||
@@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration<Digitizat
|
||||
builder.ToTable("digitization_events", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_digitization_events_event_type",
|
||||
"event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
|
||||
"event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled')");
|
||||
});
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
Generated
+1461
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCancelledStatusAndEventTypes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events");
|
||||
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_batches_status",
|
||||
table: "digitization_batches");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events",
|
||||
sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled')");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_batches_status",
|
||||
table: "digitization_batches",
|
||||
sql: "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED', 'CANCELLED')");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events");
|
||||
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "chk_batches_status",
|
||||
table: "digitization_batches");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_digitization_events_event_type",
|
||||
table: "digitization_events",
|
||||
sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "chk_batches_status",
|
||||
table: "digitization_batches",
|
||||
sql: "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
|
||||
|
||||
t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
|
||||
t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED', 'CANCELLED')");
|
||||
|
||||
t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
|
||||
});
|
||||
@@ -397,7 +397,7 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
|
||||
b.ToTable("digitization_events", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
|
||||
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled')");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
public enum BatchStatus { Uploaded, InEntry, PendingVerification, Rejected, Verified, AwaitingClinicalApproval, Approved, Promoted }
|
||||
public enum BatchStatus { Uploaded, InEntry, PendingVerification, Rejected, Verified, AwaitingClinicalApproval, Approved, Promoted, Cancelled }
|
||||
|
||||
public static class BatchStatusExtensions
|
||||
{
|
||||
@@ -12,6 +12,7 @@ public static class BatchStatusExtensions
|
||||
BatchStatus.AwaitingClinicalApproval => "AWAITING_CLINICAL_APPROVAL",
|
||||
BatchStatus.Approved => "APPROVED",
|
||||
BatchStatus.Promoted => "PROMOTED",
|
||||
BatchStatus.Cancelled => "CANCELLED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
@@ -25,6 +26,7 @@ public static class BatchStatusExtensions
|
||||
"AWAITING_CLINICAL_APPROVAL" => BatchStatus.AwaitingClinicalApproval,
|
||||
"APPROVED" => BatchStatus.Approved,
|
||||
"PROMOTED" => BatchStatus.Promoted,
|
||||
"CANCELLED" => BatchStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown batch status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public enum DigitizationEventType
|
||||
PromotionRetryFailed,
|
||||
PromotionRetryExhausted,
|
||||
PromotionFailed,
|
||||
DocumentAccessed
|
||||
DocumentAccessed,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public static class DigitizationEventTypeExtensions
|
||||
@@ -46,6 +47,7 @@ public static class DigitizationEventTypeExtensions
|
||||
DigitizationEventType.PromotionRetryExhausted => "promotion_retry_exhausted",
|
||||
DigitizationEventType.PromotionFailed => "promotion_failed",
|
||||
DigitizationEventType.DocumentAccessed => "document_accessed",
|
||||
DigitizationEventType.Cancelled => "cancelled",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
@@ -71,6 +73,7 @@ public static class DigitizationEventTypeExtensions
|
||||
"promotion_retry_exhausted" => DigitizationEventType.PromotionRetryExhausted,
|
||||
"promotion_failed" => DigitizationEventType.PromotionFailed,
|
||||
"document_accessed" => DigitizationEventType.DocumentAccessed,
|
||||
"cancelled" => DigitizationEventType.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
public record CancelBatchRequest(string Reason);
|
||||
@@ -6,14 +6,15 @@ public class BatchService : IBatchService
|
||||
{
|
||||
private static readonly Dictionary<BatchStatus, HashSet<BatchStatus>> _allowedTransitions = new()
|
||||
{
|
||||
[BatchStatus.Uploaded] = new() { BatchStatus.InEntry },
|
||||
[BatchStatus.InEntry] = new() { BatchStatus.PendingVerification },
|
||||
[BatchStatus.Uploaded] = new() { BatchStatus.InEntry, BatchStatus.Cancelled },
|
||||
[BatchStatus.InEntry] = new() { BatchStatus.PendingVerification, BatchStatus.Cancelled },
|
||||
[BatchStatus.PendingVerification] = new() { BatchStatus.Verified, BatchStatus.Rejected, BatchStatus.AwaitingClinicalApproval },
|
||||
[BatchStatus.Rejected] = new() { BatchStatus.InEntry },
|
||||
[BatchStatus.Rejected] = new() { BatchStatus.InEntry, BatchStatus.Cancelled },
|
||||
[BatchStatus.Verified] = new() { BatchStatus.Approved },
|
||||
[BatchStatus.AwaitingClinicalApproval] = new() { BatchStatus.Approved, BatchStatus.Rejected },
|
||||
[BatchStatus.Approved] = new() { BatchStatus.Promoted },
|
||||
[BatchStatus.Promoted] = new(),
|
||||
[BatchStatus.Cancelled] = new(),
|
||||
};
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
@@ -179,9 +180,14 @@ public class BatchService : IBatchService
|
||||
return batch;
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> _allowedSortFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"createdAt", "updatedAt", "status", "batchType", "track"
|
||||
};
|
||||
|
||||
public async Task<PagedResult<DigitizationBatch>> ListAsync(
|
||||
BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track,
|
||||
int page, int pageSize)
|
||||
int page, int pageSize, string sortBy = "createdAt", string sortDirection = "desc")
|
||||
{
|
||||
var query = _db.DigitizationBatches.AsQueryable();
|
||||
|
||||
@@ -194,9 +200,14 @@ public class BatchService : IBatchService
|
||||
if (track.HasValue)
|
||||
query = query.Where(b => b.Track == track.Value);
|
||||
|
||||
if (!_allowedSortFields.Contains(sortBy))
|
||||
throw new ValidationException(
|
||||
$"Invalid sortBy field '{sortBy}'. Allowed: {string.Join(", ", _allowedSortFields)}.",
|
||||
"INVALID_SORT_FIELD");
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
var ordered = ApplySort(query, sortBy, sortDirection);
|
||||
var items = await ordered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
@@ -204,6 +215,22 @@ public class BatchService : IBatchService
|
||||
return new PagedResult<DigitizationBatch>(items, page, pageSize, total);
|
||||
}
|
||||
|
||||
private static IOrderedQueryable<DigitizationBatch> ApplySort(
|
||||
IQueryable<DigitizationBatch> query, string sortBy, string sortDirection)
|
||||
{
|
||||
var desc = sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return sortBy.ToLowerInvariant() switch
|
||||
{
|
||||
"createdat" => desc ? query.OrderByDescending(b => b.CreatedAt) : query.OrderBy(b => b.CreatedAt),
|
||||
"updatedat" => desc ? query.OrderByDescending(b => b.UpdatedAt) : query.OrderBy(b => b.UpdatedAt),
|
||||
"status" => desc ? query.OrderByDescending(b => b.Status) : query.OrderBy(b => b.Status),
|
||||
"batchtype" => desc ? query.OrderByDescending(b => b.BatchType) : query.OrderBy(b => b.BatchType),
|
||||
"track" => desc ? query.OrderByDescending(b => b.Track) : query.OrderBy(b => b.Track),
|
||||
_ => query.OrderByDescending(b => b.CreatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId)
|
||||
{
|
||||
var batch = await _db.DigitizationBatches.FindAsync(batchId);
|
||||
@@ -244,6 +271,46 @@ public class BatchService : IBatchService
|
||||
return batch;
|
||||
}
|
||||
|
||||
public async Task<DigitizationBatch> CancelAsync(Guid batchId, string reason, Guid actorUserId)
|
||||
{
|
||||
var batch = await _db.DigitizationBatches.FindAsync(batchId);
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
if (!_allowedTransitions.TryGetValue(batch.Status, out var allowed)
|
||||
|| !allowed.Contains(BatchStatus.Cancelled))
|
||||
throw new ConflictException(
|
||||
$"Batch in '{batch.Status.ToDbString()}' status cannot be cancelled.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
var previousStatus = batch.Status.ToDbString();
|
||||
batch.Status = BatchStatus.Cancelled;
|
||||
batch.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Cancelled,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
MetadataJson = JsonSerializer.Serialize(new { previousStatus, reason })
|
||||
});
|
||||
|
||||
// Release Redis assignment lock if one exists
|
||||
var cache = _redis.GetDatabase();
|
||||
var lockKey = $"batch:assign:{batchId}";
|
||||
await cache.KeyDeleteAsync(lockKey);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} cancelled by {ActorUserId} from status {PreviousStatus}. Reason: {Reason}",
|
||||
batchId, actorUserId, previousStatus, reason);
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
public BatchStatus[] GetAllowedTransitions(BatchStatus current) =>
|
||||
_allowedTransitions.TryGetValue(current, out var targets)
|
||||
? targets.ToArray()
|
||||
|
||||
@@ -14,9 +14,12 @@ public interface IBatchService
|
||||
Task<PagedResult<DigitizationBatch>> ListAsync(
|
||||
BatchStatus? status, BatchType? batchType,
|
||||
Guid? assignedTo, BatchTrack? track,
|
||||
int page, int pageSize);
|
||||
int page, int pageSize,
|
||||
string sortBy = "createdAt", string sortDirection = "desc");
|
||||
|
||||
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
|
||||
|
||||
Task<DigitizationBatch> CancelAsync(Guid batchId, string reason, Guid actorUserId);
|
||||
|
||||
BatchStatus[] GetAllowedTransitions(BatchStatus current);
|
||||
}
|
||||
@@ -8,22 +8,24 @@ public interface IWorkQueueService
|
||||
/// Returns batches in PendingVerification status, sorted by UpdatedAt ASC (oldest first).
|
||||
/// This is the verifier's work queue — the next batch to verify is always at the top.
|
||||
/// </summary>
|
||||
Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize);
|
||||
Task<WorkQueueResponse> GetVerificationQueueAsync(
|
||||
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc");
|
||||
|
||||
/// <summary>
|
||||
/// Returns batches that are awaiting or currently in data entry:
|
||||
/// - Status = Uploaded (awaiting assignment and entry)
|
||||
/// - Status = InEntry (currently being entered)
|
||||
/// - Status = Rejected (returned for re-entry after verification rejection)
|
||||
/// Sorted by UpdatedAt ASC so rejected batches surface for re-entry.
|
||||
/// </summary>
|
||||
Task<WorkQueueResponse> GetEntryQueueAsync(int page, int pageSize);
|
||||
Task<WorkQueueResponse> GetEntryQueueAsync(
|
||||
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc");
|
||||
|
||||
/// <summary>
|
||||
/// Returns batches in AwaitingClinicalApproval status, sorted by UpdatedAt ASC.
|
||||
/// This is the clinical approver's work queue.
|
||||
/// </summary>
|
||||
Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize);
|
||||
Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(
|
||||
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc");
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregate work queue health metrics for the supervisor dashboard.
|
||||
|
||||
@@ -17,50 +17,78 @@ public class WorkQueueService : IWorkQueueService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize)
|
||||
private static readonly HashSet<string> _allowedSortFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"createdAt", "updatedAt", "status", "batchType", "track"
|
||||
};
|
||||
|
||||
public async Task<WorkQueueResponse> GetVerificationQueueAsync(
|
||||
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
|
||||
{
|
||||
var query = _db.DigitizationBatches
|
||||
.Where(b => b.Status == BatchStatus.PendingVerification)
|
||||
.OrderBy(b => b.UpdatedAt);
|
||||
.Where(b => b.Status == BatchStatus.PendingVerification);
|
||||
|
||||
return await BuildQueueResponseAsync("verification", query, page, pageSize);
|
||||
return await BuildQueueResponseAsync("verification", query, page, pageSize, sortBy, sortDirection);
|
||||
}
|
||||
|
||||
public async Task<WorkQueueResponse> GetEntryQueueAsync(int page, int pageSize)
|
||||
public async Task<WorkQueueResponse> GetEntryQueueAsync(
|
||||
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
|
||||
{
|
||||
var entryStatuses = new[]
|
||||
{
|
||||
BatchStatus.Uploaded,
|
||||
BatchStatus.InEntry,
|
||||
BatchStatus.Rejected
|
||||
};
|
||||
|
||||
var query = _db.DigitizationBatches
|
||||
.Where(b => entryStatuses.Contains(b.Status))
|
||||
.OrderBy(b => b.UpdatedAt);
|
||||
.Where(b => entryStatuses.Contains(b.Status));
|
||||
|
||||
return await BuildQueueResponseAsync("entry", query, page, pageSize);
|
||||
return await BuildQueueResponseAsync("entry", query, page, pageSize, sortBy, sortDirection);
|
||||
}
|
||||
|
||||
public async Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize)
|
||||
public async Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(
|
||||
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
|
||||
{
|
||||
var query = _db.DigitizationBatches
|
||||
.Where(b => b.Status == BatchStatus.AwaitingClinicalApproval)
|
||||
.OrderBy(b => b.UpdatedAt);
|
||||
.Where(b => b.Status == BatchStatus.AwaitingClinicalApproval);
|
||||
|
||||
return await BuildQueueResponseAsync("clinical-approval", query, page, pageSize);
|
||||
return await BuildQueueResponseAsync("clinical-approval", query, page, pageSize, sortBy, sortDirection);
|
||||
}
|
||||
|
||||
private static IOrderedQueryable<DigitizationBatch> ApplySort(
|
||||
IQueryable<DigitizationBatch> query, string sortBy, string sortDirection)
|
||||
{
|
||||
var desc = sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return sortBy.ToLowerInvariant() switch
|
||||
{
|
||||
"createdat" => desc ? query.OrderByDescending(b => b.CreatedAt) : query.OrderBy(b => b.CreatedAt),
|
||||
"updatedat" => desc ? query.OrderByDescending(b => b.UpdatedAt) : query.OrderBy(b => b.UpdatedAt),
|
||||
"status" => desc ? query.OrderByDescending(b => b.Status) : query.OrderBy(b => b.Status),
|
||||
"batchtype" => desc ? query.OrderByDescending(b => b.BatchType) : query.OrderBy(b => b.BatchType),
|
||||
"track" => desc ? query.OrderByDescending(b => b.Track) : query.OrderBy(b => b.Track),
|
||||
_ => query.OrderBy(b => b.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<WorkQueueResponse> BuildQueueResponseAsync(
|
||||
string queueName,
|
||||
IOrderedQueryable<DigitizationBatch> query,
|
||||
IQueryable<DigitizationBatch> query,
|
||||
int page,
|
||||
int pageSize)
|
||||
int pageSize,
|
||||
string sortBy,
|
||||
string sortDirection)
|
||||
{
|
||||
if (!_allowedSortFields.Contains(sortBy))
|
||||
throw new ValidationException(
|
||||
$"Invalid sortBy field '{sortBy}'. Allowed: {string.Join(", ", _allowedSortFields)}.",
|
||||
"INVALID_SORT_FIELD");
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
||||
|
||||
var items = await query
|
||||
var sorted = ApplySort(query, sortBy, sortDirection);
|
||||
var items = await sorted
|
||||
.Include(b => b.EnteredByUser)
|
||||
.Include(b => b.Events)
|
||||
.Skip((page - 1) * pageSize)
|
||||
|
||||
@@ -233,3 +233,12 @@ public class ChangePasswordRequestValidator : AbstractValidator<ChangePasswordRe
|
||||
.Matches("[0-9]").WithMessage("Password must contain at least one digit.");
|
||||
}
|
||||
}
|
||||
|
||||
public class CancelBatchRequestValidator : AbstractValidator<CancelBatchRequest>
|
||||
{
|
||||
public CancelBatchRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MinimumLength(5)
|
||||
.WithMessage("Cancellation reason must be at least 5 characters.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user