226 lines
8.6 KiB
C#
226 lines
8.6 KiB
C#
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();
|
|
|
|
DiagnosticsMetrics.RejectionTotal
|
|
.WithLabels("verification_failed")
|
|
.Inc();
|
|
|
|
_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();
|
|
|
|
var category = previousStatus == BatchStatus.AwaitingClinicalApproval
|
|
? "clinical_rejected"
|
|
: "verification_failed";
|
|
DiagnosticsMetrics.RejectionTotal
|
|
.WithLabels(category)
|
|
.Inc();
|
|
|
|
_logger.LogInformation(
|
|
"Batch {BatchId} rejected by {ActorUserId} from {PreviousStatus}: {Reason}",
|
|
batchId, actorUserId, previousStatus.ToDbString(), request.Reason);
|
|
|
|
return batch;
|
|
}
|
|
} |