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
+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)