feature: Draft Data Entry
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Draft data entry endpoints for digitization batches. A data entry clerk
|
||||
/// uses these endpoints to transcribe scanned paper charts into structured data.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/digitization-batches/{batchId:guid}/draft")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class DraftController : ControllerBase
|
||||
{
|
||||
private readonly IDraftService _draft;
|
||||
|
||||
public DraftController(IDraftService draft) => _draft = draft;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full draft payload for a batch, including patient demographics,
|
||||
/// encounter context, and all observation rows entered so far.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <returns>Complete draft data for the batch.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftPayloadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetDraft(Guid batchId)
|
||||
{
|
||||
var result = await _draft.GetDraftAsync(batchId);
|
||||
return Ok(ApiResponse<DraftPayloadResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft patient demographics for a batch. Creates the patient record
|
||||
/// on the first call; updates it on subsequent calls. Automatically transitions
|
||||
/// the batch to IN_ENTRY on first save.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="req">Patient demographic fields.</param>
|
||||
/// <returns>The upserted patient record.</returns>
|
||||
[HttpPut("patient")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftPatientDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpsertPatient(
|
||||
Guid batchId, [FromBody] UpsertDraftPatientRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.UpsertPatientAsync(batchId, req, actorUserId);
|
||||
return Ok(ApiResponse<DraftPatientDto>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft encounter fields for a batch. Creates the encounter record
|
||||
/// on the first call; updates it on subsequent calls.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="req">Encounter context fields.</param>
|
||||
/// <returns>The upserted encounter record.</returns>
|
||||
[HttpPut("encounter")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftEncounterDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpsertEncounter(
|
||||
Guid batchId, [FromBody] UpsertDraftEncounterRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.UpsertEncounterAsync(batchId, req, actorUserId);
|
||||
return Ok(ApiResponse<DraftEncounterDto>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new observation row to the batch draft. Validates the value against
|
||||
/// plausibility ranges before saving.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="req">Observation data to add.</param>
|
||||
/// <returns>The created observation record.</returns>
|
||||
[HttpPost("observations")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftObservationDto>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> AddObservation(
|
||||
Guid batchId, [FromBody] CreateDraftObservationRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.AddObservationAsync(batchId, req, actorUserId);
|
||||
return StatusCode(201, ApiResponse<DraftObservationDto>.Created(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing observation row in the batch draft. Re-validates the
|
||||
/// new value against plausibility ranges.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="obsId">Observation id to update.</param>
|
||||
/// <param name="req">Updated observation data.</param>
|
||||
/// <returns>The updated observation record.</returns>
|
||||
[HttpPut("observations/{obsId:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DraftObservationDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateObservation(
|
||||
Guid batchId, Guid obsId, [FromBody] UpdateDraftObservationRequest req)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var result = await _draft.UpdateObservationAsync(batchId, obsId, req, actorUserId);
|
||||
return Ok(ApiResponse<DraftObservationDto>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an observation row from the batch draft.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <param name="obsId">Observation id to delete.</param>
|
||||
[HttpDelete("observations/{obsId:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DeleteObservation(Guid batchId, Guid obsId)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
await _draft.DeleteObservationAsync(batchId, obsId, actorUserId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates completeness per batch type and transitions the batch from
|
||||
/// IN_ENTRY to PENDING_VERIFICATION. Returns 422 if required fields are missing.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Digitization batch id.</param>
|
||||
/// <returns>The updated batch record.</returns>
|
||||
[HttpPost("~/api/v1/digitization-batches/{batchId:guid}/submit-for-verification")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> SubmitForVerification(Guid batchId)
|
||||
{
|
||||
var actorUserId = GetCurrentUserId();
|
||||
var batch = await _draft.SubmitForVerificationAsync(batchId, actorUserId);
|
||||
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
||||
}
|
||||
|
||||
private Guid GetCurrentUserId() =>
|
||||
Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public record DraftPayloadResponse(
|
||||
Guid BatchId,
|
||||
string Status,
|
||||
string BatchType,
|
||||
DraftPatientDto? Patient,
|
||||
DraftEncounterDto? Encounter,
|
||||
List<DraftObservationDto> Observations
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
public record DraftEncounterDto(
|
||||
Guid Id,
|
||||
DateTimeOffset? AdmissionDate,
|
||||
string? Department,
|
||||
string? RoomBed,
|
||||
string? AdmissionReason,
|
||||
string? DischargeDiagnosis,
|
||||
string? Status,
|
||||
DateTimeOffset UpdatedAt
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
public record UpsertDraftEncounterRequest(
|
||||
DateTimeOffset? AdmissionDate,
|
||||
string? Department,
|
||||
string? RoomBed,
|
||||
string? AdmissionReason,
|
||||
string? DischargeDiagnosis,
|
||||
string? Status
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record CreateDraftObservationRequest(
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? Note
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
public record DraftObservationDto(
|
||||
Guid Id,
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record UpdateDraftObservationRequest(
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? Note
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
public record DraftPatientDto(
|
||||
Guid Id,
|
||||
string? FullName,
|
||||
DateOnly? DateOfBirth,
|
||||
string? Sex,
|
||||
string? BloodType,
|
||||
string? EmergencyContact,
|
||||
List<string>? Allergies,
|
||||
bool NoKnownAllergies,
|
||||
List<string>? Medications,
|
||||
bool NoActiveMedications,
|
||||
DateTimeOffset UpdatedAt
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
public record UpsertDraftPatientRequest(
|
||||
string? FullName,
|
||||
DateOnly? DateOfBirth,
|
||||
string? Sex,
|
||||
string? BloodType,
|
||||
string? EmergencyContact,
|
||||
List<string>? Allergies,
|
||||
bool NoKnownAllergies,
|
||||
List<string>? Medications,
|
||||
bool NoActiveMedications
|
||||
);
|
||||
@@ -22,8 +22,8 @@ try
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Redis
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(
|
||||
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
ConnectionMultiplexer.Connect(sp.GetRequiredService<IConfiguration>()["Redis:ConnectionString"]!));
|
||||
|
||||
// MinIO
|
||||
var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get<MinioOptions>()!;
|
||||
@@ -54,6 +54,7 @@ try
|
||||
// Services
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IBatchService, BatchService>();
|
||||
builder.Services.AddScoped<IDraftService, DraftService>();
|
||||
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class DraftService : IDraftService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<DraftService> _logger;
|
||||
|
||||
// Statuses that allow data entry to begin or continue
|
||||
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(),
|
||||
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 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 = req.Allergies is not null
|
||||
? JsonSerializer.Serialize(req.Allergies)
|
||||
: null,
|
||||
NoKnownAllergies = req.NoKnownAllergies,
|
||||
MedicationsJson = req.Medications is not null
|
||||
? JsonSerializer.Serialize(req.Medications)
|
||||
: null,
|
||||
NoActiveMedications = req.NoActiveMedications,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.DraftPatients.Add(patient);
|
||||
}
|
||||
else
|
||||
{
|
||||
patient.FullName = req.FullName;
|
||||
patient.DateOfBirth = req.DateOfBirth;
|
||||
patient.Sex = req.Sex;
|
||||
patient.BloodType = parsedBloodType;
|
||||
patient.EmergencyContact = req.EmergencyContact;
|
||||
patient.AllergiesJson = req.Allergies is not null
|
||||
? JsonSerializer.Serialize(req.Allergies)
|
||||
: null;
|
||||
patient.NoKnownAllergies = req.NoKnownAllergies;
|
||||
patient.MedicationsJson = req.Medications is not null
|
||||
? JsonSerializer.Serialize(req.Medications)
|
||||
: null;
|
||||
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
|
||||
{
|
||||
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");
|
||||
|
||||
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.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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transitions the batch from Uploaded or Rejected to InEntry on first save.
|
||||
/// If the batch is already InEntry, this is a no-op.
|
||||
/// </summary>
|
||||
private void TransitionToInEntryIfNeeded(DigitizationBatch batch, Guid actorUserId)
|
||||
{
|
||||
if (batch.Status == BatchStatus.InEntry) return;
|
||||
|
||||
// batch.Status is Uploaded or Rejected (validated by LoadBatchForEntryAsync)
|
||||
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)
|
||||
{
|
||||
// Required: Linked patient, encounter, >= 1 lab observation code, recordedAt
|
||||
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)");
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
public interface IDraftService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the full draft payload for a batch, including patient, encounter,
|
||||
/// and all observation rows entered so far.
|
||||
/// </summary>
|
||||
Task<DraftPayloadResponse> GetDraftAsync(Guid batchId);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft patient demographics for a batch. Creates the DraftPatient
|
||||
/// row on the first call; updates it on subsequent calls. Transitions batch
|
||||
/// from Uploaded/Rejected to InEntry on first save.
|
||||
/// </summary>
|
||||
Task<DraftPatientDto> UpsertPatientAsync(Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft encounter fields for a batch. Creates the DraftEncounter
|
||||
/// row on the first call; updates it on subsequent calls.
|
||||
/// </summary>
|
||||
Task<DraftEncounterDto> UpsertEncounterAsync(Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new observation row to the batch draft. Validates plausibility
|
||||
/// before saving — implausible values throw ValidationException.
|
||||
/// </summary>
|
||||
Task<DraftObservationDto> AddObservationAsync(Guid batchId, CreateDraftObservationRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Edits an existing observation row. Re-validates plausibility on the new value.
|
||||
/// </summary>
|
||||
Task<DraftObservationDto> UpdateObservationAsync(Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an observation row from the batch draft.
|
||||
/// </summary>
|
||||
Task DeleteObservationAsync(Guid batchId, Guid observationId, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Validates completeness per batch type and transitions the batch to
|
||||
/// PendingVerification. Throws ValidationException if required fields
|
||||
/// are missing.
|
||||
/// </summary>
|
||||
Task<DigitizationBatch> SubmitForVerificationAsync(Guid batchId, Guid actorUserId);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
public static class PlausibilityValidator
|
||||
{
|
||||
// Plausible ranges define the outer boundary of physically possible values.
|
||||
// These are NOT clinical alert thresholds — they catch device malfunctions,
|
||||
// transcription errors, and misread handwriting from paper charts.
|
||||
// A heart rate of 300 is clinically extreme but not impossible during VT;
|
||||
// 400 is physically impossible and indicates a data entry mistake.
|
||||
private static readonly Dictionary<string, (decimal Min, decimal Max)> _ranges = new()
|
||||
{
|
||||
["HEART_RATE"] = (1, 300), // beats per minute; ceiling allows extreme tachycardia (e.g. VT)
|
||||
["TEMP_C"] = (15, 50), // core body temperature in °C
|
||||
["POTASSIUM_MEQ_L"] = (0.1m, 12), // serum potassium mEq/L; catches decimal misplacement (5.2 vs 52)
|
||||
["SPO2"] = (50, 100), // peripheral oxygen saturation %
|
||||
["RESP_RATE"] = (1, 80), // respirations per minute
|
||||
["WBC_K_UL"] = (0.1m, 500), // white blood cell count ×10³/µL
|
||||
["GLUCOSE_MG_DL"] = (10, 1000), // blood glucose mg/dL
|
||||
["LACTATE_MMOL_L"] = (0.1m, 30), // blood lactate mmol/L
|
||||
["BP_SYSTOLIC"] = (40, 300), // systolic blood pressure mmHg
|
||||
["BP_DIASTOLIC"] = (20, 200), // diastolic blood pressure mmHg
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the value falls within the plausible range for the given code.
|
||||
/// Unknown observation codes pass plausibility — the code is validated elsewhere.
|
||||
/// </summary>
|
||||
public static bool IsPlausible(string observationCode, decimal value, out string? reason)
|
||||
{
|
||||
if (!_ranges.TryGetValue(observationCode, out var range))
|
||||
{
|
||||
// Unknown codes pass plausibility — the observation code itself
|
||||
// is validated at the business rule layer, not here.
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value < range.Min || value > range.Max)
|
||||
{
|
||||
reason = $"Value {value} is outside the plausible range " +
|
||||
$"[{range.Min}–{range.Max}] for {observationCode}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all known observation codes and their plausible ranges.
|
||||
/// Used by the frontend to display valid input boundaries.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, (decimal Min, decimal Max)> GetAllRanges() =>
|
||||
_ranges;
|
||||
}
|
||||
Reference in New Issue
Block a user