using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; /// /// 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. /// public class VerificationService : IVerificationService { private readonly AppDbContext _db; private readonly SiteConfigOptions _siteConfig; private readonly ILogger _logger; public VerificationService( AppDbContext db, IOptions siteConfig, ILogger logger) { _db = db; _siteConfig = siteConfig.Value; _logger = logger; } public async Task 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 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; } }