feature: Approval and Promotion to VigilCareClinical
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
/// <summary>
|
||||
/// Site-level configuration controlling workflow routing per batch type.
|
||||
/// ClinicalApprovalRequired maps BatchType DB strings to whether a clinical
|
||||
/// approver must sign off after verification before the batch can be approved.
|
||||
/// </summary>
|
||||
public class SiteConfigOptions
|
||||
{
|
||||
public const string Section = "SiteConfig";
|
||||
|
||||
/// <summary>
|
||||
/// Maps batch type DB string (e.g. "LAB_RESULTS") to whether clinical
|
||||
/// approval is required after verification. If a batch type is not listed,
|
||||
/// clinical approval is NOT required (defaults to false).
|
||||
/// </summary>
|
||||
public Dictionary<string, bool> ClinicalApprovalRequired { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given batch type requires clinical approval after
|
||||
/// verification. Batch types not present in the dictionary default to false.
|
||||
/// </summary>
|
||||
public bool RequiresClinicalApproval(BatchType batchType)
|
||||
{
|
||||
var key = batchType.ToDbString();
|
||||
return ClinicalApprovalRequired.TryGetValue(key, out var required) && required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Batch verification and rejection endpoints.
|
||||
/// Enforces separation of duties: the user who entered the data cannot verify
|
||||
/// or reject the same batch. All transitions write DigitizationEvents.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/digitization-batches")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class VerificationController : ControllerBase
|
||||
{
|
||||
private readonly IVerificationService _verification;
|
||||
|
||||
public VerificationController(IVerificationService verification)
|
||||
{
|
||||
_verification = verification;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a batch that is in PendingVerification status.
|
||||
/// Requires field-level checks. If passed is true, transitions to Verified
|
||||
/// or AwaitingClinicalApproval based on site configuration for the batch type.
|
||||
/// If passed is false, transitions to Rejected with field check errors as the reason.
|
||||
/// Returns 409 SEPARATION_OF_DUTIES_VIOLATION if the verifier is the same user
|
||||
/// who entered the data.
|
||||
/// </summary>
|
||||
/// <param name="id">The batch ID to verify.</param>
|
||||
/// <param name="request">Verification request with field checks and pass/fail.</param>
|
||||
[HttpPost("{id:guid}/verify")]
|
||||
[Authorize(Roles = "VERIFIER,CLINICAL_APPROVER,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> Verify(Guid id, [FromBody] VerifyBatchRequest request)
|
||||
{
|
||||
var verifierUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var batch = await _verification.VerifyAsync(id, request, verifierUserId);
|
||||
|
||||
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a batch that is in PendingVerification or AwaitingClinicalApproval status.
|
||||
/// Requires a rejection reason (minimum 10 characters). The batch returns to the
|
||||
/// entry work queue for re-entry by a data entry clerk.
|
||||
/// Returns 409 SEPARATION_OF_DUTIES_VIOLATION if the rejector is the same user
|
||||
/// who entered the data (for PendingVerification status only).
|
||||
/// </summary>
|
||||
/// <param name="id">The batch ID to reject.</param>
|
||||
/// <param name="request">Rejection request with required reason.</param>
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
[Authorize(Roles = "VERIFIER,CLINICAL_APPROVER,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> Reject(Guid id, [FromBody] RejectBatchRequest request)
|
||||
{
|
||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var batch = await _verification.RejectAsync(id, request, actorUserId);
|
||||
|
||||
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Work queue endpoints for each workflow stage.
|
||||
/// Each queue returns batches filtered by status and sorted by submission time ASC
|
||||
/// so the oldest pending item is always at the top.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/work-queue")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class WorkQueueController : ControllerBase
|
||||
{
|
||||
private readonly IWorkQueueService _workQueue;
|
||||
|
||||
public WorkQueueController(IWorkQueueService workQueue)
|
||||
{
|
||||
_workQueue = workQueue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns batches in PendingVerification status, sorted by submittedAt ASC.
|
||||
/// Verifiers use this queue to pick the next batch to verify.
|
||||
/// </summary>
|
||||
[HttpGet("verification")]
|
||||
[Authorize(Roles = "VERIFIER,CLINICAL_APPROVER,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<WorkQueueResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetVerificationQueue(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await _workQueue.GetVerificationQueueAsync(page, pageSize);
|
||||
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns batches awaiting or currently in data entry.
|
||||
/// Includes batches in Uploaded (awaiting assignment), InEntry (being entered),
|
||||
/// and Rejected (returned for re-entry) statuses.
|
||||
/// Data entry clerks use this queue to find their next assignment.
|
||||
/// </summary>
|
||||
[HttpGet("entry")]
|
||||
[Authorize(Roles = "DATA_ENTRY_CLERK,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<WorkQueueResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetEntryQueue(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await _workQueue.GetEntryQueueAsync(page, pageSize);
|
||||
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns batches in AwaitingClinicalApproval status, sorted by submittedAt ASC.
|
||||
/// Clinical approvers use this queue to find batches that need clinical sign-off
|
||||
/// after verification. Only batch types configured to require clinical approval
|
||||
/// in site config will appear here.
|
||||
/// </summary>
|
||||
[HttpGet("clinical-approval")]
|
||||
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<WorkQueueResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetClinicalApprovalQueue(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize);
|
||||
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ public static class DataSeeder
|
||||
{
|
||||
public static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
if (await db.Users.AnyAsync()) return;
|
||||
var existing = await db.Users.Select(u => u.Username).ToListAsync();
|
||||
if (existing.Count >= 12) return;
|
||||
|
||||
var users = new[]
|
||||
var all = new[]
|
||||
{
|
||||
CreateUser("intake1", "Intake Clerk 1", UserRole.IntakeClerk),
|
||||
CreateUser("intake2", "Intake Clerk 2", UserRole.IntakeClerk),
|
||||
@@ -22,7 +23,11 @@ public static class DataSeeder
|
||||
CreateUser("admin2", "Administrator 2", UserRole.Administrator),
|
||||
};
|
||||
|
||||
db.Users.AddRange(users);
|
||||
var existingSet = existing.ToHashSet();
|
||||
var missing = all.Where(u => !existingSet.Contains(u.Username)).ToArray();
|
||||
if (missing.Length == 0) return;
|
||||
|
||||
db.Users.AddRange(missing);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/// <summary>
|
||||
/// A single field-level verification check result.
|
||||
/// FieldName identifies the field (e.g. "patient.fullName", "observation.heartRate").
|
||||
/// Status is "ok", "warning", or "error".
|
||||
/// Note is an optional comment from the verifier.
|
||||
/// </summary>
|
||||
public record FieldCheck(
|
||||
string FieldName,
|
||||
string Status,
|
||||
string? Note
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <summary>
|
||||
/// Request body for POST /api/v1/digitization-batches/:id/reject.
|
||||
/// Reason is required — the verifier must explain why the batch was rejected.
|
||||
/// </summary>
|
||||
public record RejectBatchRequest(string Reason);
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <summary>
|
||||
/// Request body for POST /api/v1/digitization-batches/:id/verify.
|
||||
/// FieldChecks contains the verifier's field-level review results.
|
||||
/// Passed indicates whether the batch passed all verification checks.
|
||||
/// </summary>
|
||||
public record VerifyBatchRequest(
|
||||
List<FieldCheck> FieldChecks,
|
||||
bool Passed
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/// <summary>
|
||||
/// A single item in a work queue response. Contains enough information
|
||||
/// for the user to pick a batch without loading the full detail.
|
||||
/// </summary>
|
||||
public record WorkQueueItemResponse(
|
||||
Guid BatchId,
|
||||
string Status,
|
||||
string BatchType,
|
||||
string Track,
|
||||
Guid? PatientId,
|
||||
Guid? EnteredByUserId,
|
||||
string? EnteredByUserName,
|
||||
string? RejectionReason,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
int EventCount
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
/// <summary>
|
||||
/// Paginated work queue response with total count and queue metadata.
|
||||
/// </summary>
|
||||
public record WorkQueueResponse(
|
||||
string QueueName,
|
||||
IReadOnlyList<WorkQueueItemResponse> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalCount,
|
||||
int TotalPages
|
||||
);
|
||||
@@ -33,6 +33,10 @@ try
|
||||
.WithSSL(minioOptions.UseSsl)
|
||||
.Build());
|
||||
|
||||
// Site Configuration
|
||||
builder.Services.Configure<SiteConfigOptions>(
|
||||
builder.Configuration.GetSection(SiteConfigOptions.Section));
|
||||
|
||||
// JWT Authentication
|
||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
@@ -56,6 +60,8 @@ try
|
||||
builder.Services.AddScoped<IBatchService, BatchService>();
|
||||
builder.Services.AddScoped<IDraftService, DraftService>();
|
||||
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
|
||||
builder.Services.AddScoped<IVerificationService, VerificationService>();
|
||||
builder.Services.AddScoped<IWorkQueueService, WorkQueueService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/// <summary>
|
||||
/// Handles batch verification, rejection, and separation-of-duties enforcement.
|
||||
/// </summary>
|
||||
public interface IVerificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a batch that is in PendingVerification status.
|
||||
/// Enforces separation of duties: the verifier cannot be the same user who entered the data.
|
||||
/// On pass, transitions to Verified or AwaitingClinicalApproval based on site config.
|
||||
/// On fail (Passed = false), transitions to Rejected with field check notes as the reason.
|
||||
/// </summary>
|
||||
Task<DigitizationBatch> VerifyAsync(Guid batchId, VerifyBatchRequest request, Guid verifierUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a batch that is in PendingVerification or AwaitingClinicalApproval status.
|
||||
/// Stores the rejection reason on the batch and transitions status to Rejected.
|
||||
/// The batch returns to the entry work queue for re-entry by a data entry clerk.
|
||||
/// </summary>
|
||||
Task<DigitizationBatch> RejectAsync(Guid batchId, RejectBatchRequest request, Guid actorUserId);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <summary>
|
||||
/// Provides work queue views for each workflow stage.
|
||||
/// Each queue returns batches filtered by status and sorted by submission time ASC.
|
||||
/// </summary>
|
||||
public interface IWorkQueueService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Implements batch verification and rejection with separation-of-duties enforcement.
|
||||
/// Every status transition writes a DigitizationEvent with the actor, timestamp,
|
||||
/// and metadata including field-level checks where applicable.
|
||||
/// </summary>
|
||||
public class VerificationService : IVerificationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly SiteConfigOptions _siteConfig;
|
||||
private readonly ILogger<VerificationService> _logger;
|
||||
|
||||
public VerificationService(
|
||||
AppDbContext db,
|
||||
IOptions<SiteConfigOptions> siteConfig,
|
||||
ILogger<VerificationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_siteConfig = siteConfig.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DigitizationBatch> VerifyAsync(
|
||||
Guid batchId, VerifyBatchRequest request, Guid verifierUserId)
|
||||
{
|
||||
var batch = await _db.DigitizationBatches
|
||||
.Include(b => b.Events)
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
// --- Status guard ---
|
||||
if (batch.Status != BatchStatus.PendingVerification)
|
||||
throw new ConflictException(
|
||||
$"Batch is in '{batch.Status.ToDbString()}' status. " +
|
||||
"Only batches in 'PENDING_VERIFICATION' can be verified.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
// --- Separation of duties ---
|
||||
if (batch.EnteredByUserId == verifierUserId)
|
||||
throw new ConflictException(
|
||||
"The user who entered the data cannot verify the same batch. " +
|
||||
"Assign a different verifier.",
|
||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||
|
||||
// --- Validate request ---
|
||||
if (request.FieldChecks is null || request.FieldChecks.Count == 0)
|
||||
throw new ValidationException(
|
||||
"At least one field check is required.",
|
||||
"FIELD_CHECKS_REQUIRED");
|
||||
|
||||
var invalidStatuses = request.FieldChecks
|
||||
.Where(fc => fc.Status is not ("ok" or "warning" or "error"))
|
||||
.Select(fc => fc.FieldName)
|
||||
.ToList();
|
||||
|
||||
if (invalidStatuses.Count > 0)
|
||||
throw new ValidationException(
|
||||
$"Invalid status for fields: {string.Join(", ", invalidStatuses)}. " +
|
||||
"Allowed values: ok, warning, error.",
|
||||
"INVALID_FIELD_CHECK_STATUS");
|
||||
|
||||
// --- Build event metadata with field checks ---
|
||||
var metadata = new
|
||||
{
|
||||
fieldChecks = request.FieldChecks.Select(fc => new
|
||||
{
|
||||
fieldName = fc.FieldName,
|
||||
status = fc.Status,
|
||||
note = fc.Note
|
||||
}),
|
||||
passed = request.Passed,
|
||||
totalChecks = request.FieldChecks.Count,
|
||||
errorCount = request.FieldChecks.Count(fc => fc.Status == "error"),
|
||||
warningCount = request.FieldChecks.Count(fc => fc.Status == "warning")
|
||||
};
|
||||
|
||||
if (request.Passed)
|
||||
{
|
||||
// --- Determine target status based on site config ---
|
||||
var requiresClinical = _siteConfig.RequiresClinicalApproval(batch.BatchType);
|
||||
var targetStatus = requiresClinical
|
||||
? BatchStatus.AwaitingClinicalApproval
|
||||
: BatchStatus.Verified;
|
||||
|
||||
batch.Status = targetStatus;
|
||||
batch.VerifiedByUserId = verifierUserId;
|
||||
batch.RejectionReason = null; // Clear any previous rejection reason
|
||||
batch.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = requiresClinical ? DigitizationEventType.VerifiedPendingClinical : DigitizationEventType.Verified,
|
||||
ActorUserId = verifierUserId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata)
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} verified by {VerifierUserId} -> {TargetStatus}",
|
||||
batchId, verifierUserId, targetStatus.ToDbString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Verification failed — treat as rejection with field-level detail
|
||||
var fieldErrors = request.FieldChecks
|
||||
.Where(fc => fc.Status == "error")
|
||||
.Select(fc => $"{fc.FieldName}: {fc.Note ?? "failed check"}")
|
||||
.ToList();
|
||||
|
||||
var rejectionReason = fieldErrors.Count > 0
|
||||
? $"Verification failed. Errors: {string.Join("; ", fieldErrors)}"
|
||||
: "Verification failed. See field checks for details.";
|
||||
|
||||
batch.Status = BatchStatus.Rejected;
|
||||
batch.RejectionReason = rejectionReason;
|
||||
batch.VerifiedByUserId = null;
|
||||
batch.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.VerificationFailed,
|
||||
ActorUserId = verifierUserId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata)
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} verification failed by {VerifierUserId}, rejected",
|
||||
batchId, verifierUserId);
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
public async Task<DigitizationBatch> RejectAsync(
|
||||
Guid batchId, RejectBatchRequest request, Guid actorUserId)
|
||||
{
|
||||
var batch = await _db.DigitizationBatches
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
// --- Status guard: reject is allowed from PendingVerification or AwaitingClinicalApproval ---
|
||||
if (batch.Status is not (BatchStatus.PendingVerification or BatchStatus.AwaitingClinicalApproval))
|
||||
throw new ConflictException(
|
||||
$"Batch is in '{batch.Status.ToDbString()}' status. " +
|
||||
"Only batches in 'PENDING_VERIFICATION' or 'AWAITING_CLINICAL_APPROVAL' can be rejected.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
// --- Separation of duties for rejection from PendingVerification ---
|
||||
if (batch.Status == BatchStatus.PendingVerification && batch.EnteredByUserId == actorUserId)
|
||||
throw new ConflictException(
|
||||
"The user who entered the data cannot reject the same batch. " +
|
||||
"Assign a different verifier.",
|
||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||
|
||||
// --- Validate reason ---
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
throw new ValidationException(
|
||||
"Rejection reason is required.",
|
||||
"REJECTION_REASON_REQUIRED");
|
||||
|
||||
if (request.Reason.Length < 10)
|
||||
throw new ValidationException(
|
||||
"Rejection reason must be at least 10 characters.",
|
||||
"REJECTION_REASON_TOO_SHORT");
|
||||
|
||||
// --- Apply rejection ---
|
||||
var previousStatus = batch.Status;
|
||||
batch.Status = BatchStatus.Rejected;
|
||||
batch.RejectionReason = request.Reason;
|
||||
batch.VerifiedByUserId = null;
|
||||
batch.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var metadata = new
|
||||
{
|
||||
reason = request.Reason,
|
||||
previousStatus = previousStatus.ToDbString(),
|
||||
rejectedByUserId = actorUserId
|
||||
};
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Rejected,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata)
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} rejected by {ActorUserId} from {PreviousStatus}: {Reason}",
|
||||
batchId, actorUserId, previousStatus.ToDbString(), request.Reason);
|
||||
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class WorkQueueService : IWorkQueueService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public WorkQueueService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize)
|
||||
{
|
||||
var query = _db.DigitizationBatches
|
||||
.Where(b => b.Status == BatchStatus.PendingVerification)
|
||||
.OrderBy(b => b.UpdatedAt);
|
||||
|
||||
return await BuildQueueResponseAsync("verification", query, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<WorkQueueResponse> GetEntryQueueAsync(int page, int pageSize)
|
||||
{
|
||||
var entryStatuses = new[]
|
||||
{
|
||||
BatchStatus.Uploaded,
|
||||
BatchStatus.InEntry,
|
||||
BatchStatus.Rejected
|
||||
};
|
||||
|
||||
var query = _db.DigitizationBatches
|
||||
.Where(b => entryStatuses.Contains(b.Status))
|
||||
.OrderBy(b => b.UpdatedAt);
|
||||
|
||||
return await BuildQueueResponseAsync("entry", query, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize)
|
||||
{
|
||||
var query = _db.DigitizationBatches
|
||||
.Where(b => b.Status == BatchStatus.AwaitingClinicalApproval)
|
||||
.OrderBy(b => b.UpdatedAt);
|
||||
|
||||
return await BuildQueueResponseAsync("clinical-approval", query, page, pageSize);
|
||||
}
|
||||
|
||||
private async Task<WorkQueueResponse> BuildQueueResponseAsync(
|
||||
string queueName,
|
||||
IOrderedQueryable<DigitizationBatch> query,
|
||||
int page,
|
||||
int pageSize)
|
||||
{
|
||||
var totalCount = await query.CountAsync();
|
||||
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
||||
|
||||
var items = await query
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -49,5 +49,16 @@
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
}
|
||||
},
|
||||
"SiteConfig": {
|
||||
"ClinicalApprovalRequired": {
|
||||
"PATIENT_REGISTRATION": false,
|
||||
"ENCOUNTER_SUMMARY": true,
|
||||
"VITALS_SHEET": true,
|
||||
"LAB_RESULTS": true,
|
||||
"MEDICATION_LIST": true,
|
||||
"ALLERGY_UPDATE": false,
|
||||
"MIXED": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user