feature: In-App Simulation Runner (Backend)
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public class VigilCareApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public VigilCareApiClient(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task LoginAsync(string username, string password)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new SimLoginRequest(username, password));
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Login failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SimLoginResponse>>();
|
||||
var token = envelope!.Data!.AccessToken;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
public void SetBearerToken(string token)
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
public async Task<PatientResponse> RegisterPatientAsync(RegisterPatientRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Register patient failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
|
||||
return envelope!.Data!;
|
||||
}
|
||||
|
||||
public async Task<EncounterResponse> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync($"/api/v1/patients/{patientId}/encounters", req);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<EncounterResponse>>();
|
||||
return envelope!.Data!;
|
||||
}
|
||||
|
||||
public async Task SendObservationBatchAsync(
|
||||
Guid encounterId, List<IngestObservationRequest> observations,
|
||||
ReplayTarget target = ReplayTarget.Central)
|
||||
{
|
||||
if (target == ReplayTarget.Gateway)
|
||||
{
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = obs.ObservationCode,
|
||||
value = obs.Value,
|
||||
unit = obs.Unit,
|
||||
source = obs.Source,
|
||||
recordedAt = obs.RecordedAt,
|
||||
idempotencyKey = obs.IdempotencyKey ?? Guid.NewGuid().ToString()
|
||||
});
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Observation ingest failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var chunk in observations.Chunk(10))
|
||||
{
|
||||
var batch = new BatchIngestRequest(chunk.ToList());
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/observations", batch);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Observation batch failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TrySendMedicationAsync(
|
||||
Guid encounterId, CreateMedicationAdministrationRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/medications", req);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return false;
|
||||
response.EnsureSuccessStatusCode();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> TryCreateOrderAsync(
|
||||
Guid encounterId, CreateOrderRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/orders", req);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> TryResultOrderAsync(
|
||||
Guid encounterId, string orderDescription, string? resultSummary)
|
||||
{
|
||||
var ordersResponse = await _http.GetAsync(
|
||||
$"/api/v1/encounters/{encounterId}/orders?status=PENDING");
|
||||
if (!ordersResponse.IsSuccessStatusCode)
|
||||
return false;
|
||||
|
||||
var envelope = await ordersResponse.Content
|
||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<OrderResponse>>>();
|
||||
var order = FindPendingOrder(envelope?.Data?.Items ?? [], orderDescription);
|
||||
if (order is null)
|
||||
return false;
|
||||
|
||||
var resultResponse = await _http.PatchAsJsonAsync(
|
||||
$"/api/v1/orders/{order.Id}/result",
|
||||
new RecordOrderResultRequest(resultSummary));
|
||||
return resultResponse.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private static OrderResponse? FindPendingOrder(
|
||||
List<OrderResponse> items, string description)
|
||||
{
|
||||
return items.FirstOrDefault(o => o.Description == description)
|
||||
?? items.FirstOrDefault(o =>
|
||||
description.StartsWith(o.Description, StringComparison.OrdinalIgnoreCase))
|
||||
?? items.FirstOrDefault(o =>
|
||||
o.Description.StartsWith(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// --- Polling endpoints ---
|
||||
|
||||
public async Task<List<AlertResponse>> GetAlertsAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
|
||||
if (!response.IsSuccessStatusCode) return new();
|
||||
var envelope = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>(ApiJsonOptions);
|
||||
return envelope?.Data?.Items?.ToList() ?? new();
|
||||
}
|
||||
|
||||
public async Task<News2Response?> GetCurrentNews2Async(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/news2/current");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<News2Response>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<SepsisBundleResponse?> GetSepsisBundleAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync(
|
||||
$"/api/v1/encounters/{encounterId}/sepsis-bundle/current");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<SepsisBundleResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<GcsResponse?> GetCurrentGcsAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/gcs");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<GcsResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<SofaResponse?> GetCurrentSofaAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/sofa");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SofaResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<QsofaResponse?> GetCurrentQsofaAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/qsofa/current");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<QsofaResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<bool> TryAcknowledgeAlertAsync(
|
||||
Guid encounterId, string alertType, string clinicianId, string? note,
|
||||
TimeSpan? waitForAlert = null, CancellationToken ct = default)
|
||||
{
|
||||
const int pollIntervalMs = 500;
|
||||
var deadline = waitForAlert.HasValue
|
||||
? DateTimeOffset.UtcNow.Add(waitForAlert.Value)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var alerts = await GetAlertsAsync(encounterId);
|
||||
var alert = alerts.FirstOrDefault(a =>
|
||||
IsMatchingAlertType(a.AlertType, alertType)
|
||||
&& IsOpenAlertStatus(a.Status));
|
||||
if (alert is not null)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/alerts/{alert.Id}/acknowledge",
|
||||
new { note, clinicianId }, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
if (!deadline.HasValue || DateTimeOffset.UtcNow >= deadline.Value)
|
||||
return false;
|
||||
|
||||
await Task.Delay(pollIntervalMs, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOpenAlertStatus(string status) =>
|
||||
string.Equals(status, "OPEN", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsMatchingAlertType(string actual, string expected)
|
||||
{
|
||||
if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
static string Normalize(string s) =>
|
||||
s.Replace("_", "", StringComparison.Ordinal).ToUpperInvariant();
|
||||
|
||||
return Normalize(actual) == Normalize(expected);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user