using System.Text.Json; using Microsoft.EntityFrameworkCore; public class DraftService : IDraftService { private readonly AppDbContext _db; private readonly ILogger _logger; private static readonly HashSet _entryAllowedStatuses = new() { BatchStatus.Uploaded, BatchStatus.InEntry, BatchStatus.Rejected }; public DraftService(AppDbContext db, ILogger logger) { _db = db; _logger = logger; } public async Task GetDraftAsync(Guid batchId) { var batch = await _db.DigitizationBatches .AsNoTracking() .Include(b => b.DraftPatient) .Include(b => b.DraftEncounter) .Include(b => b.DraftObservations) .FirstOrDefaultAsync(b => b.Id == batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); var ocrResult = await _db.OcrResults .AsNoTracking() .FirstOrDefaultAsync(o => o.BatchId == batchId); OcrConfidenceMap? ocrConfidence = null; if (ocrResult is not null) { var fieldConfidences = JsonSerializer.Deserialize>( ocrResult.FieldConfidencesJson) ?? new Dictionary(); ocrConfidence = new OcrConfidenceMap( ocrResult.Provider, ocrResult.ProcessedAt, ocrResult.DurationMs, fieldConfidences); } return new DraftPayloadResponse( batch.Id, batch.Status.ToDbString(), batch.BatchType.ToDbString(), BatchTypeFieldRequirements.ForBatchType(batch.BatchType), ocrConfidence, batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null, batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null, batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList() ); } public async Task UpsertPatientAsync( Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId) { var batch = await LoadBatchForEntryAsync(batchId, actorUserId); TransitionToInEntryIfNeeded(batch, actorUserId); BloodType? parsedBloodType = null; if (req.BloodType is not null) { if (!BloodTypeExtensions.TryFromDbString(req.BloodType, out var parsed)) throw new ValidationException( $"Invalid blood type '{req.BloodType}'. Allowed values: A+, A-, B+, B-, AB+, AB-, O+, O-.", "INVALID_BLOOD_TYPE"); parsedBloodType = parsed; } var newAllergiesJson = req.Allergies is not null ? JsonSerializer.Serialize(req.Allergies) : null; var newMedicationsJson = req.Medications is not null ? JsonSerializer.Serialize(req.Medications) : null; var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId); if (patient is null) { patient = new DraftPatient { Id = Guid.NewGuid(), BatchId = batchId, FullName = req.FullName, DateOfBirth = req.DateOfBirth, Sex = req.Sex, BloodType = parsedBloodType, EmergencyContact = req.EmergencyContact, AllergiesJson = newAllergiesJson, NoKnownAllergies = req.NoKnownAllergies, MedicationsJson = newMedicationsJson, NoActiveMedications = req.NoActiveMedications, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; _db.DraftPatients.Add(patient); } else { var diffs = new List(); DiffField(diffs, "patient.fullName", patient.FullName, req.FullName); DiffField(diffs, "patient.dateOfBirth", patient.DateOfBirth?.ToString("yyyy-MM-dd"), req.DateOfBirth?.ToString("yyyy-MM-dd")); DiffField(diffs, "patient.sex", patient.Sex, req.Sex); DiffField(diffs, "patient.bloodType", patient.BloodType?.ToDbString(), parsedBloodType?.ToDbString()); DiffField(diffs, "patient.emergencyContact", patient.EmergencyContact, req.EmergencyContact); DiffField(diffs, "patient.allergiesJson", patient.AllergiesJson, newAllergiesJson); DiffField(diffs, "patient.noKnownAllergies", patient.NoKnownAllergies.ToString(), req.NoKnownAllergies.ToString()); DiffField(diffs, "patient.medicationsJson", patient.MedicationsJson, newMedicationsJson); DiffField(diffs, "patient.noActiveMedications", patient.NoActiveMedications.ToString(), req.NoActiveMedications.ToString()); if (diffs.Count > 0) await WriteDraftFieldEventAsync(batchId, actorUserId, "patient", diffs); patient.FullName = req.FullName; patient.DateOfBirth = req.DateOfBirth; patient.Sex = req.Sex; patient.BloodType = parsedBloodType; patient.EmergencyContact = req.EmergencyContact; patient.AllergiesJson = newAllergiesJson; patient.NoKnownAllergies = req.NoKnownAllergies; patient.MedicationsJson = newMedicationsJson; patient.NoActiveMedications = req.NoActiveMedications; patient.UpdatedAt = DateTimeOffset.UtcNow; } await _db.SaveChangesAsync(); _logger.LogInformation( "Draft patient upserted for batch {BatchId} by user {UserId}", batchId, actorUserId); return MapPatient(patient); } public async Task UpsertEncounterAsync( Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId) { var batch = await LoadBatchForEntryAsync(batchId, actorUserId); TransitionToInEntryIfNeeded(batch, actorUserId); Department? parsedDepartment = null; if (req.Department is not null) { if (!DepartmentExtensions.TryFromDbString(req.Department, out var parsed)) throw new ValidationException( $"Invalid department '{req.Department}'. Must be a recognized hospital department.", "INVALID_DEPARTMENT"); parsedDepartment = parsed; } var encounter = await _db.DraftEncounters.FirstOrDefaultAsync(e => e.BatchId == batchId); if (encounter is null) { encounter = new DraftEncounter { Id = Guid.NewGuid(), BatchId = batchId, AdmissionDate = req.AdmissionDate, Department = parsedDepartment, RoomBed = req.RoomBed, AdmissionReason = req.AdmissionReason, DischargeDiagnosis = req.DischargeDiagnosis, Status = req.Status, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; _db.DraftEncounters.Add(encounter); } else { var diffs = new List(); DiffField(diffs, "encounter.admissionDate", encounter.AdmissionDate?.ToString("O"), req.AdmissionDate?.ToString("O")); DiffField(diffs, "encounter.department", encounter.Department?.ToDbString(), parsedDepartment?.ToDbString()); DiffField(diffs, "encounter.roomBed", encounter.RoomBed, req.RoomBed); DiffField(diffs, "encounter.admissionReason", encounter.AdmissionReason, req.AdmissionReason); DiffField(diffs, "encounter.dischargeDiagnosis", encounter.DischargeDiagnosis, req.DischargeDiagnosis); DiffField(diffs, "encounter.status", encounter.Status, req.Status); if (diffs.Count > 0) await WriteDraftFieldEventAsync(batchId, actorUserId, "encounter", diffs); encounter.AdmissionDate = req.AdmissionDate; encounter.Department = parsedDepartment; encounter.RoomBed = req.RoomBed; encounter.AdmissionReason = req.AdmissionReason; encounter.DischargeDiagnosis = req.DischargeDiagnosis; encounter.Status = req.Status; encounter.UpdatedAt = DateTimeOffset.UtcNow; } await _db.SaveChangesAsync(); _logger.LogInformation( "Draft encounter upserted for batch {BatchId} by user {UserId}", batchId, actorUserId); return MapEncounter(encounter); } public async Task AddObservationAsync( Guid batchId, CreateDraftObservationRequest req, Guid actorUserId) { var batch = await LoadBatchForEntryAsync(batchId, actorUserId); TransitionToInEntryIfNeeded(batch, actorUserId); // Plausibility check — reject impossible values before persisting if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason)) throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); var observation = new DraftObservation { Id = Guid.NewGuid(), BatchId = batchId, ObservationCode = req.ObservationCode, Value = req.Value, Unit = req.Unit, RecordedAt = req.RecordedAt, Note = req.Note, CreatedAt = DateTimeOffset.UtcNow }; _db.DraftObservations.Add(observation); await _db.SaveChangesAsync(); _logger.LogInformation( "Draft observation {ObservationId} added to batch {BatchId} by user {UserId}", observation.Id, batchId, actorUserId); return MapObservation(observation); } public async Task UpdateObservationAsync( Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId) { var batch = await LoadBatchForEntryAsync(batchId, actorUserId); var observation = await _db.DraftObservations .FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId); if (observation is null) throw new NotFoundException( "Observation not found in this batch.", "OBSERVATION_NOT_FOUND"); // Re-validate plausibility on the new value if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason)) throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); var diffs = new List(); DiffField(diffs, "observationCode", observation.ObservationCode, req.ObservationCode); DiffField(diffs, "value", observation.Value.ToString(), req.Value.ToString()); DiffField(diffs, "unit", observation.Unit, req.Unit); DiffField(diffs, "recordedAt", observation.RecordedAt.ToString("O"), req.RecordedAt.ToString("O")); DiffField(diffs, "note", observation.Note, req.Note); if (diffs.Count > 0) await WriteDraftFieldEventAsync(batchId, actorUserId, $"observation:{observationId}", diffs); observation.ObservationCode = req.ObservationCode; observation.Value = req.Value; observation.Unit = req.Unit; observation.RecordedAt = req.RecordedAt; observation.Note = req.Note; await _db.SaveChangesAsync(); _logger.LogInformation( "Draft observation {ObservationId} updated in batch {BatchId} by user {UserId}", observationId, batchId, actorUserId); return MapObservation(observation); } public async Task DeleteObservationAsync( Guid batchId, Guid observationId, Guid actorUserId) { var batch = await LoadBatchForEntryAsync(batchId, actorUserId); var observation = await _db.DraftObservations .FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId); if (observation is null) throw new NotFoundException( "Observation not found in this batch.", "OBSERVATION_NOT_FOUND"); _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.DraftObservationDeleted, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow, MetadataJson = JsonSerializer.Serialize(new { observationId, observationCode = observation.ObservationCode, value = observation.Value, unit = observation.Unit, recordedAt = observation.RecordedAt }) }); _db.DraftObservations.Remove(observation); await _db.SaveChangesAsync(); _logger.LogInformation( "Draft observation {ObservationId} deleted from batch {BatchId} by user {UserId}", observationId, batchId, actorUserId); } public async Task SubmitForVerificationAsync( Guid batchId, Guid actorUserId) { var batch = await _db.DigitizationBatches .Include(b => b.DraftPatient) .Include(b => b.DraftEncounter) .Include(b => b.DraftObservations) .FirstOrDefaultAsync(b => b.Id == batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); if (batch.Status != BatchStatus.InEntry) throw new ConflictException( $"Only batches in 'in_entry' status can be submitted for verification. " + $"Current status: '{batch.Status.ToDbString()}'.", "ILLEGAL_STATUS_TRANSITION"); // Batch-type-specific completeness validation ValidateCompleteness(batch); // Transition to PendingVerification batch.Status = BatchStatus.PendingVerification; batch.UpdatedAt = DateTimeOffset.UtcNow; _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.SubmittedForVerification, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow, MetadataJson = JsonSerializer.Serialize(new { batchType = batch.BatchType.ToDbString(), observationCount = batch.DraftObservations.Count }) }); await _db.SaveChangesAsync(); _logger.LogInformation( "Batch {BatchId} submitted for verification by user {UserId}. " + "Type={BatchType}, ObservationCount={ObsCount}", batchId, actorUserId, batch.BatchType.ToDbString(), batch.DraftObservations.Count); return batch; } // ─── Private helpers ───────────────────────────────────────── /// /// Loads the batch and validates that its current status allows data entry. /// Enforces assignment: only the assigned entry clerk (enteredByUserId) or an /// administrator may save draft fields. Throws BATCH_NOT_ASSIGNED otherwise. /// private async Task LoadBatchForEntryAsync(Guid batchId, Guid actorUserId) { var batch = await _db.DigitizationBatches.FindAsync(batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); if (!_entryAllowedStatuses.Contains(batch.Status)) throw new ConflictException( $"Data entry is not allowed for batches in '{batch.Status.ToDbString()}' status. " + $"Allowed statuses: UPLOADED, IN_ENTRY, REJECTED.", "ENTRY_NOT_ALLOWED"); if (batch.EnteredByUserId.HasValue && batch.EnteredByUserId.Value != actorUserId) { var actor = await _db.Users.AsNoTracking() .FirstOrDefaultAsync(u => u.Id == actorUserId); if (actor?.Role != UserRole.Administrator) { throw new ConflictException( "This batch is assigned to another entry clerk.", "BATCH_NOT_ASSIGNED"); } } return batch; } private void TransitionToInEntryIfNeeded(DigitizationBatch batch, Guid actorUserId) { if (batch.Status == BatchStatus.InEntry) return; var previousStatus = batch.Status.ToDbString(); batch.Status = BatchStatus.InEntry; batch.UpdatedAt = DateTimeOffset.UtcNow; _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch.Id, EventType = DigitizationEventType.EntryStarted, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow, MetadataJson = JsonSerializer.Serialize(new { previousStatus, newStatus = "IN_ENTRY" }) }); _logger.LogInformation( "Batch {BatchId} transitioned from {PreviousStatus} to IN_ENTRY", batch.Id, previousStatus); } /// /// Validates batch-type-specific completeness rules. Throws ValidationException /// with a descriptive message listing all missing fields. /// private static void ValidateCompleteness(DigitizationBatch batch) { var errors = new List(); switch (batch.BatchType) { case BatchType.PatientRegistration: ValidatePatientRegistration(batch, errors); break; case BatchType.VitalsSheet: ValidateVitalsSheet(batch, errors); break; case BatchType.LabResults: ValidateLabResults(batch, errors); break; case BatchType.AllergyUpdate: ValidateAllergyUpdate(batch, errors); break; case BatchType.EncounterSummary: ValidateEncounterSummary(batch, errors); break; case BatchType.MedicationList: ValidateMedicationList(batch, errors); break; case BatchType.Mixed: ValidateMixed(batch, errors); break; } if (errors.Count > 0) { var message = $"Batch is incomplete for type '{batch.BatchType.ToDbString()}'. " + $"Missing: {string.Join("; ", errors)}."; throw new ValidationException(message, "BATCH_INCOMPLETE"); } } private static void ValidatePatientRegistration( DigitizationBatch batch, List errors) { // Required: Full name, date of birth, sex if (batch.DraftPatient is null) { errors.Add("Patient demographics are required"); return; } if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName)) errors.Add("Patient full name is required"); if (batch.DraftPatient.DateOfBirth is null) errors.Add("Patient date of birth is required"); if (string.IsNullOrWhiteSpace(batch.DraftPatient.Sex)) errors.Add("Patient sex is required"); } private static void ValidateVitalsSheet( DigitizationBatch batch, List errors) { // Required: Linked patient, encounter context, >= 1 observation with recordedAt if (batch.DraftPatient is null) errors.Add("Linked patient is required for vitals sheets"); if (batch.DraftEncounter is null) errors.Add("Encounter context is required for vitals sheets"); if (batch.DraftObservations.Count == 0) errors.Add("At least one observation with a recorded timestamp is required"); else if (batch.DraftObservations.Any(o => o.RecordedAt == default)) errors.Add("All observations must have a recorded timestamp"); } private static void ValidateLabResults( DigitizationBatch batch, List errors) { // Corrections inherit patient and encounter from the superseded promoted batch if (!batch.SupersedesBatchId.HasValue) { if (batch.DraftPatient is null) errors.Add("Linked patient is required for lab results"); if (batch.DraftEncounter is null) errors.Add("Encounter context is required for lab results"); } if (batch.DraftObservations.Count == 0) errors.Add("At least one lab observation is required"); else if (batch.DraftObservations.Any(o => o.RecordedAt == default)) errors.Add("All observations must have a recorded timestamp"); } private static void ValidateAllergyUpdate( DigitizationBatch batch, List errors) { // Required: Linked patient, allergies list (may be empty with noKnownAllergies: true) if (batch.DraftPatient is null) { errors.Add("Linked patient is required for allergy updates"); return; } var hasAllergies = !string.IsNullOrWhiteSpace(batch.DraftPatient.AllergiesJson); var hasNoKnownAllergiesFlag = batch.DraftPatient.NoKnownAllergies; // Either the allergies list must be present OR noKnownAllergies must be true if (!hasAllergies && !hasNoKnownAllergiesFlag) errors.Add("Allergies list is required (set noKnownAllergies to true if none)"); } private static void ValidateEncounterSummary( DigitizationBatch batch, List errors) { if (batch.DraftPatient is null) errors.Add("Linked patient is required for encounter summaries"); if (batch.DraftEncounter is null) { errors.Add("Encounter context is required for encounter summaries"); return; } if (batch.DraftEncounter.AdmissionDate is null) errors.Add("Admission date is required for encounter summaries"); if (batch.DraftEncounter.Department is null) errors.Add("Department is required for encounter summaries"); if (string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason)) errors.Add("Admission reason is required for encounter summaries"); } private static void ValidateMedicationList( DigitizationBatch batch, List errors) { if (batch.DraftPatient is null) { errors.Add("Linked patient is required for medication lists"); return; } var hasMedications = !string.IsNullOrWhiteSpace(batch.DraftPatient.MedicationsJson); if (!hasMedications && !batch.DraftPatient.NoActiveMedications) errors.Add("Medications list is required (set noActiveMedications to true if none)"); } private static void ValidateMixed( DigitizationBatch batch, List errors) { if (batch.DraftPatient is null) errors.Add("Linked patient is required for mixed batches"); if (batch.DraftEncounter is null) errors.Add("Encounter context is required for mixed batches"); var hasObservations = batch.DraftObservations.Count > 0 && batch.DraftObservations.All(o => o.RecordedAt != default); var hasEncounterSummary = batch.DraftEncounter is not null && batch.DraftEncounter.AdmissionDate is not null && batch.DraftEncounter.Department is not null && !string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason); if (!hasObservations && !hasEncounterSummary) errors.Add("Mixed batch requires at least one observation with recordedAt, or a complete encounter summary (admission date, department, admission reason)"); } // ─── Field-level audit helpers ──────────────────────────────── private static void DiffField(List diffs, string field, string? oldValue, string? newValue) { if (string.Equals(oldValue ?? "", newValue ?? "", StringComparison.Ordinal)) return; diffs.Add(new { field, oldValue = oldValue ?? "", newValue = newValue ?? "" }); } private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(30); /// /// Writes a DraftFieldUpdated event. If an event of the same type was written /// for this batch within the last 30 seconds, merges the new fields into the /// existing event's metadata to prevent audit noise from auto-save. /// private async Task WriteDraftFieldEventAsync( Guid batchId, Guid actorUserId, string section, List fieldsChanged) { var cutoff = DateTimeOffset.UtcNow.Add(-DebounceWindow); var recent = await _db.DigitizationEvents .Where(e => e.BatchId == batchId && e.EventType == DigitizationEventType.DraftFieldUpdated && e.ActorUserId == actorUserId && e.OccurredAt >= cutoff) .OrderByDescending(e => e.OccurredAt) .FirstOrDefaultAsync(); if (recent is not null) { var existing = !string.IsNullOrWhiteSpace(recent.MetadataJson) ? JsonSerializer.Deserialize>(recent.MetadataJson) : new Dictionary(); var existingChanges = existing!.TryGetValue("fieldsChanged", out var fc) ? JsonSerializer.Deserialize>(fc.GetRawText()) ?? new List() : new List(); existingChanges.AddRange(fieldsChanged); existing["section"] = JsonSerializer.SerializeToElement(section); existing["fieldsChanged"] = JsonSerializer.SerializeToElement(existingChanges); recent.MetadataJson = JsonSerializer.Serialize(existing); recent.OccurredAt = DateTimeOffset.UtcNow; } else { _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.DraftFieldUpdated, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow, MetadataJson = JsonSerializer.Serialize(new { section, fieldsChanged }) }); } } // ─── Mapping helpers ───────────────────────────────────────── private static DraftPatientDto MapPatient(DraftPatient p) => new( p.Id, p.FullName, p.DateOfBirth, p.Sex, p.BloodType?.ToDbString(), p.EmergencyContact, !string.IsNullOrWhiteSpace(p.AllergiesJson) ? JsonSerializer.Deserialize>(p.AllergiesJson) : null, p.NoKnownAllergies, !string.IsNullOrWhiteSpace(p.MedicationsJson) ? JsonSerializer.Deserialize>(p.MedicationsJson) : null, p.NoActiveMedications, p.UpdatedAt ); private static DraftEncounterDto MapEncounter(DraftEncounter e) => new( e.Id, e.AdmissionDate, e.Department?.ToDbString(), e.RoomBed, e.AdmissionReason, e.DischargeDiagnosis, e.Status, e.UpdatedAt ); private static DraftObservationDto MapObservation(DraftObservation o) => new( o.Id, o.ObservationCode, o.Value, o.Unit, o.RecordedAt, o.Note, o.CreatedAt ); }