feature: Approval and Promotion to VigilCareClinical

This commit is contained in:
voltsrage
2026-06-26 15:05:02 +08:00
parent 470df683dd
commit 706318e5d2
40 changed files with 5639 additions and 3 deletions
@@ -0,0 +1,50 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public class IdempotencyService : IIdempotencyService
{
private readonly AppDbContext _db;
private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24);
public IdempotencyService(AppDbContext db)
{
_db = db;
}
public async Task<IdempotencyRecord?> GetExistingAsync(string idempotencyKey, string operationName)
{
var record = await _db.IdempotencyRecords
.FirstOrDefaultAsync(r =>
r.IdempotencyKey == idempotencyKey &&
r.OperationName == operationName &&
r.ExpiresAt > DateTimeOffset.UtcNow);
return record;
}
public async Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
int httpStatusCode, object responseBody, TimeSpan? ttl = null)
{
var effectiveTtl = ttl ?? DefaultTtl;
var now = DateTimeOffset.UtcNow;
var record = new IdempotencyRecord
{
Id = Guid.NewGuid(),
IdempotencyKey = idempotencyKey,
OperationName = operationName,
ResourceId = resourceId,
HttpStatusCode = httpStatusCode,
ResponseBodyJson = JsonSerializer.Serialize(responseBody, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
}),
CreatedAt = now,
ExpiresAt = now.Add(effectiveTtl)
};
_db.IdempotencyRecords.Add(record);
// SaveChanges is called by the caller (within the same transaction)
}
}
@@ -0,0 +1,14 @@
public interface IIdempotencyService
{
/// <summary>
/// Returns the cached response if the key was already used, or null if this is a new key.
/// </summary>
Task<IdempotencyRecord?> GetExistingAsync(string idempotencyKey, string operationName);
/// <summary>
/// Stores the result of an operation for future deduplication.
/// Must be called within the same transaction as the operation.
/// </summary>
Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
int httpStatusCode, object responseBody, TimeSpan? ttl = null);
}
@@ -0,0 +1,8 @@
public interface IMrnGenerator
{
/// <summary>
/// Generates the next unique MRN in the format "VCR-{6-digit padded number}".
/// Uses a PostgreSQL sequence for atomic increment under concurrent access.
/// </summary>
Task<string> GenerateNextMrnAsync();
}
@@ -0,0 +1,23 @@
public interface IPromotionService
{
/// <summary>
/// Approves a verified or awaiting-clinical-approval batch and promotes its draft data
/// into live VigilCareClinical tables within a single atomic transaction.
/// </summary>
/// <param name="batchId">The batch to promote.</param>
/// <param name="approverUserId">The user performing the approval (separation-of-duties enforced).</param>
/// <param name="enableRetroactiveAlerts">
/// If true, outbox events are written for backfill observations so the alert engine processes them.
/// If false (default), backfill observations are silently inserted with no alert path.
/// Live-capture track always writes outbox events regardless of this flag.
/// </param>
/// <param name="idempotencyKey">Optional key for idempotent promotion. If provided and already used, returns cached result.</param>
/// <returns>The promotion result with all created live IDs.</returns>
Task<PromotionResultResponse> ApproveAndPromoteAsync(
Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey);
/// <summary>
/// Returns the promotion result for an already-promoted batch.
/// </summary>
Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId);
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
public class MrnGenerator : IMrnGenerator
{
private readonly AppDbContext _db;
private const string MrnPrefix = "VCR";
private const string SequenceName = "clinical.mrn_sequence";
public MrnGenerator(AppDbContext db)
{
_db = db;
}
public async Task<string> GenerateNextMrnAsync()
{
// Use PostgreSQL sequence for atomic, gap-free numbering
var connection = _db.Database.GetDbConnection();
var wasOpen = connection.State == System.Data.ConnectionState.Open;
if (!wasOpen)
await connection.OpenAsync();
try
{
using var command = connection.CreateCommand();
command.CommandText = $"SELECT nextval('{SequenceName}')";
// If we're in a transaction, enlist the command
if (_db.Database.CurrentTransaction is not null)
{
command.Transaction = _db.Database.CurrentTransaction.GetDbTransaction();
}
var nextVal = (long)(await command.ExecuteScalarAsync())!;
return $"{MrnPrefix}-{nextVal:D6}";
}
finally
{
if (!wasOpen)
await connection.CloseAsync();
}
}
}
@@ -0,0 +1,392 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public class PromotionService : IPromotionService
{
private readonly AppDbContext _db;
private readonly IIdempotencyService _idempotency;
private readonly IMrnGenerator _mrnGenerator;
private readonly ILogger<PromotionService> _logger;
public PromotionService(
AppDbContext db,
IIdempotencyService idempotency,
IMrnGenerator mrnGenerator,
ILogger<PromotionService> logger)
{
_db = db;
_idempotency = idempotency;
_mrnGenerator = mrnGenerator;
_logger = logger;
}
public async Task<PromotionResultResponse> ApproveAndPromoteAsync(
Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey)
{
// --- Idempotency check (before transaction) ---
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
var existing = await _idempotency.GetExistingAsync(idempotencyKey, "batch_promote");
if (existing is not null)
{
_logger.LogInformation(
"Idempotent replay for batch {BatchId} with key {Key}",
batchId, idempotencyKey);
return JsonSerializer.Deserialize<PromotionResultResponse>(
existing.ResponseBodyJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
}
// --- Load batch with all draft data ---
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");
// --- Status validation ---
if (batch.Status != BatchStatus.Verified && batch.Status != BatchStatus.AwaitingClinicalApproval)
throw new ConflictException(
$"Batch must be in 'verified' or 'awaiting_clinical_approval' status to approve. Current status: '{batch.Status.ToDbString()}'.",
"ILLEGAL_STATUS_TRANSITION");
// --- Separation of duties: approver cannot be the entry clerk ---
if (batch.EnteredByUserId == approverUserId)
throw new ConflictException(
"Separation of duties: the entry clerk cannot approve their own batch.",
"SEPARATION_OF_DUTIES_VIOLATION");
// --- Also cannot be the verifier ---
if (batch.VerifiedByUserId == approverUserId)
throw new ConflictException(
"Separation of duties: the verifier cannot also approve the same batch.",
"SEPARATION_OF_DUTIES_VIOLATION");
// --- Validate draft data completeness ---
if (batch.DraftPatient is null)
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
// --- Begin atomic transaction ---
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
var now = DateTimeOffset.UtcNow;
// === Step 1: Create or update Patient ===
var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now);
// === Step 2: Create or match Encounter ===
var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now);
// === Step 3: Insert each DraftObservation as live Observation ===
var (observationIds, outboxCount) = await PromoteObservationsAsync(
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
// === Step 4: Update batch status to Promoted ===
batch.Status = BatchStatus.Promoted;
batch.ApprovedByUserId = approverUserId;
batch.PromotedAt = now;
batch.PromotionEncounterId = encounter.Id;
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
batch.UpdatedAt = now;
// === Step 5: Write DigitizationEvent ===
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Promoted,
ActorUserId = approverUserId,
OccurredAt = now,
MetadataJson = JsonSerializer.Serialize(new
{
patientId = patient.Id,
mrn = patient.Mrn,
encounterId = encounter.Id,
observationCount = observationIds.Length,
outboxEventsWritten = outboxCount,
enableRetroactiveAlerts,
track = batch.Track.ToDbString()
})
});
// === Step 6: Store idempotency record (within same transaction) ===
var result = new PromotionResultResponse(
BatchId: batchId,
Status: BatchStatus.Promoted.ToDbString(),
PatientId: patient.Id,
Mrn: patient.Mrn,
EncounterId: encounter.Id,
ObservationIds: observationIds,
PromotedAt: now,
OutboxEventsWritten: outboxCount
);
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
await _idempotency.SaveAsync(
idempotencyKey, "batch_promote", batchId,
200, result, TimeSpan.FromHours(24));
}
// === Step 7: Commit ===
await _db.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation(
"Batch {BatchId} promoted: Patient {PatientId} (MRN {Mrn}), " +
"Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events",
batchId, patient.Id, patient.Mrn, encounter.Id,
observationIds.Length, outboxCount);
return result;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId)
{
var batch = await _db.DigitizationBatches
.FirstOrDefaultAsync(b => b.Id == batchId);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
if (batch.Status != BatchStatus.Promoted)
throw new ConflictException(
$"Batch is not promoted. Current status: '{batch.Status.ToDbString()}'.",
"BATCH_NOT_PROMOTED");
// Retrieve the live patient by looking up the encounter
var encounter = await _db.Encounters
.Include(e => e.Patient)
.FirstOrDefaultAsync(e => e.Id == batch.PromotionEncounterId);
if (encounter is null)
throw new NotFoundException(
"Promotion encounter not found. Data may be inconsistent.",
"PROMOTION_ENCOUNTER_NOT_FOUND");
var observationIds = await _db.Observations
.Where(o => o.SourceBatchId == batchId)
.Select(o => o.Id)
.ToArrayAsync();
return new PromotionResultResponse(
BatchId: batchId,
Status: batch.Status.ToDbString(),
PatientId: encounter.PatientId,
Mrn: encounter.Patient.Mrn,
EncounterId: encounter.Id,
ObservationIds: observationIds,
PromotedAt: batch.PromotedAt!.Value,
OutboxEventsWritten: 0 // Historical count not stored; use event metadata
);
}
// ──────────────────────────────────────────────────
// Private helpers
// ──────────────────────────────────────────────────
private async Task<Patient> CreateOrUpdatePatientAsync(DraftPatient draft, DateTimeOffset now)
{
// Attempt to match existing patient by name + DOB (simple dedup)
Patient? existing = null;
if (draft.DateOfBirth.HasValue)
{
existing = await _db.Patients
.FirstOrDefaultAsync(p =>
p.FullName == draft.FullName &&
p.DateOfBirth == draft.DateOfBirth);
}
if (existing is not null)
{
// Update fields that may have new information
existing.Sex = draft.Sex ?? existing.Sex;
existing.BloodType = draft.BloodType ?? existing.BloodType;
existing.EmergencyContact = draft.EmergencyContact ?? existing.EmergencyContact;
existing.AllergiesJson = draft.AllergiesJson ?? existing.AllergiesJson;
existing.NoKnownAllergies = draft.NoKnownAllergies || existing.NoKnownAllergies;
existing.UpdatedAt = now;
_logger.LogInformation(
"Matched existing patient {PatientId} (MRN {Mrn}) by name + DOB",
existing.Id, existing.Mrn);
return existing;
}
// Create new patient with generated MRN
var mrn = await _mrnGenerator.GenerateNextMrnAsync();
var patient = new Patient
{
Id = Guid.NewGuid(),
Mrn = mrn,
FullName = draft.FullName!,
DateOfBirth = draft.DateOfBirth,
Sex = draft.Sex,
BloodType = draft.BloodType,
EmergencyContact = draft.EmergencyContact,
AllergiesJson = draft.AllergiesJson,
NoKnownAllergies = draft.NoKnownAllergies,
CreatedAt = now,
UpdatedAt = now
};
_db.Patients.Add(patient);
_logger.LogInformation(
"Created new patient {PatientId} with MRN {Mrn}",
patient.Id, mrn);
return patient;
}
private async Task<Encounter> CreateOrMatchEncounterAsync(
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
{
// If the batch has a draft encounter, try to match an active encounter
// for the same patient in the same department
if (batch.DraftEncounter is not null)
{
var existingEncounter = await _db.Encounters
.FirstOrDefaultAsync(e =>
e.PatientId == patientId &&
e.Department == batch.DraftEncounter.Department &&
e.Status == "active" &&
e.SourceBatchId != batch.Id);
if (existingEncounter is not null)
{
_logger.LogInformation(
"Matched existing active encounter {EncounterId} for patient {PatientId}",
existingEncounter.Id, patientId);
return existingEncounter;
}
}
// Determine encounter status from draft
var encounterStatus = batch.DraftEncounter?.Status ?? "active";
if (batch.DraftEncounter?.DischargeDiagnosis is not null)
{
encounterStatus = "discharged";
}
var encounter = new Encounter
{
Id = Guid.NewGuid(),
PatientId = patientId,
AdmissionDate = batch.DraftEncounter?.AdmissionDate,
Department = batch.DraftEncounter?.Department,
RoomBed = batch.DraftEncounter?.RoomBed,
AdmissionReason = batch.DraftEncounter?.AdmissionReason,
DischargeDiagnosis = batch.DraftEncounter?.DischargeDiagnosis,
Status = encounterStatus,
SourceBatchId = batch.Id,
CreatedAt = now,
UpdatedAt = now
};
_db.Encounters.Add(encounter);
_logger.LogInformation(
"Created encounter {EncounterId} for patient {PatientId} with status '{Status}'",
encounter.Id, patientId, encounterStatus);
return encounter;
}
private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
DigitizationBatch batch, Guid patientId, Guid encounterId,
bool enableRetroactiveAlerts, DateTimeOffset now)
{
var observationIds = new List<Guid>();
var outboxCount = 0;
// Determine the source label based on batch track
var source = batch.Track == BatchTrack.LiveCapture
? "live_capture"
: "digitization_backfill";
// Determine whether outbox events should be written for this batch
var shouldAlert = batch.Track == BatchTrack.LiveCapture || enableRetroactiveAlerts;
foreach (var draft in batch.DraftObservations)
{
var observationId = Guid.NewGuid();
var observation = new Observation
{
Id = observationId,
EncounterId = encounterId,
PatientId = patientId,
ObservationCode = draft.ObservationCode,
Value = draft.Value,
Unit = draft.Unit,
RecordedAt = draft.RecordedAt,
Note = draft.Note,
Source = source,
SourceDraftObservationId = draft.Id,
SourceBatchId = batch.Id,
CreatedAt = now
};
_db.Observations.Add(observation);
observationIds.Add(observationId);
// Write outbox event only if alerting is enabled for this batch
if (shouldAlert)
{
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "observation.created",
AggregateType = "Observation",
AggregateId = observationId,
PayloadJson = JsonSerializer.Serialize(new
{
observationId,
encounterId,
patientId,
observationCode = draft.ObservationCode,
value = draft.Value,
unit = draft.Unit,
recordedAt = draft.RecordedAt,
source,
batchId = batch.Id,
track = batch.Track.ToDbString()
}),
CreatedAt = now,
ProcessedAt = null,
RetryCount = 0
});
outboxCount++;
}
}
_logger.LogInformation(
"Promoted {Count} observations for batch {BatchId}, " +
"{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})",
observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString());
return (observationIds.ToArray(), outboxCount);
}
}