using System.Text.Json; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; public class BatchService : IBatchService { private static readonly Dictionary> _allowedTransitions = new() { [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.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; private readonly IDocumentStorageService _storage; private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; public BatchService(AppDbContext db, IDocumentStorageService storage, IConnectionMultiplexer redis, ILogger logger) { _db = db; _storage = storage; _redis = redis; _logger = logger; } public async Task CreateAsync( Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid? supersedesBatchId, Guid actorUserId) { // Supersession validation: target batch must exist and be in Promoted status DigitizationBatch? supersededBatch = null; if (supersedesBatchId.HasValue) { supersededBatch = await _db.DigitizationBatches .FirstOrDefaultAsync(b => b.Id == supersedesBatchId.Value); if (supersededBatch is null) throw new NotFoundException( $"Batch {supersedesBatchId.Value} not found.", "SUPERSEDED_BATCH_NOT_FOUND"); if (supersededBatch.Status != BatchStatus.Promoted) throw new ValidationException( "Only promoted batches can be superseded. " + $"Batch {supersedesBatchId.Value} is in '{supersededBatch.Status.ToDbString()}' status.", "SUPERSEDED_BATCH_NOT_PROMOTED"); // Prevent supersession chains: the target batch must not itself be a correction // that has already been superseded by another promoted correction var existingCorrection = await _db.DigitizationBatches .AnyAsync(b => b.SupersedesBatchId == supersedesBatchId.Value && b.Status == BatchStatus.Promoted); if (existingCorrection) throw new ConflictException( $"Batch {supersedesBatchId.Value} has already been superseded by a promoted correction. " + "Create a new correction against the latest promoted batch instead.", "BATCH_ALREADY_SUPERSEDED"); // Inherit patient context from the superseded batch patientId ??= supersededBatch.PatientId; } var batchId = Guid.NewGuid(); var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, batchId); // Duplicate detection: same SHA-256 for same patient within 24 hours if (patientId.HasValue) { // Atomic Redis guard prevents concurrent uploads from racing past the DB check var cache = _redis.GetDatabase(); var dedupKey = $"batch:dedup:{sha256}:{patientId.Value}"; var acquired = await cache.StringSetAsync(dedupKey, "1", TimeSpan.FromHours(24), When.NotExists); if (!acquired) throw new ConflictException( "A document with the same content was uploaded for this patient within the last 24 hours.", "DUPLICATE_DOCUMENT"); // DB fallback for dedup entries created before Redis guard was deployed var cutoff = DateTimeOffset.UtcNow.AddHours(-24); var duplicate = await _db.DigitizationBatches.AnyAsync(b => b.DocumentSha256 == sha256 && b.PatientId == patientId.Value && b.CreatedAt >= cutoff); if (duplicate) { await cache.KeyDeleteAsync(dedupKey); throw new ConflictException( "A document with the same content was uploaded for this patient within the last 24 hours.", "DUPLICATE_DOCUMENT"); } } var batch = new DigitizationBatch { Id = batchId, Status = BatchStatus.Uploaded, BatchType = batchType, Track = track, PatientId = patientId, DocumentRef = objectKey, DocumentSha256 = sha256, EnableRetroactiveAlerts = false, SupersedesBatchId = supersedesBatchId, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; var document = new ScannedDocument { Id = Guid.NewGuid(), BatchId = batchId, ObjectKey = objectKey, Sha256 = sha256, ContentType = contentType, FileSizeBytes = fileSize, UploadedAt = DateTimeOffset.UtcNow }; var eventType = supersedesBatchId.HasValue ? DigitizationEventType.CorrectionUploaded : DigitizationEventType.Uploaded; var eventMetadata = supersedesBatchId.HasValue ? JsonSerializer.Serialize(new { supersedesBatchId = supersedesBatchId.Value }) : null; var evt = new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = eventType, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow, MetadataJson = eventMetadata }; _db.DigitizationBatches.Add(batch); _db.ScannedDocuments.Add(document); _db.DigitizationEvents.Add(evt); await _db.SaveChangesAsync(); _logger.LogInformation( "Batch {BatchId} created (correction={IsCorrection}, supersedes={SupersedesBatchId})", batchId, supersedesBatchId.HasValue, supersedesBatchId); SupersessionInfo? supersession = null; if (supersededBatch is not null) { var obsCount = await _db.Observations .CountAsync(o => o.SourceBatchId == supersededBatch.Id); supersession = BatchDetailResponse.ToSupersessionInfo(supersededBatch, obsCount); } return new CreateBatchResult(batch, supersession); } public async Task GetByIdAsync(Guid id) { var batch = await _db.DigitizationBatches .Include(b => b.Document) .Include(b => b.DraftPatient) .Include(b => b.DraftEncounter) .Include(b => b.DraftObservations) .FirstOrDefaultAsync(b => b.Id == id); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); return batch; } private static readonly HashSet _allowedSortFields = new(StringComparer.OrdinalIgnoreCase) { "createdAt", "updatedAt", "status", "batchType", "track" }; public async Task> ListAsync( BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize, string sortBy = "createdAt", string sortDirection = "desc") { var query = _db.DigitizationBatches.AsQueryable(); if (status.HasValue) query = query.Where(b => b.Status == status.Value); if (batchType.HasValue) query = query.Where(b => b.BatchType == batchType.Value); if (assignedTo.HasValue) query = query.Where(b => b.EnteredByUserId == assignedTo.Value); 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 ordered = ApplySort(query, sortBy, sortDirection); var items = await ordered .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResult(items, page, pageSize, total); } 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.OrderByDescending(b => b.CreatedAt) }; } public async Task AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId) { var batch = await _db.DigitizationBatches.FindAsync(batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); if (batch.Status != BatchStatus.Uploaded) throw new ConflictException( "Only batches in 'uploaded' status can be assigned.", "ILLEGAL_STATUS_TRANSITION"); // Redis lock to prevent double-assignment var cache = _redis.GetDatabase(); var lockKey = $"batch:assign:{batchId}"; var acquired = await cache.StringSetAsync(lockKey, entryClerkUserId.ToString(), TimeSpan.FromHours(1), When.NotExists); if (!acquired) throw new ConflictException( "This batch is already assigned to another clerk.", "BATCH_ALREADY_ASSIGNED"); batch.EnteredByUserId = entryClerkUserId; batch.Status = BatchStatus.InEntry; batch.UpdatedAt = DateTimeOffset.UtcNow; _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.EntryStarted, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow, MetadataJson = JsonSerializer.Serialize(new { assignedTo = entryClerkUserId }) }); await _db.SaveChangesAsync(); return batch; } public async Task 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() : Array.Empty(); }