Files
vigilcare-records/VigilCareRecordsAPI/Services/DraftService.cs
T

699 lines
27 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public class DraftService : IDraftService
{
private readonly AppDbContext _db;
private readonly ILogger<DraftService> _logger;
private static readonly HashSet<BatchStatus> _entryAllowedStatuses = new()
{
BatchStatus.Uploaded,
BatchStatus.InEntry,
BatchStatus.Rejected
};
public DraftService(AppDbContext db, ILogger<DraftService> logger)
{
_db = db;
_logger = logger;
}
public async Task<DraftPayloadResponse> 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");
return new DraftPayloadResponse(
batch.Id,
batch.Status.ToDbString(),
batch.BatchType.ToDbString(),
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
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<DraftPatientDto> 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<object>();
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<DraftEncounterDto> 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<object>();
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<DraftObservationDto> 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<DraftObservationDto> 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<object>();
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<DigitizationBatch> 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 ─────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private async Task<DigitizationBatch> 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);
}
/// <summary>
/// Validates batch-type-specific completeness rules. Throws ValidationException
/// with a descriptive message listing all missing fields.
/// </summary>
private static void ValidateCompleteness(DigitizationBatch batch)
{
var errors = new List<string>();
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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<object> 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);
/// <summary>
/// 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.
/// </summary>
private async Task WriteDraftFieldEventAsync(
Guid batchId, Guid actorUserId, string section, List<object> 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<Dictionary<string, JsonElement>>(recent.MetadataJson)
: new Dictionary<string, JsonElement>();
var existingChanges = existing!.TryGetValue("fieldsChanged", out var fc)
? JsonSerializer.Deserialize<List<object>>(fc.GetRawText()) ?? new List<object>()
: new List<object>();
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<List<string>>(p.AllergiesJson)
: null,
p.NoKnownAllergies,
!string.IsNullOrWhiteSpace(p.MedicationsJson)
? JsonSerializer.Deserialize<List<string>>(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
);
}