using Microsoft.EntityFrameworkCore;
///
/// Provides filtered, sorted work queue views for each workflow stage.
/// All queues sort by UpdatedAt ASC to enforce FIFO processing — the oldest
/// pending item is always at the top of the queue.
///
public class WorkQueueService : IWorkQueueService
{
private readonly AppDbContext _db;
private readonly ILogger _logger;
public WorkQueueService(AppDbContext db, ILogger logger)
{
_db = db;
_logger = logger;
}
private static readonly HashSet _allowedSortFields = new(StringComparer.OrdinalIgnoreCase)
{
"createdAt", "updatedAt", "status", "batchType", "track"
};
public async Task GetVerificationQueueAsync(
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
{
var query = _db.DigitizationBatches
.Where(b => b.Status == BatchStatus.PendingVerification);
return await BuildQueueResponseAsync("verification", query, page, pageSize, sortBy, sortDirection);
}
public async Task GetEntryQueueAsync(
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
{
var entryStatuses = new[]
{
BatchStatus.InEntry,
BatchStatus.Rejected
};
var query = _db.DigitizationBatches
.Where(b => entryStatuses.Contains(b.Status));
return await BuildQueueResponseAsync("entry", query, page, pageSize, sortBy, sortDirection);
}
public async Task GetClinicalApprovalQueueAsync(
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
{
var query = _db.DigitizationBatches
.Where(b => b.Status == BatchStatus.AwaitingClinicalApproval);
return await BuildQueueResponseAsync("clinical-approval", query, page, pageSize, sortBy, sortDirection);
}
private static IOrderedQueryable ApplySort(
IQueryable 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 BuildQueueResponseAsync(
string queueName,
IQueryable query,
int page,
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 sorted = ApplySort(query, sortBy, sortDirection);
var items = await sorted
.Include(b => b.EnteredByUser)
.Include(b => b.Events)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(b => new WorkQueueItemResponse(
b.Id,
b.Status.ToDbString(),
b.BatchType.ToDbString(),
b.Track.ToDbString(),
b.PatientId,
b.EnteredByUserId,
b.EnteredByUser != null ? b.EnteredByUser.FullName : null,
b.RejectionReason,
b.CreatedAt,
b.UpdatedAt,
b.Events.Count
))
.ToListAsync();
return new WorkQueueResponse(queueName, items, page, pageSize, totalCount, totalPages);
}
public async Task GetOverviewAsync()
{
var now = DateTimeOffset.UtcNow;
var statusGroups = await _db.DigitizationBatches
.AsNoTracking()
.GroupBy(b => b.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync();
var statusCounts = new Dictionary();
foreach (var status in Enum.GetValues())
{
var count = statusGroups.FirstOrDefault(g => g.Status == status)?.Count ?? 0;
statusCounts[status.ToDbString()] = count;
}
var pendingBatches = await _db.DigitizationBatches
.AsNoTracking()
.Where(b => b.Status == BatchStatus.PendingVerification)
.Select(b => b.UpdatedAt)
.ToListAsync();
double avgTimeInQueueMinutes = 0;
double oldestPendingMinutes = 0;
if (pendingBatches.Count > 0)
{
var ages = pendingBatches
.Select(updatedAt => (now - updatedAt).TotalMinutes)
.ToList();
avgTimeInQueueMinutes = ages.Average();
oldestPendingMinutes = ages.Max();
}
var cutoff = now.AddHours(-24);
var recentEvents = await _db.DigitizationEvents
.AsNoTracking()
.Where(e => e.OccurredAt >= cutoff)
.Where(e => e.EventType == DigitizationEventType.Rejected
|| e.EventType == DigitizationEventType.VerificationFailed
|| e.EventType == DigitizationEventType.Verified
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
.GroupBy(e => e.EventType)
.Select(g => new { EventType = g.Key, Count = g.Count() })
.ToListAsync();
var rejections = recentEvents
.Where(e => e.EventType == DigitizationEventType.Rejected
|| e.EventType == DigitizationEventType.VerificationFailed)
.Sum(e => e.Count);
var verifications = recentEvents
.Where(e => e.EventType == DigitizationEventType.Verified
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
.Sum(e => e.Count);
var totalDecisions = rejections + verifications;
var rejectRate = totalDecisions > 0
? (double)rejections / totalDecisions
: 0.0;
_logger.LogDebug(
"Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
return new WorkQueueOverviewResponse(
StatusCounts: statusCounts,
AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1),
RejectRate: Math.Round(rejectRate, 4),
OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1));
}
}