Do Pagination missing sortBy and sortDirection parameters

This commit is contained in:
voltsrage
2026-06-27 16:14:52 +08:00
parent 299e6e1453
commit 1c7e7fee7d
19 changed files with 2082 additions and 102 deletions
@@ -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()");
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);
+73 -6
View File
@@ -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.");
}
}