feature: Ward Gateway Service (Local-First Clinical Path)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
|
||||
|
||||
**Implementation status:** Twenty-seven planned phases are complete through Phase 31 (plus Phase 20) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **FHIR R4 Inbound Facade** for EHR integration, and **Role-Based Access Control (RBAC) with clinical audit logging**. Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, and **JWT signing key validation** at startup. See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
|
||||
**Implementation status:** Twenty-seven planned phases are complete through Phase 31 (plus Phase 20) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **FHIR R4 Inbound Facade** for EHR integration, and **Role-Based Access Control (RBAC) with clinical audit logging**. Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
|
||||
|
||||
## Domain Model — How It Maps to a Real Clinical System
|
||||
|
||||
@@ -53,7 +53,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
||||
## Features
|
||||
|
||||
- **Patient Registration** — register patients with MRN generation; optional blood type, allergies, and emergency contact; paginated list with name (`ILIKE`) and MRN (exact) search; patient detail with active encounter summary
|
||||
- **Encounter Management** — open encounters against a patient with optional room/bed and admission reason; encounter status state machine (`scheduled → active → discharged / cancelled`) with 409 on illegal transitions; optional discharge diagnosis on discharge; encounter timeline as a merged chronological view across status changes, observation summaries, and alerts
|
||||
- **Encounter Management** — open encounters against a patient with optional room/bed and admission reason; encounter status state machine (`scheduled → active → discharged / cancelled`) with 409 on illegal transitions; unique active encounter per type per patient enforced by partial unique index (concurrent duplicate attempts return 409); optional discharge diagnosis on discharge; encounter timeline as a merged chronological view across status changes, observation summaries, and alerts
|
||||
- **Alert Threshold Management** — configure per-observation-code numeric bounds (`criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`) for 12 observation codes; thresholds pre-loaded into Redis on startup; write-through cache invalidation on update and delete; `DELETE /alert-thresholds/{id}` removes a threshold with `THRESHOLD_DELETED` audit trail and Redis cache invalidation
|
||||
- **Observation Ingest** — `POST /encounters/:id/observations` accepts single or small batch (up to 10); idempotency via `Idempotency-Key` header (partial unique index); plausibility validation per observation code; synchronous critical alert creation within the ingest transaction; warning-range breaches evaluated asynchronously by `WarningAlertService` (Kafka consumer group `warning-evaluator`); outbox event written in the same commit; cursor-paginated history on `(encounter_id, observation_code, recorded_at DESC)`
|
||||
- **Warning Threshold Alerts** — `WarningEvaluator` reads thresholds from Redis; creates `WARNING`-severity alerts for values above `warningHigh` or below `warningLow` that are not also critical breaches; idempotent `INSERT WHERE NOT EXISTS` per encounter and alert type while status is `OPEN` or `ACKNOWLEDGED`; warning alerts are indexed in Elasticsearch but not published to the RabbitMQ paging queue
|
||||
@@ -439,6 +439,8 @@ tests/
|
||||
├── QsofaCurrentTests.cs # qSOFA current API — Redis state, criteria breakdown
|
||||
├── GcsScoringTests.cs # GCS component scoring, alerts, NEWS2/qSOFA integration paths
|
||||
├── SofaScoringTests.cs # SOFA organ scores, baseline, delta alerts, carry-forward, vasopressors
|
||||
├── BackgroundServiceTests.cs # Outbox relay, Kafka consumer, sepsis bundle monitor, reconciliation
|
||||
├── ConcurrencyTests.cs # Parallel patient MRN, sepsis bundle, observation idempotency, encounter open
|
||||
├── GatewayRegistryTests.cs # Gateway register, heartbeat, API key auth, department filter
|
||||
├── Helpers/GatewayAuthHelper.cs # WithGatewayApiKey extension method for test clients
|
||||
├── Auth/
|
||||
@@ -459,6 +461,47 @@ VigilCare.ClinicalContracts/ # Phase 20 — shared sync DTO
|
||||
VigilCare.ClinicalContracts.Tests/ # Contracts round-trip serialization tests
|
||||
└── ClinicalContractsTests.cs
|
||||
|
||||
VigilCare.WardGateway/ # Phase 21 — local-first ward edge API (separate DB/Redis/RabbitMQ)
|
||||
├── Dockerfile
|
||||
├── Program.cs
|
||||
├── Data/
|
||||
│ ├── GatewayDbContext.cs
|
||||
│ └── Configurations/ # snake_case mappings mirroring central API patterns
|
||||
├── Domain/
|
||||
│ ├── Entities/ # ReplicaPatient, ReplicaEncounter, LocalObservation, …
|
||||
│ └── Enums/ # AlertType, EncounterStatus, BufferedSyncItemType, …
|
||||
├── Models/Records/ThresholdCacheEntry.cs
|
||||
├── Models/Records/Observation/IngestObservationRequest.cs
|
||||
├── Models/Records/Alert/AcknowledgeAlertRequest.cs
|
||||
├── Models/Central/CentralSyncDtos.cs # DTOs for central API sync responses
|
||||
├── Models/Central/CentralApiJson.cs
|
||||
├── Validators/IngestObservationRequestValidator.cs
|
||||
├── Validators/AcknowledgeAlertRequestValidator.cs
|
||||
├── Services/
|
||||
│ ├── PlausibilityValidator.cs
|
||||
│ ├── LocalObservationService.cs
|
||||
│ ├── LocalWarningEvaluator.cs
|
||||
│ ├── LocalAlertService.cs
|
||||
│ ├── LocalPagingPublisher.cs
|
||||
│ ├── ObservationQueryService.cs
|
||||
│ ├── EncounterReadService.cs
|
||||
│ └── BufferedSyncWriter.cs
|
||||
├── Controllers/
|
||||
│ ├── ObservationsController.cs
|
||||
│ ├── AlertsController.cs
|
||||
│ ├── EncountersController.cs
|
||||
│ └── CentralRequiredController.cs
|
||||
├── Notifications/RabbitMqTopologyProvisioner.cs
|
||||
├── BackgroundService/
|
||||
│ ├── EncounterReplicaSyncService.cs
|
||||
│ ├── ThresholdCacheLoader.cs
|
||||
│ ├── CentralReachabilityService.cs
|
||||
│ ├── LocalPagingWorkerService.cs
|
||||
│ └── LocalEscalationWorkerService.cs
|
||||
├── Configurations/ # GatewayOptions, CentralApiOptions, RabbitMqOptions
|
||||
├── Infrastructure/ # Health checks (Redis, RabbitMQ, encounter replica ready)
|
||||
└── Migrations/ # InitialGatewaySchema
|
||||
|
||||
VigilCare.Simulator/ # Phase 16 — console replay simulator (HTTP-only, no direct DB/Kafka)
|
||||
├── Program.cs # CLI: replay, replay-all, validate, dry-run
|
||||
├── Commands/ # System.CommandLine command handlers
|
||||
@@ -598,10 +641,26 @@ This two-tier design prevents false-positive bundle activations — SIRS criteri
|
||||
|
||||
### Start Infrastructure
|
||||
|
||||
**Central stack only** (default — no Compose profile):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Ward gateway stack** (separate PostgreSQL, Redis, RabbitMQ, and gateway API on port 5081):
|
||||
|
||||
```bash
|
||||
docker compose --profile ward-gateway up -d
|
||||
```
|
||||
|
||||
**Everything** (central + ward):
|
||||
|
||||
```bash
|
||||
docker compose --profile full up -d
|
||||
```
|
||||
|
||||
Ward services use Compose profiles and do **not** start with a plain `docker compose up`. See `docs/docker-compose-usage-and-troubleshooting.md` §10.
|
||||
|
||||
All services join the `vigilcare_net` bridge network so containers can reach each other by service name (e.g. Grafana → `http://prometheus:9090`). Connection strings in `appsettings.json` use **host** ports when running `dotnet run` on your machine.
|
||||
|
||||
| Service | Host Port | Notes |
|
||||
@@ -616,6 +675,15 @@ All services join the `vigilcare_net` bridge network so containers can reach eac
|
||||
| Prometheus 2.52 | 9101 | UI at `http://localhost:9101` — scrapes `GET /metrics` on the API |
|
||||
| Grafana 10.4 | 3101 | UI at `http://localhost:3101` — login: `admin` / `admin` |
|
||||
|
||||
**Ward gateway stack** (`--profile ward-gateway` or `full`):
|
||||
|
||||
| Service | Host Port | Notes |
|
||||
|---|---|---|
|
||||
| Ward PostgreSQL 16 | 5437 | Database: `vigilcare_ward`, user: `postgres`, password: `password` |
|
||||
| Ward Redis 7 | 6383 | No auth |
|
||||
| Ward RabbitMQ 3.13 | 5675 (AMQP), 15675 (UI) | login: `guest` / `guest` |
|
||||
| Ward Gateway API | 5081 | `VigilCare.WardGateway` — local-first clinical path (Phase 21) |
|
||||
|
||||
**Seq first-run:** `SEQ_FIRSTRUN_ADMINPASSWORD=admin` is set in `docker-compose.yml`. This password is only applied on the very first container start (when the `/data` volume is empty). After initialization, the password is stored in the volume and this env var is ignored.
|
||||
|
||||
### Docker notes for Linux
|
||||
@@ -728,6 +796,7 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
|
||||
| `ClinicalContractsTests` | 20 | ClinicalSyncBatchRequest JSON round-trip serialization |
|
||||
| `FhirIngestTests` | 30 | FHIR R4 patient upsert idempotency, LOINC observation mapping, unknown code 422, transaction bundle |
|
||||
| `RbacTests` | 31 | Unauthenticated 401, nurse 403 on threshold write, admin threshold update with audit log creation, alert acknowledge uses authenticated user |
|
||||
| `ConcurrencyTests` | — | Parallel patient registration unique MRNs, parallel sepsis alerts single bundle, parallel observation idempotency, parallel encounter open duplicate rejection |
|
||||
|
||||
### Verification Scripts
|
||||
|
||||
@@ -1350,7 +1419,7 @@ dischargedAt DateTimeOffset?
|
||||
createdAt DateTimeOffset
|
||||
```
|
||||
|
||||
Indexes: `(patient_id, admitted_at DESC)`, partial `(status, admitted_at DESC) WHERE status = 'active'`
|
||||
Indexes: `(patient_id, admitted_at DESC)`, partial `(status, admitted_at DESC) WHERE status = 'active'`, partial unique `(patient_id, encounter_type) WHERE status = 'ACTIVE'` (prevents duplicate active encounters of the same type per patient)
|
||||
|
||||
### AlertThreshold
|
||||
|
||||
@@ -1777,7 +1846,7 @@ When a `SOFA_SEPSIS` alert fires (delta ≥ 2 from baseline), `SepsisAlertHandle
|
||||
7. When all four elements are complete: `complianceStatus = COMPLIANT` (within deadline) or `NON_COMPLIANT` (past deadline); Prometheus `sepsis_bundle_compliance_total{status}` incremented
|
||||
8. If the deadline passes with incomplete elements, `SepsisBundleMonitorService` (polling every 5 min) marks the bundle `NON_COMPLIANT` — elements remain `PENDING` but the bundle status reflects the missed deadline
|
||||
|
||||
**Idempotency:** Only one in-progress bundle can exist per encounter, enforced by a partial unique index `(encounter_id) WHERE compliance_status = 'IN_PROGRESS'`. A second alert for the same encounter returns early without creating a duplicate — the database constraint prevents TOCTOU race conditions even under concurrent SOFA scoring events.
|
||||
**Idempotency:** Only one in-progress bundle can exist per encounter, enforced by a partial unique index `(encounter_id) WHERE compliance_status = 'IN_PROGRESS'`. Bundle creation wraps order and bundle inserts in a single database transaction — if the unique constraint rejects a concurrent duplicate, the transaction rolls back all associated orders, preventing orphaned order records. A second alert for the same encounter returns early without side effects.
|
||||
|
||||
---
|
||||
|
||||
@@ -1921,6 +1990,8 @@ Twenty-seven phases from the project roadmap are implemented and verified, inclu
|
||||
|
||||
**Site & Gateway Registry (Phase 20):** Central API manages clinical sites and ward edge nodes (gateways). Gateways authenticate via API key for heartbeat and future sync upload. Shared `VigilCare.ClinicalContracts` class library defines sync DTOs consumed by both central API and ward gateway projects. Prometheus fleet health gauges track offline gateways and buffer depth per site. Foundation for Phase 21 (WardGateway standalone service) and Phase 22 (sync batch upload).
|
||||
|
||||
**Ward Gateway (Phase 21, in progress):** `VigilCare.WardGateway` is a separate ASP.NET deployable with its own PostgreSQL (`vigilcare_ward`), Redis, and RabbitMQ. Docker Compose services use the `ward-gateway` profile — start with `docker compose --profile ward-gateway up -d`. Domain entities mirror central `Patient`/`Encounter`/`Observation`/`ClinicalAlert` as replica or local types; see `docs/plans/phase-21-plan.md` Step 2.
|
||||
|
||||
**Scoring pipeline (Phases 25–26):** GCS components → `gcs_scores` + `gcs.scored` → SOFA CNS organ system; SOFA lab/vital observations → `sofa_scores` with baseline tracking → delta sepsis alerts when organ dysfunction worsens.
|
||||
|
||||
**Sepsis-3 refactor (Phases 27–29):** SIRS removed; qSOFA repositioned as bedside screening (`QSOFA_SCREEN`); SOFA delta ≥ 2 triggers `SOFA_SEPSIS` → sepsis bundle. Frontend gains GCS entry form and SOFA score panel. Eleven simulator scenarios validate the full clinical pipeline end-to-end.
|
||||
@@ -1935,6 +2006,7 @@ Twenty-seven phases from the project roadmap are implemented and verified, inclu
|
||||
- **FHIR API key rotation** — `Fhir:ApiKeys` array alongside existing `Fhir:ApiKey` for zero-downtime key rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals` prevents timing attacks
|
||||
- **Authorization failure logging** — `PermissionAuthorizationHandler` logs denied requests with structured details (username, user ID, role, required permission, endpoint); Prometheus `authorization_failures_total` counter with `permission` and `role` labels
|
||||
- **JWT signing key validation** — startup guard rejects keys shorter than 256 bits (HMAC-SHA256 minimum); prevents silent misconfiguration that would weaken token verification
|
||||
- **Concurrency hardening** — `SepsisBundleService.TryCreateBundleAsync` wraps order + bundle creation in a single database transaction so the unique constraint rollback also reverts orphaned orders; `PatientService.OpenEncounterAsync` enforced by new partial unique index `ix_encounters_patient_active_type` on `(patient_id, encounter_type) WHERE status = 'ACTIVE'` with constraint-violation catch returning 409 Conflict; `ConcurrencyTests` validates parallel patient registration, sepsis bundle creation, observation idempotency, and encounter open race conditions
|
||||
- **New Prometheus metrics** — `fhir_read_total` (resource_type, interaction, outcome), `authorization_failures_total` (permission, role)
|
||||
- **New audit actions** — `THRESHOLD_DELETED`, `AUTHORIZATION_DENIED`
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
public const string TestConnectionString =
|
||||
"Host=localhost;Port=5437;Database=vigilcare_ward_test;Username=postgres;Password=password";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:GatewayDb"] = TestConnectionString,
|
||||
["Redis:ConnectionString"] = "localhost:6383,allowAdmin=true",
|
||||
["RabbitMq:Host"] = "localhost",
|
||||
["RabbitMq:Port"] = "5675",
|
||||
["RabbitMq:Username"] = "guest",
|
||||
["RabbitMq:Password"] = "guest",
|
||||
["RabbitMq:PagingAckTimeoutMs"] = "5000",
|
||||
["CentralApi:BaseUrl"] = "http://127.0.0.1:1",
|
||||
["Gateway:GatewayId"] = "22222222-2222-2222-2222-222222222222",
|
||||
["Gateway:SiteId"] = "11111111-1111-1111-1111-111111111111",
|
||||
["Gateway:Department"] = "ICU",
|
||||
["Gateway:EncounterSyncIntervalMinutes"] = "60",
|
||||
["Gateway:CentralReachabilityIntervalSeconds"] = "1",
|
||||
["Gateway:HeartbeatIntervalSeconds"] = "1",
|
||||
["Gateway:SyncBatchSize"] = "500",
|
||||
["ApiKey:Gateway"] = "dev-gateway-key-change-in-production",
|
||||
["Jwt:SigningKey"] = "dev-signing-key-minimum-32-bytes-long!!",
|
||||
["Jwt:Issuer"] = "vigilcare-gateway",
|
||||
["Jwt:Audience"] = "vigilcare-dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestingAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestingAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestingAuthHandler>(
|
||||
TestingAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<GatewayDbContext>()
|
||||
.UseNpgsql(TestConnectionString)
|
||||
.Options;
|
||||
await using (var migrateDb = new GatewayDbContext(options))
|
||||
{
|
||||
await migrateDb.Database.MigrateAsync();
|
||||
await GatewayDbResetHelper.ResetAsync(migrateDb);
|
||||
}
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var server = redis.GetServer(redis.GetEndPoints().First());
|
||||
await server.FlushAllDatabasesAsync();
|
||||
}
|
||||
|
||||
protected override void ConfigureClient(HttpClient client)
|
||||
{
|
||||
base.ConfigureClient(client);
|
||||
client.AsNurse();
|
||||
}
|
||||
|
||||
public new async Task DisposeAsync() => await base.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[CollectionDefinition("GatewayIntegration")]
|
||||
public class GatewayIntegrationCollection : ICollectionFixture<GatewayApiFixture>;
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public static class GatewayDbResetHelper
|
||||
{
|
||||
public static async Task ResetAsync(GatewayDbContext db)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(@"
|
||||
DELETE FROM buffered_sync_items;
|
||||
DELETE FROM sync_outbox;
|
||||
DELETE FROM clinical_alerts;
|
||||
DELETE FROM observations;
|
||||
DELETE FROM encounters;
|
||||
DELETE FROM patients;
|
||||
DELETE FROM alert_thresholds;
|
||||
DELETE FROM gateway_sync_state;
|
||||
");
|
||||
return;
|
||||
}
|
||||
catch when (attempt < 5)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public static class GatewayTestSeeder
|
||||
{
|
||||
public static async Task<(Guid PatientId, Guid EncounterId)> SeedActiveEncounterAsync(
|
||||
IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
await GatewayDbResetHelper.ResetAsync(db);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var patient = new ReplicaPatient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = "MRN-GW-001",
|
||||
FirstName = "Gateway",
|
||||
LastName = "Test",
|
||||
DateOfBirth = new DateOnly(1970, 1, 1),
|
||||
Gender = "F",
|
||||
SyncedAt = now
|
||||
};
|
||||
var encounter = new ReplicaEncounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patient.Id,
|
||||
EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active,
|
||||
Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. Gateway",
|
||||
RoomBed = "ICU-01",
|
||||
AdmittedAt = now,
|
||||
SyncedAt = now
|
||||
};
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
db.AlertThresholds.Add(new ReplicaAlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ObservationCode = "HEART_RATE",
|
||||
DisplayName = "Heart Rate",
|
||||
Unit = "bpm",
|
||||
CriticalLow = 30,
|
||||
WarningLow = 50,
|
||||
WarningHigh = 100,
|
||||
CriticalHigh = 150,
|
||||
SyncedAt = now
|
||||
});
|
||||
db.SyncState.Add(new GatewaySyncState
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InitialSyncCompleted = true,
|
||||
LastEncounterSyncAt = now
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase();
|
||||
await cache.StringSetAsync(
|
||||
"threshold:HEART_RATE",
|
||||
JsonSerializer.Serialize(new ThresholdCacheEntry(
|
||||
"HEART_RATE", 30, 50, 100, 150)));
|
||||
|
||||
return (patient.Id, encounter.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VigilCare.WardGateway\VigilCare.WardGateway.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\VigilCareClinicalAPI.Tests\Auth\TestingAuthHandler.cs" Link="Auth\TestingAuthHandler.cs" />
|
||||
<Compile Include="..\VigilCareClinicalAPI.Tests\Helpers\AuthHelper.cs" Link="Helpers\AuthHelper.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
[Collection("GatewayIntegration")]
|
||||
public class WardGatewayLocalPathTests : IAsyncLifetime
|
||||
{
|
||||
private readonly GatewayApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _encounterId;
|
||||
|
||||
public WardGatewayLocalPathTests(GatewayApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
(_, _encounterId) = await GatewayTestSeeder.SeedActiveEncounterAsync(_fixture.Services);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task CriticalObservation_CreatesAlertLocally()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().BeGreaterThan(0);
|
||||
(await db.BufferedSyncItems.CountAsync(b => !b.Synced)).Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateIdempotencyKey_ReturnsExisting()
|
||||
{
|
||||
var key = Guid.NewGuid().ToString();
|
||||
var body = new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 80,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = key
|
||||
};
|
||||
await _client.PostAsJsonAsync($"/api/v1/encounters/{_encounterId}/observations", body);
|
||||
var dup = await _client.PostAsJsonAsync($"/api/v1/encounters/{_encounterId}/observations", body);
|
||||
dup.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var json = await dup.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
json!.RootElement.GetProperty("data").GetProperty("duplicate").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_WritesBufferedAck()
|
||||
{
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
});
|
||||
|
||||
var alertsResp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/alerts");
|
||||
alertsResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var alertsDoc = await alertsResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var alertId = alertsDoc!.RootElement
|
||||
.GetProperty("data").GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||
|
||||
var ackResp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/alerts/{alertId}/acknowledge",
|
||||
new AcknowledgeAlertRequest("Reviewing at bedside."));
|
||||
ackResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
(await db.BufferedSyncItems.CountAsync(b =>
|
||||
b.ItemType == BufferedSyncItemType.Ack && !b.Synced)).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDown_ReachabilityReportsDegraded()
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var reachability = scope.ServiceProvider.GetRequiredService<CentralReachabilityService>();
|
||||
reachability.IsCentralReachable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WarningObservation_CreatesWarningNoPage()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 105,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.Severity.Should().Be(AlertSeverity.Warning);
|
||||
alert.AlertType.Should().Be(AlertType.WarningHeartRate);
|
||||
alert.Status.Should().Be(AlertStatus.Open);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Degraded-mode tests — central is unreachable; gateway clinical path remains authoritative locally.
|
||||
/// </summary>
|
||||
[Collection("GatewayIntegration")]
|
||||
public class WardGatewayPartitionTests : IAsyncLifetime
|
||||
{
|
||||
private readonly GatewayApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _encounterId;
|
||||
|
||||
public WardGatewayPartitionTests(GatewayApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
(_, _encounterId) = await GatewayTestSeeder.SeedActiveEncounterAsync(_fixture.Services);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_CriticalIngest_CreatesLocalAlertAndBuffer()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = $"partition-{Guid.NewGuid()}"
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var alerts = await db.ClinicalAlerts.Where(a => a.EncounterId == _encounterId).ToListAsync();
|
||||
alerts.Should().ContainSingle();
|
||||
alerts[0].Severity.Should().Be(AlertSeverity.Critical);
|
||||
|
||||
(await db.BufferedSyncItems.CountAsync(b => !b.Synced)).Should().BeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_ListAlertsViaGatewayApi()
|
||||
{
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = $"partition-list-{Guid.NewGuid()}"
|
||||
});
|
||||
|
||||
var resp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/alerts");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
doc!.RootElement.GetProperty("data").GetProperty("items").GetArrayLength()
|
||||
.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_ListActiveEncounters_FromLocalReplica()
|
||||
{
|
||||
var resp = await _client.GetAsync("/api/v1/encounters?status=ACTIVE&department=ICU");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var items = doc!.RootElement.GetProperty("data").GetProperty("items");
|
||||
items.GetArrayLength().Should().BeGreaterThan(0);
|
||||
items[0].GetProperty("encounterId").GetGuid().Should().Be(_encounterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_AcknowledgeAndResolve_BuffersLocally()
|
||||
{
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = $"partition-lifecycle-{Guid.NewGuid()}"
|
||||
});
|
||||
|
||||
var alertsResp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/alerts");
|
||||
var alertId = (await alertsResp.Content.ReadFromJsonAsync<JsonDocument>())!
|
||||
.RootElement.GetProperty("data").GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||
|
||||
(await _client.PostAsJsonAsync(
|
||||
$"/api/v1/alerts/{alertId}/acknowledge",
|
||||
new AcknowledgeAlertRequest("Partition ack"))).StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
(await _client.PostAsync($"/api/v1/alerts/{alertId}/resolve", null))
|
||||
.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var alert = await db.ClinicalAlerts.FindAsync(alertId);
|
||||
alert!.Status.Should().Be(AlertStatus.Resolved);
|
||||
(await db.BufferedSyncItems.CountAsync(b => !b.Synced)).Should().BeGreaterThanOrEqualTo(4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class CentralReachabilityService : BackgroundService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly CentralApiOptions _central;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private volatile bool _isReachable;
|
||||
|
||||
public bool IsCentralReachable => _isReachable;
|
||||
|
||||
public CentralReachabilityService(
|
||||
IHttpClientFactory http,
|
||||
IOptions<CentralApiOptions> central,
|
||||
IOptions<GatewayOptions> gateway)
|
||||
{
|
||||
_http = http;
|
||||
_central = central.Value;
|
||||
_gateway = gateway.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(
|
||||
TimeSpan.FromSeconds(_gateway.CentralReachabilityIntervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
await ProbeAsync(ct);
|
||||
}
|
||||
|
||||
private async Task ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient("central");
|
||||
client.BaseAddress = new Uri(_central.BaseUrl);
|
||||
var resp = await client.GetAsync("/health/ready", ct);
|
||||
_isReachable = resp.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_isReachable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public sealed class EncounterReplicaSyncService : BackgroundService
|
||||
{
|
||||
private const int EncounterPageSize = 100;
|
||||
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly CentralReachabilityService _reachability;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private readonly ILogger<EncounterReplicaSyncService> _logger;
|
||||
|
||||
public EncounterReplicaSyncService(
|
||||
IServiceScopeFactory scopes,
|
||||
IHttpClientFactory http,
|
||||
CentralReachabilityService reachability,
|
||||
IOptions<GatewayOptions> gateway,
|
||||
ILogger<EncounterReplicaSyncService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_http = http;
|
||||
_reachability = reachability;
|
||||
_gateway = gateway.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
await SyncOnceAsync(ct);
|
||||
|
||||
using var timer = new PeriodicTimer(
|
||||
TimeSpan.FromMinutes(_gateway.EncounterSyncIntervalMinutes));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (_reachability.IsCentralReachable)
|
||||
await SyncOnceAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var centralOpts = scope.ServiceProvider.GetRequiredService<IOptions<CentralApiOptions>>().Value;
|
||||
|
||||
var client = _http.CreateClient("central");
|
||||
client.BaseAddress = new Uri(centralOpts.BaseUrl);
|
||||
ConfigureAuth(client);
|
||||
|
||||
if (client.DefaultRequestHeaders.Authorization is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"GATEWAY_SYNC_JWT not set — skipping encounter replica sync (seed local replica manually for dev)");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var syncedCount = await SyncEncountersAsync(client, db, ct);
|
||||
await SyncThresholdsAsync(client, db, redis, ct);
|
||||
|
||||
var state = await db.SyncState.FirstOrDefaultAsync(ct)
|
||||
?? db.SyncState.Add(new GatewaySyncState { Id = Guid.NewGuid() }).Entity;
|
||||
state.LastEncounterSyncAt = DateTimeOffset.UtcNow;
|
||||
state.InitialSyncCompleted = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("Encounter replica sync completed — {Count} encounters", syncedCount);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Encounter replica sync failed — central API unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> SyncEncountersAsync(
|
||||
HttpClient client, GatewayDbContext db, CancellationToken ct)
|
||||
{
|
||||
var dept = Uri.EscapeDataString(_gateway.Department);
|
||||
var summaries = new List<CentralWardEncounterSummary>();
|
||||
var page = 1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var listResp = await client.GetFromJsonAsync<ApiResponse<CentralEncounterListPage>>(
|
||||
$"/api/v1/encounters?status=ACTIVE&department={dept}&page={page}&pageSize={EncounterPageSize}",
|
||||
CentralApiJson.Options,
|
||||
ct);
|
||||
|
||||
if (listResp?.Data?.Items is null || listResp.Data.Items.Count == 0)
|
||||
break;
|
||||
|
||||
summaries.AddRange(listResp.Data.Items);
|
||||
|
||||
if (page >= listResp.Data.TotalPages)
|
||||
break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
var synced = 0;
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
var detailResp = await client.GetFromJsonAsync<ApiResponse<CentralEncounterDetail>>(
|
||||
$"/api/v1/encounters/{summary.EncounterId}",
|
||||
CentralApiJson.Options,
|
||||
ct);
|
||||
|
||||
if (detailResp?.Data is null)
|
||||
{
|
||||
_logger.LogWarning("Central returned no detail for encounter {EncounterId}", summary.EncounterId);
|
||||
continue;
|
||||
}
|
||||
|
||||
UpsertReplica(db, detailResp.Data);
|
||||
synced++;
|
||||
}
|
||||
|
||||
return synced;
|
||||
}
|
||||
|
||||
private static void UpsertReplica(GatewayDbContext db, CentralEncounterDetail detail)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var patient = detail.Patient;
|
||||
|
||||
var replicaPatient = db.Patients.Find(patient.Id);
|
||||
if (replicaPatient is null)
|
||||
{
|
||||
db.Patients.Add(new ReplicaPatient
|
||||
{
|
||||
Id = patient.Id,
|
||||
Mrn = patient.Mrn,
|
||||
FirstName = patient.FirstName,
|
||||
LastName = patient.LastName,
|
||||
DateOfBirth = patient.DateOfBirth,
|
||||
Gender = patient.Gender,
|
||||
SyncedAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
replicaPatient.Mrn = patient.Mrn;
|
||||
replicaPatient.FirstName = patient.FirstName;
|
||||
replicaPatient.LastName = patient.LastName;
|
||||
replicaPatient.DateOfBirth = patient.DateOfBirth;
|
||||
replicaPatient.Gender = patient.Gender;
|
||||
replicaPatient.SyncedAt = now;
|
||||
}
|
||||
|
||||
var enc = db.Encounters.Find(detail.Id);
|
||||
if (enc is null)
|
||||
{
|
||||
db.Encounters.Add(new ReplicaEncounter
|
||||
{
|
||||
Id = detail.Id,
|
||||
PatientId = detail.PatientId,
|
||||
EncounterType = detail.EncounterType,
|
||||
Status = detail.Status,
|
||||
Department = detail.Department,
|
||||
AttendingPhysician = detail.AttendingPhysician,
|
||||
RoomBed = detail.RoomBed,
|
||||
AdmittedAt = detail.AdmittedAt,
|
||||
SyncedAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
enc.PatientId = detail.PatientId;
|
||||
enc.EncounterType = detail.EncounterType;
|
||||
enc.Status = detail.Status;
|
||||
enc.Department = detail.Department;
|
||||
enc.AttendingPhysician = detail.AttendingPhysician;
|
||||
enc.RoomBed = detail.RoomBed;
|
||||
enc.AdmittedAt = detail.AdmittedAt;
|
||||
enc.SyncedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncThresholdsAsync(
|
||||
HttpClient client,
|
||||
GatewayDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var thrResp = await client.GetFromJsonAsync<ApiResponse<List<CentralAlertThreshold>>>(
|
||||
"/api/v1/alert-thresholds",
|
||||
CentralApiJson.Options,
|
||||
ct);
|
||||
|
||||
if (thrResp?.Data is null)
|
||||
return;
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("DELETE FROM alert_thresholds", ct);
|
||||
var syncedAt = DateTimeOffset.UtcNow;
|
||||
db.AlertThresholds.AddRange(thrResp.Data.Select(t => new ReplicaAlertThreshold
|
||||
{
|
||||
Id = t.Id,
|
||||
ObservationCode = t.ObservationCode,
|
||||
DisplayName = t.DisplayName,
|
||||
Unit = t.Unit,
|
||||
CriticalLow = t.CriticalLow,
|
||||
WarningLow = t.WarningLow,
|
||||
WarningHigh = t.WarningHigh,
|
||||
CriticalHigh = t.CriticalHigh,
|
||||
SuppressionWindowMinutes = t.SuppressionWindowMinutes,
|
||||
SyncedAt = syncedAt
|
||||
}));
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await ThresholdCacheLoader.RefreshAsync(db, redis, _logger, ct);
|
||||
}
|
||||
|
||||
private static void ConfigureAuth(HttpClient client)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = null;
|
||||
|
||||
var jwt = Environment.GetEnvironmentVariable("GATEWAY_SYNC_JWT");
|
||||
if (!string.IsNullOrEmpty(jwt))
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class GatewayHeartbeatService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly CentralReachabilityService _reachability;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<GatewayHeartbeatService> _logger;
|
||||
|
||||
public GatewayHeartbeatService(
|
||||
IServiceScopeFactory scopes,
|
||||
IHttpClientFactory http,
|
||||
CentralReachabilityService reachability,
|
||||
IOptions<GatewayOptions> gateway,
|
||||
IConfiguration config,
|
||||
ILogger<GatewayHeartbeatService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_http = http;
|
||||
_reachability = reachability;
|
||||
_gateway = gateway.Value;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_gateway.HeartbeatIntervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
await SendHeartbeatAsync(ct);
|
||||
}
|
||||
|
||||
private async Task SendHeartbeatAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var bufferDepth = await db.BufferedSyncItems.CountAsync(b => !b.Synced, ct);
|
||||
|
||||
var status = _reachability.IsCentralReachable ? "ONLINE" : "DEGRADED";
|
||||
var req = new GatewayHeartbeatRequest(status, bufferDepth, DateTimeOffset.UtcNow);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient("central");
|
||||
client.BaseAddress = new Uri(
|
||||
scope.ServiceProvider.GetRequiredService<IOptions<CentralApiOptions>>().Value.BaseUrl);
|
||||
client.DefaultRequestHeaders.Remove("X-Api-Key");
|
||||
client.DefaultRequestHeaders.Remove("X-Gateway-Id");
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", _config["ApiKey:Gateway"]);
|
||||
client.DefaultRequestHeaders.Add("X-Gateway-Id", _gateway.GatewayId.ToString());
|
||||
|
||||
var resp = await client.PatchAsJsonAsync(
|
||||
$"/api/v1/gateways/{_gateway.GatewayId}/heartbeat", req, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
_logger.LogWarning("Heartbeat failed: {Status}", resp.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Heartbeat to central failed — gateway continues in DEGRADED mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class LocalEscalationWorkerService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<RabbitMqOptions> _opts;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<LocalEscalationWorkerService> _logger;
|
||||
|
||||
public LocalEscalationWorkerService(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<LocalEscalationWorkerService> logger)
|
||||
{
|
||||
_opts = opts;
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("gateway-escalation-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
channel.BasicQos(0, prefetchCount: 5, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (_, ea) =>
|
||||
{
|
||||
await HandleEscalationAsync(channel, ea, stoppingToken);
|
||||
};
|
||||
|
||||
channel.BasicConsume("alerts.escalation.queue", autoAck: false, consumer);
|
||||
|
||||
_logger.LogInformation("LocalEscalationWorkerService consuming alerts.escalation.queue");
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandleEscalationAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
|
||||
|
||||
_logger.LogCritical("[ESCALATION] Paging on-call backup for alert {AlertId}", alertId);
|
||||
|
||||
try
|
||||
{
|
||||
var escalated = await UpdateAlertStatusEscalatedAsync(alertId, ct);
|
||||
channel.BasicAck(ea.DeliveryTag, multiple: false);
|
||||
|
||||
if (escalated)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[ESCALATION-DONE] Alert {AlertId} status → Escalated in gateway DB", alertId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Local escalation worker failed for alert {AlertId}", alertId);
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateAlertStatusEscalatedAsync(Guid alertId, CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var alert = await db.ClinicalAlerts.FindAsync([alertId], ct);
|
||||
if (alert is null) return false;
|
||||
|
||||
if (alert.Status != AlertStatus.Open) return false;
|
||||
|
||||
alert.Status = AlertStatus.Escalated;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class LocalPagingWorkerService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<RabbitMqOptions> _opts;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<LocalPagingWorkerService> _logger;
|
||||
|
||||
public LocalPagingWorkerService(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<LocalPagingWorkerService> logger)
|
||||
{
|
||||
_opts = opts;
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("gateway-paging-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (_, ea) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandlePageAsync(channel, ea, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Local paging worker stopping — requeueing in-flight page message");
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Local paging worker failed — NACKing to DLQ");
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
|
||||
}
|
||||
};
|
||||
|
||||
channel.BasicConsume("alerts.paging.queue", autoAck: false, consumer);
|
||||
|
||||
_logger.LogInformation(
|
||||
"LocalPagingWorkerService consuming alerts.paging.queue (prefetch=1, timeout={Timeout}ms)",
|
||||
o.PagingAckTimeoutMs);
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandlePageAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var o = _opts.Value;
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
|
||||
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
|
||||
var details = doc.RootElement.TryGetProperty("details", out var d) ? d.GetString() : null;
|
||||
var physician = doc.RootElement.TryGetProperty("attendingPhysician", out var p)
|
||||
? p.GetString()
|
||||
: "unknown";
|
||||
|
||||
_logger.LogWarning(
|
||||
"[PAGE] Paging attending physician '{Physician}' for encounter {EncounterId} — " +
|
||||
"AlertId={AlertId} Details={Details}",
|
||||
physician, encounterId, alertId, details);
|
||||
|
||||
var deadline = DateTimeOffset.UtcNow.AddMilliseconds(o.PagingAckTimeoutMs);
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline && !ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(2_000, ct);
|
||||
|
||||
if (await IsAlertAcknowledgedAsync(alertId, ct))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[PAGE-ACK] Alert {AlertId} acknowledged — ACKing RabbitMQ message", alertId);
|
||||
channel.BasicAck(ea.DeliveryTag, multiple: false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"[PAGE-TIMEOUT] No acknowledgment within {TimeoutMs}ms for alert {AlertId} — " +
|
||||
"NACKing to DLQ for escalation",
|
||||
o.PagingAckTimeoutMs, alertId);
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
|
||||
}
|
||||
|
||||
private async Task<bool> IsAlertAcknowledgedAsync(Guid alertId, CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var status = await db.ClinicalAlerts
|
||||
.Where(a => a.Id == alertId)
|
||||
.Select(a => a.Status)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return status is AlertStatus.Acknowledged or AlertStatus.Resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using VigilCare.ClinicalContracts.Sync;
|
||||
|
||||
public sealed class SyncUploaderService : BackgroundService
|
||||
{
|
||||
private static readonly JsonSerializerOptions PayloadJson = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly CentralReachabilityService _reachability;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<SyncUploaderService> _logger;
|
||||
private int _backoffSeconds = 30;
|
||||
|
||||
public SyncUploaderService(
|
||||
IServiceScopeFactory scopes,
|
||||
CentralReachabilityService reachability,
|
||||
IOptions<GatewayOptions> gateway,
|
||||
IConfiguration config,
|
||||
ILogger<SyncUploaderService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_reachability = reachability;
|
||||
_gateway = gateway.Value;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (!_reachability.IsCentralReachable) continue;
|
||||
try
|
||||
{
|
||||
await UploadBatchAsync(ct);
|
||||
_backoffSeconds = 30;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Sync upload failed — backoff {Seconds}s", _backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(_backoffSeconds), ct);
|
||||
_backoffSeconds = Math.Min(_backoffSeconds * 2, 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadBatchAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var pending = await db.BufferedSyncItems
|
||||
.Where(b => !b.Synced)
|
||||
.OrderBy(b => b.RecordedAt)
|
||||
.Take(_gateway.SyncBatchSize)
|
||||
.ToListAsync(ct);
|
||||
if (pending.Count == 0) return;
|
||||
|
||||
var batchRef = Guid.NewGuid();
|
||||
var request = BuildBatchRequest(batchRef, pending);
|
||||
|
||||
var client = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>().CreateClient("central");
|
||||
client.BaseAddress = new Uri(
|
||||
scope.ServiceProvider.GetRequiredService<IOptions<CentralApiOptions>>().Value.BaseUrl);
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", _config["ApiKey:Gateway"]!);
|
||||
client.DefaultRequestHeaders.Add("X-Gateway-Id", _gateway.GatewayId.ToString());
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/sync/batches", request, ct);
|
||||
submit.EnsureSuccessStatusCode();
|
||||
var batchId = (await submit.Content.ReadFromJsonAsync<ApiResponse<SyncBatchStatus>>(ct))!.Data!.BatchId;
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
await Task.Delay(2000, ct);
|
||||
var status = await client.GetFromJsonAsync<ApiResponse<SyncBatchStatus>>(
|
||||
$"/api/v1/sync/batches/{batchId}", ct);
|
||||
if (status?.Data?.Status is "APPLIED")
|
||||
{
|
||||
foreach (var item in pending)
|
||||
{
|
||||
item.Synced = true;
|
||||
item.SyncedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation(
|
||||
"Sync batch {BatchId} applied — marked {Count} buffered items synced",
|
||||
batchId, pending.Count);
|
||||
return;
|
||||
}
|
||||
if (status?.Data?.Status is "CONFLICT" or "REJECTED")
|
||||
{
|
||||
_logger.LogError(
|
||||
"Sync batch {BatchId} {Status} — items remain buffered",
|
||||
batchId, status.Data.Status);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Sync batch {BatchId} still processing after poll window", batchId);
|
||||
}
|
||||
|
||||
private ClinicalSyncBatchRequest BuildBatchRequest(Guid batchRef, List<BufferedSyncItem> items)
|
||||
{
|
||||
var observations = new List<SyncedObservation>();
|
||||
var alertEvents = new List<SyncedAlertEvent>();
|
||||
var acks = new List<SyncedAlertAcknowledgment>();
|
||||
var resolves = new List<SyncedAlertResolution>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
switch (item.ItemType)
|
||||
{
|
||||
case BufferedSyncItemType.Observation:
|
||||
observations.Add(MapObservation(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Alert:
|
||||
alertEvents.Add(MapAlert(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Ack:
|
||||
acks.Add(MapAck(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Resolve:
|
||||
resolves.Add(MapResolve(item));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown buffered sync item type '{item.ItemType}' for item {item.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
return new ClinicalSyncBatchRequest(
|
||||
batchRef,
|
||||
_gateway.GatewayId,
|
||||
_gateway.SiteId,
|
||||
DateTimeOffset.UtcNow,
|
||||
observations,
|
||||
alertEvents,
|
||||
acks,
|
||||
resolves);
|
||||
}
|
||||
|
||||
private static SyncedObservation MapObservation(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedObservationPayload>(item.Payload);
|
||||
return new SyncedObservation(
|
||||
p.ClientRef,
|
||||
p.IdempotencyKey ?? item.IdempotencyKey,
|
||||
p.EncounterId,
|
||||
p.ObservationCode,
|
||||
p.Value,
|
||||
p.Unit,
|
||||
p.Source,
|
||||
p.RecordedAt);
|
||||
}
|
||||
|
||||
private static SyncedAlertEvent MapAlert(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedAlertPayload>(item.Payload);
|
||||
return new SyncedAlertEvent(
|
||||
p.ClientAlertId,
|
||||
p.EncounterId,
|
||||
p.AlertType,
|
||||
p.Severity,
|
||||
p.Details,
|
||||
p.GeneratedAt);
|
||||
}
|
||||
|
||||
private static SyncedAlertAcknowledgment MapAck(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedAckPayload>(item.Payload);
|
||||
return new SyncedAlertAcknowledgment(
|
||||
p.ClientRef,
|
||||
p.ClientAlertId,
|
||||
p.ClinicianId,
|
||||
p.AcknowledgedAt,
|
||||
p.Note);
|
||||
}
|
||||
|
||||
private static SyncedAlertResolution MapResolve(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedResolvePayload>(item.Payload);
|
||||
return new SyncedAlertResolution(
|
||||
p.ClientRef,
|
||||
p.ClientAlertId,
|
||||
p.ClinicianId,
|
||||
p.ResolvedAt,
|
||||
p.Note);
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(string json) =>
|
||||
JsonSerializer.Deserialize<T>(json, PayloadJson)
|
||||
?? throw new JsonException($"Failed to deserialize buffered sync payload as {typeof(T).Name}");
|
||||
|
||||
private sealed record BufferedObservationPayload(
|
||||
Guid ClientRef,
|
||||
string? IdempotencyKey,
|
||||
Guid EncounterId,
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string? Unit,
|
||||
string Source,
|
||||
DateTimeOffset RecordedAt);
|
||||
|
||||
private sealed record BufferedAlertPayload(
|
||||
Guid ClientAlertId,
|
||||
Guid EncounterId,
|
||||
string AlertType,
|
||||
string Severity,
|
||||
string Details,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
private sealed record BufferedAckPayload(
|
||||
Guid ClientRef,
|
||||
Guid ClientAlertId,
|
||||
string ClinicianId,
|
||||
DateTimeOffset AcknowledgedAt,
|
||||
string? Note);
|
||||
|
||||
private sealed record BufferedResolvePayload(
|
||||
Guid ClientRef,
|
||||
Guid ClientAlertId,
|
||||
string ClinicianId,
|
||||
DateTimeOffset ResolvedAt,
|
||||
string? Note);
|
||||
|
||||
private sealed record SyncBatchStatus(Guid BatchId, string Status);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class ThresholdCacheLoader : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<ThresholdCacheLoader> _logger;
|
||||
|
||||
public ThresholdCacheLoader(
|
||||
IServiceProvider services,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<ThresholdCacheLoader> logger)
|
||||
{
|
||||
_services = services;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
await RefreshAsync(db, _redis, _logger, cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Reloads all gateway alert thresholds into Redis. Called on startup and after
|
||||
/// <see cref="EncounterReplicaSyncService"/> pulls thresholds from central.
|
||||
/// </summary>
|
||||
public static async Task RefreshAsync(
|
||||
GatewayDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var thresholds = await db.AlertThresholds.ToListAsync(cancellationToken);
|
||||
if (thresholds.Count == 0)
|
||||
{
|
||||
logger.LogWarning("No alert thresholds in gateway DB — Redis cache not updated");
|
||||
return;
|
||||
}
|
||||
|
||||
const int maxAttempts = 3;
|
||||
int[] backoffMs = [2000, 4000, 8000];
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
WriteThresholds(redis, thresholds);
|
||||
logger.LogInformation(
|
||||
"Loaded {Count} alert thresholds into gateway Redis cache", thresholds.Count);
|
||||
return;
|
||||
}
|
||||
catch (RedisException ex)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"Redis unavailable during gateway threshold cache load — attempt {Attempt}/{Max}",
|
||||
attempt + 1, maxAttempts);
|
||||
|
||||
if (attempt < maxAttempts - 1)
|
||||
await Task.Delay(backoffMs[attempt], cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Failed to load thresholds into gateway Redis after {Max} attempts — " +
|
||||
"observation ingest falls back to PostgreSQL for threshold lookups",
|
||||
maxAttempts);
|
||||
}
|
||||
|
||||
private static void WriteThresholds(IConnectionMultiplexer redis, List<ReplicaAlertThreshold> thresholds)
|
||||
{
|
||||
var cache = redis.GetDatabase();
|
||||
var batch = cache.CreateBatch();
|
||||
|
||||
foreach (var t in thresholds)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new ThresholdCacheEntry(
|
||||
t.ObservationCode,
|
||||
t.CriticalLow,
|
||||
t.WarningLow,
|
||||
t.WarningHigh,
|
||||
t.CriticalHigh));
|
||||
_ = batch.StringSetAsync($"threshold:{t.ObservationCode}", json);
|
||||
}
|
||||
|
||||
batch.Execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error)
|
||||
{
|
||||
public static ApiResponse<T> Ok(T data) =>
|
||||
new(true, 200, data, null);
|
||||
|
||||
public static ApiResponse<T> Created(T data) =>
|
||||
new(true, 201, data, null);
|
||||
|
||||
public static ApiResponse<T> Fail(int statusCode, string message, string code) =>
|
||||
new(false, statusCode, default, new ApiError(message, code));
|
||||
}
|
||||
|
||||
public record ApiError(string Message, string Code);
|
||||
@@ -0,0 +1 @@
|
||||
public record CursorPage<T>(List<T> Items, string? NextCursor, bool HasMore);
|
||||
@@ -0,0 +1,5 @@
|
||||
public class ConflictException : DomainException
|
||||
{
|
||||
public ConflictException(string message, string errorCode = "CONFLICT_ERROR")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public static class DbExceptions
|
||||
{
|
||||
public static bool IsUniqueViolation(DbUpdateException ex) =>
|
||||
ex.InnerException?.Message.Contains("23505") == true
|
||||
|| ex.InnerException?.Message.Contains("unique constraint") == true;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public abstract class DomainException : Exception
|
||||
{
|
||||
public string ErrorCode { get; }
|
||||
|
||||
protected DomainException(string message, string errorCode) : base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public class NotFoundException : DomainException
|
||||
{
|
||||
public NotFoundException(string message, string errorCode = "NOT_FOUND")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public class ValidationException : DomainException
|
||||
{
|
||||
public ValidationException(string message, string errorCode = "VALIDATION_ERROR")
|
||||
: base(message, errorCode) { }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public record PagedResult<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalCount
|
||||
)
|
||||
{
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount/PageSize);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public sealed class CentralApiOptions
|
||||
{
|
||||
public const string Section = "CentralApi";
|
||||
public string BaseUrl { get; init; } = "http://localhost:5080";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
public const string Section = "Gateway";
|
||||
public Guid GatewayId { get; init; }
|
||||
public Guid SiteId { get; init; }
|
||||
public string Department { get; init; } = "ICU";
|
||||
public int EncounterSyncIntervalMinutes { get; init; } = 5;
|
||||
public int CentralReachabilityIntervalSeconds { get; init; } = 30;
|
||||
public int HeartbeatIntervalSeconds { get; init; } = 60;
|
||||
public int SyncBatchSize { get; init; } = 500;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public sealed class RabbitMqOptions
|
||||
{
|
||||
public const string Section = "RabbitMq";
|
||||
public string Host { get; init; } = "localhost";
|
||||
public int Port { get; init; } = 5674;
|
||||
public string Username { get; init; } = "guest";
|
||||
public string Password { get; init; } = "guest";
|
||||
// Drives both the paging worker poll timeout and the DLQ x-message-ttl.
|
||||
// In production: 300000 (5 min). In tests: 5000 (5 sec).
|
||||
public int PagingAckTimeoutMs { get; init; } = 300000;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public class SuppressionOptions
|
||||
{
|
||||
public const string SectionName = "AlertSuppression";
|
||||
|
||||
/// <summary>Default suppression window after acknowledgment (minutes).</summary>
|
||||
public int DefaultWindowMinutes { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AlertsController : ControllerBase
|
||||
{
|
||||
private readonly ILocalAlertService _alerts;
|
||||
|
||||
public AlertsController(ILocalAlertService alerts) => _alerts = alerts;
|
||||
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/alerts")]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
Guid encounterId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(status) && !TryParseStatus(status, out _))
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
|
||||
|
||||
var result = await _alerts.ListAsync(encounterId, status, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/alerts")]
|
||||
public async Task<IActionResult> ListGlobal(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(status) && !TryParseStatus(status, out _))
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
|
||||
|
||||
var result = await _alerts.ListAsync(null, status, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
|
||||
public async Task<IActionResult> Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req)
|
||||
{
|
||||
var clinicianId = User.Identity?.Name ?? "unknown";
|
||||
var alert = await _alerts.AcknowledgeAsync(id, req, clinicianId);
|
||||
return Ok(ApiResponse<LocalClinicalAlert>.Ok(alert));
|
||||
}
|
||||
|
||||
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
|
||||
public async Task<IActionResult> Resolve(Guid id)
|
||||
{
|
||||
var alert = await _alerts.ResolveAsync(id);
|
||||
return Ok(ApiResponse<LocalClinicalAlert>.Ok(alert));
|
||||
}
|
||||
|
||||
private static bool TryParseStatus(string status, out AlertStatus _)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = AlertStatusExtensions.FromDbString(status);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
_ = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Stubs for central-only scoring and bundle endpoints — returns 503 during degraded mode.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}")]
|
||||
[Authorize]
|
||||
public class CentralRequiredController : ControllerBase
|
||||
{
|
||||
[HttpGet("news2")]
|
||||
public IActionResult News2History(Guid encounterId) =>
|
||||
CentralRequired("NEWS2 history requires central connection.");
|
||||
|
||||
[HttpGet("news2/current")]
|
||||
public IActionResult News2Current(Guid encounterId) =>
|
||||
CentralRequired("NEWS2 current score requires central connection.");
|
||||
|
||||
[HttpGet("qsofa/current")]
|
||||
public IActionResult QsofaCurrent(Guid encounterId) =>
|
||||
CentralRequired("qSOFA current score requires central connection.");
|
||||
|
||||
[HttpGet("sofa/current")]
|
||||
public IActionResult SofaCurrent(Guid encounterId) =>
|
||||
CentralRequired("SOFA current score requires central connection.");
|
||||
|
||||
[HttpGet("sepsis-bundle/current")]
|
||||
public IActionResult SepsisBundleCurrent(Guid encounterId) =>
|
||||
CentralRequired("Sepsis bundle status requires central connection.");
|
||||
|
||||
private static IActionResult CentralRequired(string message) =>
|
||||
new ObjectResult(ApiResponse<object>.Fail(503, message, "CENTRAL_REQUIRED"))
|
||||
{
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class EncountersController : ControllerBase
|
||||
{
|
||||
private readonly IEncounterReadService _encounters;
|
||||
|
||||
public EncountersController(IEncounterReadService encounters) => _encounters = encounters;
|
||||
|
||||
[HttpGet("api/v1/encounters")]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? department)
|
||||
{
|
||||
if (status is not null and not "ACTIVE")
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Gateway supports ACTIVE only.", "INVALID_STATUS"));
|
||||
|
||||
if (!string.IsNullOrEmpty(department) && !TryParseDepartment(department, out _))
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid department filter.", "INVALID_DEPARTMENT"));
|
||||
|
||||
var items = await _encounters.ListActiveAsync(department);
|
||||
return Ok(ApiResponse<object>.Ok(new { items }));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/encounters/{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var detail = await _encounters.GetByIdAsync(id);
|
||||
if (detail is null)
|
||||
return NotFound(ApiResponse<object>.Fail(404, "Encounter not found.", "ENCOUNTER_NOT_FOUND"));
|
||||
|
||||
return Ok(ApiResponse<EncounterDetail>.Ok(detail));
|
||||
}
|
||||
|
||||
private static bool TryParseDepartment(string department, out Department _)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = DepartmentExtensions.FromDbString(department);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
_ = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/observations")]
|
||||
[Authorize]
|
||||
public class ObservationsController : ControllerBase
|
||||
{
|
||||
private readonly ILocalObservationService _observations;
|
||||
private readonly IObservationQueryService _query;
|
||||
|
||||
public ObservationsController(
|
||||
ILocalObservationService observations,
|
||||
IObservationQueryService query)
|
||||
{
|
||||
_observations = observations;
|
||||
_query = query;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Ingest(Guid encounterId, [FromBody] IngestObservationRequest req)
|
||||
{
|
||||
var result = await _observations.IngestAsync(encounterId, req);
|
||||
if (result.IsDuplicate)
|
||||
return Ok(ApiResponse<object>.Ok(new { duplicate = true, result.Observation }));
|
||||
return StatusCode(201, ApiResponse<object>.Created(new
|
||||
{
|
||||
observation = result.Observation,
|
||||
alert = result.Alert
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> History(
|
||||
Guid encounterId,
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
var page = await _query.GetHistoryAsync(encounterId, code, from, to, limit, cursor);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = page.Items,
|
||||
nextCursor = page.NextCursor,
|
||||
hasMore = page.HasMore
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class BufferedSyncItemConfiguration : IEntityTypeConfiguration<BufferedSyncItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BufferedSyncItem> builder)
|
||||
{
|
||||
builder.ToTable("buffered_sync_items", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_buffered_sync_items_item_type",
|
||||
"item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
builder.HasKey(b => b.Id);
|
||||
builder.Property(b => b.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(b => b.ItemType)
|
||||
.HasColumnName("item_type")
|
||||
.HasMaxLength(32)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => BufferedSyncItemTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb").IsRequired();
|
||||
builder.Property(b => b.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(200).IsRequired();
|
||||
builder.Property(b => b.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(b => b.RecordedAt).HasColumnName("recorded_at");
|
||||
builder.Property(b => b.Synced).HasColumnName("synced").HasDefaultValue(false);
|
||||
builder.Property(b => b.SyncedAt).HasColumnName("synced_at");
|
||||
builder.Property(b => b.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(b => b.IdempotencyKey)
|
||||
.IsUnique()
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
builder.HasIndex(b => b.CreatedAt)
|
||||
.HasFilter("NOT synced");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class GatewaySyncStateConfiguration : IEntityTypeConfiguration<GatewaySyncState>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GatewaySyncState> builder)
|
||||
{
|
||||
builder.ToTable("gateway_sync_state");
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(s => s.LastEncounterSyncAt).HasColumnName("last_encounter_sync_at");
|
||||
builder.Property(s => s.InitialSyncCompleted)
|
||||
.HasColumnName("initial_sync_completed")
|
||||
.HasDefaultValue(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class LocalClinicalAlertConfiguration : IEntityTypeConfiguration<LocalClinicalAlert>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LocalClinicalAlert> builder)
|
||||
{
|
||||
builder.ToTable("clinical_alerts", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity",
|
||||
"severity IN ('WARNING', 'CRITICAL')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status",
|
||||
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
|
||||
"alert_type IN (" +
|
||||
"'SEPSIS_WARNING', " +
|
||||
"'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', " +
|
||||
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
|
||||
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
|
||||
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " +
|
||||
"'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', " +
|
||||
"'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', " +
|
||||
"'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
|
||||
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
|
||||
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
|
||||
"'WARNING_GLUCOSE_MG_DL', " +
|
||||
"'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', " +
|
||||
"'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', " +
|
||||
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
|
||||
"'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
|
||||
"'GCS_CRITICAL', 'GCS_WARNING', " +
|
||||
"'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
});
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(a => a.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(a => a.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(a => a.ObservationId).HasColumnName("observation_id");
|
||||
builder.Property(a => a.AlertType)
|
||||
.HasColumnName("alert_type")
|
||||
.HasMaxLength(50)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Severity)
|
||||
.HasColumnName("severity")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertSeverityExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(a => a.Details).HasColumnName("details").IsRequired();
|
||||
builder.Property(a => a.ObservationCode).HasColumnName("observation_code").HasMaxLength(50);
|
||||
builder.Property(a => a.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => AlertStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'OPEN'")
|
||||
.HasSentinel((AlertStatus)(-1));
|
||||
builder.Property(a => a.AcknowledgedAt).HasColumnName("acknowledged_at");
|
||||
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
|
||||
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
||||
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id").IsRequired();
|
||||
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(a => a.Encounter)
|
||||
.WithMany(e => e.Alerts)
|
||||
.HasForeignKey(a => a.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(a => new { a.EncounterId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.PatientId, a.TriggeredAt });
|
||||
builder.HasIndex(a => new { a.Severity, a.TriggeredAt })
|
||||
.HasFilter("status = 'OPEN'");
|
||||
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
builder.HasIndex(a => a.ClientAlertId).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class LocalObservationConfiguration : IEntityTypeConfiguration<LocalObservation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LocalObservation> builder)
|
||||
{
|
||||
builder.ToTable("observations", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source",
|
||||
"source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(o => o.EncounterId).HasColumnName("encounter_id");
|
||||
builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
|
||||
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(o => o.Source)
|
||||
.HasColumnName("source")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => ObservationSourceExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'MANUAL'")
|
||||
.HasSentinel((ObservationSource)(-1));
|
||||
builder.Property(o => o.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(100);
|
||||
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at");
|
||||
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(o => o.Encounter)
|
||||
.WithMany(e => e.Observations)
|
||||
.HasForeignKey(o => o.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(o => o.IdempotencyKey)
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
builder.HasIndex(o => new { o.EncounterId, o.ObservationCode, o.RecordedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ReplicaAlertThresholdConfiguration : IEntityTypeConfiguration<ReplicaAlertThreshold>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReplicaAlertThreshold> builder)
|
||||
{
|
||||
builder.ToTable("alert_thresholds");
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(t => t.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(t => t.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
|
||||
builder.Property(t => t.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(t => t.CriticalLow).HasColumnName("critical_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
|
||||
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
|
||||
builder.Property(t => t.SyncedAt).HasColumnName("synced_at");
|
||||
|
||||
builder.HasIndex(t => t.ObservationCode).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ReplicaEncounterConfiguration : IEntityTypeConfiguration<ReplicaEncounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReplicaEncounter> builder)
|
||||
{
|
||||
builder.ToTable("encounters", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type",
|
||||
"encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
t.HasCheckConstraint("chk_encounters_status",
|
||||
"status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
t.HasCheckConstraint("chk_encounters_department",
|
||||
"department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
});
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(e => e.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(e => e.EncounterType)
|
||||
.HasColumnName("encounter_type")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => EncounterTypeExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(e => e.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(20)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => EncounterStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'SCHEDULED'")
|
||||
.HasSentinel((EncounterStatus)(-1));
|
||||
builder.Property(e => e.Department)
|
||||
.HasColumnName("department")
|
||||
.HasMaxLength(100)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => DepartmentExtensions.FromDbString(v))
|
||||
.IsRequired();
|
||||
builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(20);
|
||||
builder.Property(e => e.AttendingPhysician).HasColumnName("attending_physician").HasMaxLength(200).IsRequired();
|
||||
builder.Property(e => e.AdmittedAt).HasColumnName("admitted_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(e => e.SyncedAt).HasColumnName("synced_at");
|
||||
|
||||
builder.HasOne(e => e.Patient)
|
||||
.WithMany(p => p.Encounters)
|
||||
.HasForeignKey(e => e.PatientId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(e => new { e.PatientId, e.AdmittedAt });
|
||||
builder.HasIndex(e => new { e.Status, e.AdmittedAt })
|
||||
.HasFilter("status = 'ACTIVE'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ReplicaPatientConfiguration : IEntityTypeConfiguration<ReplicaPatient>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReplicaPatient> builder)
|
||||
{
|
||||
builder.ToTable("patients");
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(p => p.Mrn).HasColumnName("mrn").HasMaxLength(20).IsRequired();
|
||||
builder.Property(p => p.FirstName).HasColumnName("first_name").HasMaxLength(100).IsRequired();
|
||||
builder.Property(p => p.LastName).HasColumnName("last_name").HasMaxLength(100).IsRequired();
|
||||
builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth");
|
||||
builder.Property(p => p.Gender).HasColumnName("gender").HasMaxLength(10).IsRequired();
|
||||
builder.Property(p => p.SyncedAt).HasColumnName("synced_at");
|
||||
|
||||
builder.HasIndex(p => p.Mrn).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class SyncOutboxEntryConfiguration : IEntityTypeConfiguration<SyncOutboxEntry>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SyncOutboxEntry> builder)
|
||||
{
|
||||
builder.ToTable("sync_outbox");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(e => e.BatchId).HasColumnName("batch_id");
|
||||
builder.Property(e => e.Status)
|
||||
.HasColumnName("status")
|
||||
.HasMaxLength(16)
|
||||
.HasConversion(
|
||||
v => v.ToDbString(),
|
||||
v => SyncOutboxStatusExtensions.FromDbString(v))
|
||||
.HasDefaultValueSql("'PENDING'")
|
||||
.HasSentinel((SyncOutboxStatus)(-1));
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(e => e.SubmittedAt).HasColumnName("submitted_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class GatewayDbContext : DbContext
|
||||
{
|
||||
public GatewayDbContext(DbContextOptions<GatewayDbContext> opts) : base(opts) { }
|
||||
|
||||
public DbSet<ReplicaPatient> Patients => Set<ReplicaPatient>();
|
||||
public DbSet<ReplicaEncounter> Encounters => Set<ReplicaEncounter>();
|
||||
public DbSet<ReplicaAlertThreshold> AlertThresholds => Set<ReplicaAlertThreshold>();
|
||||
public DbSet<LocalObservation> Observations => Set<LocalObservation>();
|
||||
public DbSet<LocalClinicalAlert> ClinicalAlerts => Set<LocalClinicalAlert>();
|
||||
public DbSet<BufferedSyncItem> BufferedSyncItems => Set<BufferedSyncItem>();
|
||||
public DbSet<SyncOutboxEntry> SyncOutbox => Set<SyncOutboxEntry>();
|
||||
public DbSet<GatewaySyncState> SyncState => Set<GatewaySyncState>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(GatewayDbContext).Assembly);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY VigilCareClinical.sln ./
|
||||
COPY VigilCare.ClinicalContracts/ VigilCare.ClinicalContracts/
|
||||
COPY VigilCare.WardGateway/ VigilCare.WardGateway/
|
||||
RUN dotnet restore VigilCare.WardGateway/VigilCare.WardGateway.csproj
|
||||
RUN dotnet publish VigilCare.WardGateway/VigilCare.WardGateway.csproj -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "VigilCare.WardGateway.dll"]
|
||||
@@ -0,0 +1,12 @@
|
||||
public class BufferedSyncItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public BufferedSyncItemType ItemType { get; set; }
|
||||
public string Payload { get; set; } = null!;
|
||||
public string IdempotencyKey { get; set; } = null!;
|
||||
public Guid EncounterId { get; set; }
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
public bool Synced { get; set; }
|
||||
public DateTimeOffset? SyncedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public class GatewaySyncState
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTimeOffset? LastEncounterSyncAt { get; set; }
|
||||
public bool InitialSyncCompleted { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
public class LocalClinicalAlert
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public Guid? ObservationId { get; set; }
|
||||
public AlertType AlertType { get; set; }
|
||||
public AlertSeverity Severity { get; set; }
|
||||
public string Details { get; set; } = null!;
|
||||
public string? ObservationCode { get; set; }
|
||||
public AlertStatus Status { get; set; } = AlertStatus.Open;
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public DateTimeOffset TriggeredAt { get; set; }
|
||||
public Guid ClientAlertId { get; set; }
|
||||
|
||||
public ReplicaEncounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
public class LocalObservation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public decimal Value { get; set; }
|
||||
public string Unit { get; set; } = null!;
|
||||
public ObservationSource Source { get; set; } = ObservationSource.Manual;
|
||||
public string? IdempotencyKey { get; set; }
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public ReplicaEncounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public class ReplicaAlertThreshold
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string Unit { get; set; } = null!;
|
||||
public decimal? CriticalLow { get; set; }
|
||||
public decimal? WarningLow { get; set; }
|
||||
public decimal? WarningHigh { get; set; }
|
||||
public decimal? CriticalHigh { get; set; }
|
||||
public int? SuppressionWindowMinutes { get; set; }
|
||||
public DateTimeOffset SyncedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
public class ReplicaEncounter
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public EncounterType EncounterType { get; set; } = EncounterType.Inpatient;
|
||||
public EncounterStatus Status { get; set; }
|
||||
public Department Department { get; set; }
|
||||
public string AttendingPhysician { get; set; } = null!;
|
||||
public string? RoomBed { get; set; }
|
||||
public DateTimeOffset AdmittedAt { get; set; }
|
||||
public DateTimeOffset SyncedAt { get; set; }
|
||||
|
||||
public ReplicaPatient Patient { get; set; } = null!;
|
||||
public ICollection<LocalObservation> Observations { get; set; } = new List<LocalObservation>();
|
||||
public ICollection<LocalClinicalAlert> Alerts { get; set; } = new List<LocalClinicalAlert>();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
public class ReplicaPatient
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Mrn { get; set; } = null!;
|
||||
public string FirstName { get; set; } = null!;
|
||||
public string LastName { get; set; } = null!;
|
||||
public DateOnly DateOfBirth { get; set; }
|
||||
public string Gender { get; set; } = null!;
|
||||
public DateTimeOffset SyncedAt { get; set; }
|
||||
|
||||
public ICollection<ReplicaEncounter> Encounters { get; set; } = new List<ReplicaEncounter>();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public class SyncOutboxEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
public SyncOutboxStatus Status { get; set; } = SyncOutboxStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public enum AlertSeverity { Warning, Critical }
|
||||
|
||||
public static class AlertSeverityExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertSeverity s) => s switch
|
||||
{
|
||||
AlertSeverity.Warning => "WARNING",
|
||||
AlertSeverity.Critical => "CRITICAL",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertSeverity FromDbString(string v) => v switch
|
||||
{
|
||||
"WARNING" => AlertSeverity.Warning,
|
||||
"CRITICAL" => AlertSeverity.Critical,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert severity: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum AlertStatus { Open, Acknowledged, Resolved, Escalated }
|
||||
|
||||
public static class AlertStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertStatus s) => s switch
|
||||
{
|
||||
AlertStatus.Open => "OPEN",
|
||||
AlertStatus.Acknowledged => "ACKNOWLEDGED",
|
||||
AlertStatus.Resolved => "RESOLVED",
|
||||
AlertStatus.Escalated => "ESCALATED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static AlertStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"OPEN" => AlertStatus.Open,
|
||||
"ACKNOWLEDGED" => AlertStatus.Acknowledged,
|
||||
"RESOLVED" => AlertStatus.Resolved,
|
||||
"ESCALATED" => AlertStatus.Escalated,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
public enum AlertType
|
||||
{
|
||||
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
|
||||
SepsisWarning,
|
||||
CriticalHeartRate,
|
||||
CriticalTempC,
|
||||
CriticalPotassiumMeqL,
|
||||
CriticalSpo2,
|
||||
CriticalRespRate,
|
||||
CriticalWbcKUl,
|
||||
CriticalSystolicBp,
|
||||
CriticalDiastolicBp,
|
||||
CriticalLactateMmolL,
|
||||
CriticalAvpu,
|
||||
CriticalGlucoseMgDl,
|
||||
|
||||
// New — warning-level threshold alerts
|
||||
WarningHeartRate,
|
||||
WarningTempC,
|
||||
WarningPotassiumMeqL,
|
||||
WarningSpo2,
|
||||
WarningRespRate,
|
||||
WarningWbcKUl,
|
||||
WarningSystolicBp,
|
||||
WarningDiastolicBp,
|
||||
WarningLactateMmolL,
|
||||
WarningGlucoseMgDl,
|
||||
|
||||
News2Warning,
|
||||
News2Emergency,
|
||||
|
||||
RapidDeterioration,
|
||||
|
||||
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
|
||||
QsofaWarning,
|
||||
|
||||
QsofaScreen,
|
||||
|
||||
GcsCritical,
|
||||
GcsWarning,
|
||||
|
||||
CriticalPao2MmHg,
|
||||
WarningPao2MmHg,
|
||||
CriticalPlateletKUl,
|
||||
WarningPlateletKUl,
|
||||
CriticalBilirubinMgDl,
|
||||
WarningBilirubinMgDl,
|
||||
CriticalCreatinineMgDl,
|
||||
WarningCreatinineMgDl,
|
||||
|
||||
SofaSepsis,
|
||||
SofaWarning,
|
||||
}
|
||||
|
||||
public static class AlertTypeExtensions
|
||||
{
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static string ToDbString(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning => "SEPSIS_WARNING",
|
||||
AlertType.CriticalHeartRate => "CRITICAL_HEART_RATE",
|
||||
AlertType.CriticalTempC => "CRITICAL_TEMP_C",
|
||||
AlertType.CriticalPotassiumMeqL => "CRITICAL_POTASSIUM_MEQ_L",
|
||||
AlertType.CriticalSpo2 => "CRITICAL_SPO2",
|
||||
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
|
||||
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL",
|
||||
AlertType.CriticalSystolicBp => "CRITICAL_SYSTOLIC_BP",
|
||||
AlertType.CriticalDiastolicBp => "CRITICAL_DIASTOLIC_BP",
|
||||
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
|
||||
AlertType.CriticalAvpu => "CRITICAL_AVPU",
|
||||
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
|
||||
AlertType.WarningHeartRate => "WARNING_HEART_RATE",
|
||||
AlertType.WarningTempC => "WARNING_TEMP_C",
|
||||
AlertType.WarningPotassiumMeqL => "WARNING_POTASSIUM_MEQ_L",
|
||||
AlertType.WarningSpo2 => "WARNING_SPO2",
|
||||
AlertType.WarningRespRate => "WARNING_RESP_RATE",
|
||||
AlertType.WarningWbcKUl => "WARNING_WBC_K_UL",
|
||||
AlertType.WarningSystolicBp => "WARNING_SYSTOLIC_BP",
|
||||
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
|
||||
AlertType.News2Warning => "NEWS2_WARNING",
|
||||
AlertType.News2Emergency => "NEWS2_EMERGENCY",
|
||||
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
|
||||
AlertType.QsofaWarning => "QSOFA_WARNING",
|
||||
AlertType.GcsCritical => "GCS_CRITICAL",
|
||||
AlertType.GcsWarning => "GCS_WARNING",
|
||||
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
|
||||
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
|
||||
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
|
||||
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
|
||||
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
|
||||
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
|
||||
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
|
||||
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
|
||||
AlertType.SofaSepsis => "SOFA_SEPSIS",
|
||||
AlertType.SofaWarning => "SOFA_WARNING",
|
||||
AlertType.QsofaScreen => "QSOFA_SCREEN",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static AlertType FromDbString(string v) => v switch
|
||||
{
|
||||
"SEPSIS_WARNING" => AlertType.SepsisWarning,
|
||||
"CRITICAL_HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"CRITICAL_TEMP_C" => AlertType.CriticalTempC,
|
||||
"CRITICAL_POTASSIUM_MEQ_L"=> AlertType.CriticalPotassiumMeqL,
|
||||
"CRITICAL_SPO2" => AlertType.CriticalSpo2,
|
||||
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
"CRITICAL_SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||
"CRITICAL_DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
|
||||
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||
"WARNING_HEART_RATE" => AlertType.WarningHeartRate,
|
||||
"WARNING_TEMP_C" => AlertType.WarningTempC,
|
||||
"WARNING_POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||
"WARNING_SPO2" => AlertType.WarningSpo2,
|
||||
"WARNING_RESP_RATE" => AlertType.WarningRespRate,
|
||||
"WARNING_WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||
"WARNING_SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"NEWS2_WARNING" => AlertType.News2Warning,
|
||||
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
|
||||
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
|
||||
"QSOFA_WARNING" => AlertType.QsofaWarning,
|
||||
"QSOFA_SCREEN" => AlertType.QsofaScreen,
|
||||
"GCS_CRITICAL" => AlertType.GcsCritical,
|
||||
"GCS_WARNING" => AlertType.GcsWarning,
|
||||
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||
"SOFA_SEPSIS" => AlertType.SofaSepsis,
|
||||
"SOFA_WARNING" => AlertType.SofaWarning,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
|
||||
public static AlertType CriticalFor(string observationCode) => observationCode switch
|
||||
{
|
||||
"HEART_RATE" => AlertType.CriticalHeartRate,
|
||||
"TEMP_C" => AlertType.CriticalTempC,
|
||||
"POTASSIUM_MEQ_L" => AlertType.CriticalPotassiumMeqL,
|
||||
"SPO2" => AlertType.CriticalSpo2,
|
||||
"RESP_RATE" => AlertType.CriticalRespRate,
|
||||
"WBC_K_UL" => AlertType.CriticalWbcKUl,
|
||||
"SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
|
||||
"DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
|
||||
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
|
||||
"AVPU" => AlertType.CriticalAvpu,
|
||||
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
|
||||
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
|
||||
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
|
||||
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
|
||||
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
|
||||
};
|
||||
|
||||
public static AlertType WarningFor(string observationCode) => observationCode switch
|
||||
{
|
||||
"HEART_RATE" => AlertType.WarningHeartRate,
|
||||
"TEMP_C" => AlertType.WarningTempC,
|
||||
"POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
|
||||
"SPO2" => AlertType.WarningSpo2,
|
||||
"RESP_RATE" => AlertType.WarningRespRate,
|
||||
"WBC_K_UL" => AlertType.WarningWbcKUl,
|
||||
"SYSTOLIC_BP" => AlertType.WarningSystolicBp,
|
||||
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
|
||||
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
|
||||
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
|
||||
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
|
||||
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
|
||||
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
|
||||
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
|
||||
};
|
||||
|
||||
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
|
||||
public static bool IsSuppressible(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.SepsisWarning or AlertType.News2Emergency => false,
|
||||
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
|
||||
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
|
||||
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
|
||||
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
|
||||
or AlertType.CriticalGlucoseMgDl => false,
|
||||
AlertType.RapidDeterioration => false,
|
||||
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
|
||||
AlertType.SofaSepsis => false,
|
||||
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
public static string? ObservationCodeForWarning(this AlertType t) => t switch
|
||||
{
|
||||
AlertType.WarningHeartRate => "HEART_RATE",
|
||||
AlertType.WarningTempC => "TEMP_C",
|
||||
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
|
||||
AlertType.WarningSpo2 => "SPO2",
|
||||
AlertType.WarningRespRate => "RESP_RATE",
|
||||
AlertType.WarningWbcKUl => "WBC_K_UL",
|
||||
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
|
||||
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
|
||||
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
|
||||
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
|
||||
AlertType.WarningPao2MmHg => "PAO2_MMHG",
|
||||
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
|
||||
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
|
||||
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum BufferedSyncItemType { Observation, Alert, Ack, Resolve }
|
||||
|
||||
public static class BufferedSyncItemTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this BufferedSyncItemType t) => t switch
|
||||
{
|
||||
BufferedSyncItemType.Observation => "OBSERVATION",
|
||||
BufferedSyncItemType.Alert => "ALERT",
|
||||
BufferedSyncItemType.Ack => "ACK",
|
||||
BufferedSyncItemType.Resolve => "RESOLVE",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static BufferedSyncItemType FromDbString(string v) => v switch
|
||||
{
|
||||
"OBSERVATION" => BufferedSyncItemType.Observation,
|
||||
"ALERT" => BufferedSyncItemType.Alert,
|
||||
"ACK" => BufferedSyncItemType.Ack,
|
||||
"RESOLVE" => BufferedSyncItemType.Resolve,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown buffered sync item type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
public enum Department
|
||||
{
|
||||
Icu,
|
||||
GeneralMedicine,
|
||||
Emergency,
|
||||
Cardiology,
|
||||
Surgery,
|
||||
Pediatrics
|
||||
}
|
||||
|
||||
public static class DepartmentExtensions
|
||||
{
|
||||
public static string ToDbString(this Department d) => d switch
|
||||
{
|
||||
Department.Icu => "ICU",
|
||||
Department.GeneralMedicine => "GENERAL_MEDICINE",
|
||||
Department.Emergency => "EMERGENCY",
|
||||
Department.Cardiology => "CARDIOLOGY",
|
||||
Department.Surgery => "SURGERY",
|
||||
Department.Pediatrics => "PEDIATRICS",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(d))
|
||||
};
|
||||
|
||||
public static Department FromDbString(string v) => v switch
|
||||
{
|
||||
"ICU" => Department.Icu,
|
||||
"GENERAL_MEDICINE" => Department.GeneralMedicine,
|
||||
"EMERGENCY" => Department.Emergency,
|
||||
"CARDIOLOGY" => Department.Cardiology,
|
||||
"SURGERY" => Department.Surgery,
|
||||
"PEDIATRICS" => Department.Pediatrics,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown department: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public enum EncounterStatus { Scheduled, Active, Discharged, Cancelled }
|
||||
|
||||
public static class EncounterStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this EncounterStatus s) => s switch
|
||||
{
|
||||
EncounterStatus.Scheduled => "SCHEDULED",
|
||||
EncounterStatus.Active => "ACTIVE",
|
||||
EncounterStatus.Discharged => "DISCHARGED",
|
||||
EncounterStatus.Cancelled => "CANCELLED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static EncounterStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"SCHEDULED" => EncounterStatus.Scheduled,
|
||||
"ACTIVE" => EncounterStatus.Active,
|
||||
"DISCHARGED" => EncounterStatus.Discharged,
|
||||
"CANCELLED" => EncounterStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown encounter status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public enum EncounterType { Inpatient, Outpatient, Emergency }
|
||||
|
||||
public static class EncounterTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this EncounterType t) => t switch
|
||||
{
|
||||
EncounterType.Inpatient => "INPATIENT",
|
||||
EncounterType.Outpatient => "OUTPATIENT",
|
||||
EncounterType.Emergency => "EMERGENCY",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static EncounterType FromDbString(string v) => v switch
|
||||
{
|
||||
"INPATIENT" => EncounterType.Inpatient,
|
||||
"OUTPATIENT" => EncounterType.Outpatient,
|
||||
"EMERGENCY" => EncounterType.Emergency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown encounter type: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public enum ObservationSource { Manual, Device, Lab }
|
||||
|
||||
public static class ObservationSourceExtensions
|
||||
{
|
||||
public static string ToDbString(this ObservationSource s) => s switch
|
||||
{
|
||||
ObservationSource.Manual => "MANUAL",
|
||||
ObservationSource.Device => "DEVICE",
|
||||
ObservationSource.Lab => "LAB",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static ObservationSource FromDbString(string v) => v switch
|
||||
{
|
||||
"MANUAL" => ObservationSource.Manual,
|
||||
"DEVICE" => ObservationSource.Device,
|
||||
"LAB" => ObservationSource.Lab,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown observation source: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public enum SyncOutboxStatus { Pending, Submitted }
|
||||
|
||||
public static class SyncOutboxStatusExtensions
|
||||
{
|
||||
public static string ToDbString(this SyncOutboxStatus s) => s switch
|
||||
{
|
||||
SyncOutboxStatus.Pending => "PENDING",
|
||||
SyncOutboxStatus.Submitted => "SUBMITTED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||
};
|
||||
|
||||
public static SyncOutboxStatus FromDbString(string v) => v switch
|
||||
{
|
||||
"PENDING" => SyncOutboxStatus.Pending,
|
||||
"SUBMITTED" => SyncOutboxStatus.Submitted,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown sync outbox status: '{v}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class DepartmentJsonConverter : JsonConverter<Department>
|
||||
{
|
||||
public override Department Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> DepartmentExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Department value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
public sealed class EncounterReplicaReadyCheck : IHealthCheck
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public EncounterReplicaReadyCheck(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken ct = default)
|
||||
{
|
||||
var encounterCount = await _db.Encounters.CountAsync(ct);
|
||||
if (encounterCount > 0)
|
||||
return HealthCheckResult.Healthy("Replica data available.");
|
||||
|
||||
var state = await _db.SyncState.FirstOrDefaultAsync(ct);
|
||||
if (state?.InitialSyncCompleted == true)
|
||||
return HealthCheckResult.Healthy("Initial sync completed.");
|
||||
|
||||
return HealthCheckResult.Unhealthy(
|
||||
"No encounter replica data — first boot requires central sync.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
public static class HealthCheckResponseWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static async Task WriteAsync(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = new
|
||||
{
|
||||
status = report.Status.ToString(),
|
||||
totalDurationMs = report.TotalDuration.TotalMilliseconds,
|
||||
checks = report.Entries.Select(e => new
|
||||
{
|
||||
name = e.Key,
|
||||
status = e.Value.Status.ToString(),
|
||||
durationMs = e.Value.Duration.TotalMilliseconds,
|
||||
description = e.Value.Description,
|
||||
data = e.Value.Data.Count > 0 ? e.Value.Data : null,
|
||||
exception = e.Value.Exception?.Message
|
||||
})
|
||||
};
|
||||
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
public sealed class RabbitMqHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly RabbitMqOptions _options;
|
||||
|
||||
public RabbitMqHealthCheck(IOptions<RabbitMqOptions> options) => _options = options.Value;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _options.Host,
|
||||
Port = _options.Port,
|
||||
UserName = _options.Username,
|
||||
Password = _options.Password
|
||||
};
|
||||
|
||||
using var connection = await Task.Run(() => factory.CreateConnection(), cancellationToken);
|
||||
var data = new Dictionary<string, object> { ["endpoint"] = connection.Endpoint.ToString() };
|
||||
return HealthCheckResult.Healthy(data: data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public sealed class RedisHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public RedisHealthCheck(IConnectionMultiplexer redis) => _redis = redis;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var latency = await db.PingAsync();
|
||||
var data = new Dictionary<string, object> { ["ping_ms"] = latency.TotalMilliseconds };
|
||||
return HealthCheckResult.Healthy(data: data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
public class ExceptionHandlerMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
|
||||
|
||||
public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status404NotFound,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status404NotFound, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status422UnprocessableEntity,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status422UnprocessableEntity, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (ConflictException ex)
|
||||
{
|
||||
_logger.LogWarning("{Message}", ex.Message);
|
||||
await WriteAsync(context, StatusCodes.Status409Conflict,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status409Conflict, ex.Message, ex.ErrorCode));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception");
|
||||
await WriteAsync(context, StatusCodes.Status500InternalServerError,
|
||||
ApiResponse<object>.Fail(StatusCodes.Status500InternalServerError,
|
||||
"An unexpected error occurred", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteAsync<T>(HttpContext context, int status, ApiResponse<T> body)
|
||||
{
|
||||
context.Response.StatusCode = status;
|
||||
await context.Response.WriteAsJsonAsync(body);
|
||||
}
|
||||
}
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCare.WardGateway.Migrations
|
||||
{
|
||||
[DbContext(typeof(GatewayDbContext))]
|
||||
[Migration("20260623073142_InitialGatewaySchema")]
|
||||
partial class InitialGatewaySchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("BufferedSyncItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ItemType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("item_type");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<bool>("Synced")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("synced");
|
||||
|
||||
b.Property<DateTimeOffset?>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.ToTable("buffered_sync_items", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_buffered_sync_items_item_type", "item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GatewaySyncState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<bool>("InitialSyncCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("initial_sync_completed");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastEncounterSyncAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_encounter_sync_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("gateway_sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("AcknowledgedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("acknowledged_at");
|
||||
|
||||
b.Property<string>("AcknowledgedBy")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("acknowledged_by");
|
||||
|
||||
b.Property<string>("AlertType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("alert_type");
|
||||
|
||||
b.Property<Guid>("ClientAlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("client_alert_id");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<Guid?>("ObservationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("observation_id");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'OPEN'");
|
||||
|
||||
b.Property<DateTimeOffset>("TriggeredAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("triggered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientAlertId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("EncounterId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("PatientId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("Severity", "TriggeredAt")
|
||||
.HasFilter("status = 'OPEN'");
|
||||
|
||||
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
b.ToTable("clinical_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
|
||||
|
||||
b.ToTable("observations", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaAlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<decimal?>("CriticalHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_high");
|
||||
|
||||
b.Property<decimal?>("CriticalLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_low");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<int?>("SuppressionWindowMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("suppression_window_minutes");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal?>("WarningHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_high");
|
||||
|
||||
b.Property<decimal?>("WarningLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_low");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ObservationCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("alert_thresholds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("AdmittedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("admitted_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("AttendingPhysician")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("attending_physician");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("EncounterType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("encounter_type");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'SCHEDULED'");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PatientId", "AdmittedAt");
|
||||
|
||||
b.HasIndex("Status", "AdmittedAt")
|
||||
.HasFilter("status = 'ACTIVE'");
|
||||
|
||||
b.ToTable("encounters", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaPatient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SyncOutboxEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("batch_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("submitted_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("sync_outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.HasOne("ReplicaPatient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaPatient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCare.WardGateway.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialGatewaySchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "alert_thresholds",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
critical_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
warning_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
critical_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
|
||||
suppression_window_minutes = table.Column<int>(type: "integer", nullable: true),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_alert_thresholds", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "buffered_sync_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
item_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
payload = table.Column<string>(type: "jsonb", nullable: false),
|
||||
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
synced = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_buffered_sync_items", x => x.id);
|
||||
table.CheckConstraint("chk_buffered_sync_items_item_type", "item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "gateway_sync_state",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
last_encounter_sync_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
initial_sync_completed = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_gateway_sync_state", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "patients",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
mrn = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
first_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
last_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
date_of_birth = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
gender = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_patients", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sync_outbox",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false, defaultValueSql: "'PENDING'"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
submitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sync_outbox", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "encounters",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
encounter_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'SCHEDULED'"),
|
||||
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
attending_physician = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
room_bed = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
admitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
synced_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_encounters", x => x.id);
|
||||
table.CheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
table.CheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
table.CheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
table.ForeignKey(
|
||||
name: "FK_encounters_patients_patient_id",
|
||||
column: x => x.patient_id,
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "clinical_alerts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
observation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
details = table.Column<string>(type: "text", nullable: false),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"),
|
||||
acknowledged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
acknowledged_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
triggered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
client_alert_id = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_clinical_alerts", x => x.id);
|
||||
table.CheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
table.CheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
table.CheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
table.ForeignKey(
|
||||
name: "FK_clinical_alerts_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "observations",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
source = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'MANUAL'"),
|
||||
idempotency_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_observations", x => x.id);
|
||||
table.CheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
table.ForeignKey(
|
||||
name: "FK_observations_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_thresholds_observation_code",
|
||||
table: "alert_thresholds",
|
||||
column: "observation_code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_buffered_sync_items_created_at",
|
||||
table: "buffered_sync_items",
|
||||
column: "created_at",
|
||||
filter: "NOT synced");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_buffered_sync_items_idempotency_key",
|
||||
table: "buffered_sync_items",
|
||||
column: "idempotency_key",
|
||||
unique: true,
|
||||
filter: "NOT synced");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_client_alert_id",
|
||||
table: "clinical_alerts",
|
||||
column: "client_alert_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_encounter_id_alert_type_observation_code",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "encounter_id", "alert_type", "observation_code" },
|
||||
filter: "status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_encounter_id_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "encounter_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_patient_id_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "patient_id", "triggered_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_clinical_alerts_severity_triggered_at",
|
||||
table: "clinical_alerts",
|
||||
columns: new[] { "severity", "triggered_at" },
|
||||
filter: "status = 'OPEN'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_encounters_patient_id_admitted_at",
|
||||
table: "encounters",
|
||||
columns: new[] { "patient_id", "admitted_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_encounters_status_admitted_at",
|
||||
table: "encounters",
|
||||
columns: new[] { "status", "admitted_at" },
|
||||
filter: "status = 'ACTIVE'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_observations_encounter_id_observation_code_recorded_at",
|
||||
table: "observations",
|
||||
columns: new[] { "encounter_id", "observation_code", "recorded_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_observations_idempotency_key",
|
||||
table: "observations",
|
||||
column: "idempotency_key",
|
||||
unique: true,
|
||||
filter: "idempotency_key IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_patients_mrn",
|
||||
table: "patients",
|
||||
column: "mrn",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "alert_thresholds");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "buffered_sync_items");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "clinical_alerts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "gateway_sync_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "observations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sync_outbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "encounters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "patients");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCare.WardGateway.Migrations
|
||||
{
|
||||
[DbContext(typeof(GatewayDbContext))]
|
||||
partial class GatewayDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("BufferedSyncItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ItemType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("item_type");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<bool>("Synced")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("synced");
|
||||
|
||||
b.Property<DateTimeOffset?>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("NOT synced");
|
||||
|
||||
b.ToTable("buffered_sync_items", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_buffered_sync_items_item_type", "item_type IN ('OBSERVATION', 'ALERT', 'ACK', 'RESOLVE')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GatewaySyncState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<bool>("InitialSyncCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("initial_sync_completed");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastEncounterSyncAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_encounter_sync_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("gateway_sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("AcknowledgedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("acknowledged_at");
|
||||
|
||||
b.Property<string>("AcknowledgedBy")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("acknowledged_by");
|
||||
|
||||
b.Property<string>("AlertType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("alert_type");
|
||||
|
||||
b.Property<Guid>("ClientAlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("client_alert_id");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("details");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<Guid?>("ObservationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("observation_id");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'OPEN'");
|
||||
|
||||
b.Property<DateTimeOffset>("TriggeredAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("triggered_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientAlertId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("EncounterId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("PatientId", "TriggeredAt");
|
||||
|
||||
b.HasIndex("Severity", "TriggeredAt")
|
||||
.HasFilter("status = 'OPEN'");
|
||||
|
||||
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
|
||||
.HasFilter("status IN ('OPEN', 'ESCALATED')");
|
||||
|
||||
b.ToTable("clinical_alerts", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
|
||||
|
||||
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("source")
|
||||
.HasDefaultValueSql("'MANUAL'");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
|
||||
|
||||
b.ToTable("observations", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaAlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<decimal?>("CriticalHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_high");
|
||||
|
||||
b.Property<decimal?>("CriticalLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("critical_low");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<int?>("SuppressionWindowMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("suppression_window_minutes");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal?>("WarningHigh")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_high");
|
||||
|
||||
b.Property<decimal?>("WarningLow")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("warning_low");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ObservationCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("alert_thresholds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("AdmittedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("admitted_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("AttendingPhysician")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("attending_physician");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("EncounterType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("encounter_type");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'SCHEDULED'");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PatientId", "AdmittedAt");
|
||||
|
||||
b.HasIndex("Status", "AdmittedAt")
|
||||
.HasFilter("status = 'ACTIVE'");
|
||||
|
||||
b.ToTable("encounters", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
|
||||
|
||||
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaPatient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateOnly>("DateOfBirth")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date_of_birth");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
b.Property<DateTimeOffset>("SyncedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("synced_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("patients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SyncOutboxEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("batch_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("status")
|
||||
.HasDefaultValueSql("'PENDING'");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("submitted_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("sync_outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Alerts")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LocalObservation", b =>
|
||||
{
|
||||
b.HasOne("ReplicaEncounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.HasOne("ReplicaPatient", "Patient")
|
||||
.WithMany("Encounters")
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaEncounter", b =>
|
||||
{
|
||||
b.Navigation("Alerts");
|
||||
|
||||
b.Navigation("Observations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ReplicaPatient", b =>
|
||||
{
|
||||
b.Navigation("Encounters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public static class CentralApiJson
|
||||
{
|
||||
public static JsonSerializerOptions Options { get; } = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(),
|
||||
new DepartmentJsonConverter()
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
public sealed class CentralEncounterListPage
|
||||
{
|
||||
public List<CentralWardEncounterSummary> Items { get; set; } = [];
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
public int TotalPages { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CentralWardEncounterSummary
|
||||
{
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CentralEncounterDetail
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public EncounterType EncounterType { get; set; }
|
||||
public EncounterStatus Status { get; set; }
|
||||
public Department Department { get; set; }
|
||||
public string AttendingPhysician { get; set; } = null!;
|
||||
public string? RoomBed { get; set; }
|
||||
public DateTimeOffset AdmittedAt { get; set; }
|
||||
public CentralPatientDetail Patient { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class CentralPatientDetail
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Mrn { get; set; } = null!;
|
||||
public string FirstName { get; set; } = null!;
|
||||
public string LastName { get; set; } = null!;
|
||||
public DateOnly DateOfBirth { get; set; }
|
||||
public string Gender { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class CentralAlertThreshold
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string Unit { get; set; } = null!;
|
||||
public decimal? CriticalLow { get; set; }
|
||||
public decimal? WarningLow { get; set; }
|
||||
public decimal? WarningHigh { get; set; }
|
||||
public decimal? CriticalHigh { get; set; }
|
||||
public int? SuppressionWindowMinutes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
public record AcknowledgeAlertRequest(string? Note);
|
||||
@@ -0,0 +1,13 @@
|
||||
public record EncounterDetail(
|
||||
Guid Id,
|
||||
Guid PatientId,
|
||||
string Mrn,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
Department Department,
|
||||
EncounterStatus Status,
|
||||
string? RoomBed,
|
||||
string AttendingPhysician,
|
||||
DateTimeOffset AdmittedAt,
|
||||
IReadOnlyList<LocalObservation> RecentObservations,
|
||||
IReadOnlyList<LocalClinicalAlert> OpenAlerts);
|
||||
@@ -0,0 +1,13 @@
|
||||
public record WardEncounterSummary(
|
||||
Guid EncounterId,
|
||||
string Mrn,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
Department Department,
|
||||
string? RoomBed,
|
||||
string AttendingPhysician,
|
||||
DateTimeOffset AdmittedAt,
|
||||
int OpenAlertCount,
|
||||
int? News2Score,
|
||||
int? QsofaScore,
|
||||
string? SepsisBundleStatus);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record LocalIngestResult(bool IsDuplicate, LocalObservation Observation, LocalClinicalAlert? Alert)
|
||||
{
|
||||
public static LocalIngestResult Created(LocalObservation o, LocalClinicalAlert? a) =>
|
||||
new(false, o, a);
|
||||
public static LocalIngestResult Duplicate(LocalObservation o) =>
|
||||
new(true, o, null);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public record IngestObservationRequest(
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string Unit,
|
||||
ObservationSource Source,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? IdempotencyKey);
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
public record ObservationCursor(DateTimeOffset RecordedAt, Guid Id)
|
||||
{
|
||||
public string Encode()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this);
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
public static ObservationCursor? Decode(string? encoded)
|
||||
{
|
||||
if (string.IsNullOrEmpty(encoded)) return null;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
|
||||
return JsonSerializer.Deserialize<ObservationCursor>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public record ThresholdCacheEntry(
|
||||
string ObservationCode,
|
||||
decimal? CriticalLow,
|
||||
decimal? WarningLow,
|
||||
decimal? WarningHigh,
|
||||
decimal? CriticalHigh);
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Exceptions;
|
||||
|
||||
public sealed class RabbitMqTopologyProvisioner : IHostedService
|
||||
{
|
||||
public const string Exchange = "clinical.notifications.exchange";
|
||||
public const string PagingKey = "alerts.paging";
|
||||
public const string EscalKey = "alerts.escalation";
|
||||
|
||||
private readonly RabbitMqOptions _opts;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly ILogger<RabbitMqTopologyProvisioner> _logger;
|
||||
|
||||
public RabbitMqTopologyProvisioner(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IHostEnvironment env,
|
||||
ILogger<RabbitMqTopologyProvisioner> logger)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
var factory = BuildFactory();
|
||||
using var connection = factory.CreateConnection("gateway-topology");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
if (_env.IsDevelopment() || _env.EnvironmentName == "Testing")
|
||||
{
|
||||
using var cleanup = connection.CreateModel();
|
||||
try
|
||||
{
|
||||
cleanup.QueueDelete("alerts.paging.dlq", ifUnused: false, ifEmpty: false);
|
||||
}
|
||||
catch (OperationInterruptedException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "DLQ delete skipped — queue may not exist yet");
|
||||
}
|
||||
}
|
||||
|
||||
channel.ExchangeDeclare(Exchange, ExchangeType.Direct, durable: true);
|
||||
|
||||
channel.QueueDeclare(
|
||||
queue: "alerts.paging.queue",
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: new Dictionary<string, object>
|
||||
{
|
||||
["x-dead-letter-exchange"] = "",
|
||||
["x-dead-letter-routing-key"] = "alerts.paging.dlq",
|
||||
});
|
||||
channel.QueueBind("alerts.paging.queue", Exchange, PagingKey);
|
||||
|
||||
channel.QueueDeclare(
|
||||
queue: "alerts.paging.dlq",
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: new Dictionary<string, object>
|
||||
{
|
||||
["x-message-ttl"] = _opts.PagingAckTimeoutMs,
|
||||
["x-dead-letter-exchange"] = Exchange,
|
||||
["x-dead-letter-routing-key"] = EscalKey,
|
||||
});
|
||||
|
||||
channel.QueueDeclare(
|
||||
queue: "alerts.escalation.queue",
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null);
|
||||
channel.QueueBind("alerts.escalation.queue", Exchange, EscalKey);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gateway RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}",
|
||||
Exchange, _opts.PagingAckTimeoutMs);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public ConnectionFactory BuildFactory() => new()
|
||||
{
|
||||
HostName = _opts.Host,
|
||||
Port = _opts.Port,
|
||||
UserName = _opts.Username,
|
||||
Password = _opts.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Prometheus;
|
||||
using Serilog;
|
||||
using StackExchange.Redis;
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
if (!builder.Environment.IsEnvironment("Testing"))
|
||||
builder.Host.UseSerilog((ctx, _, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));
|
||||
|
||||
builder.Services.AddDbContext<GatewayDbContext>(opts =>
|
||||
opts.UseNpgsql(builder.Configuration.GetConnectionString("GatewayDb")));
|
||||
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
|
||||
|
||||
builder.Services.Configure<GatewayOptions>(builder.Configuration.GetSection(GatewayOptions.Section));
|
||||
builder.Services.Configure<CentralApiOptions>(builder.Configuration.GetSection(CentralApiOptions.Section));
|
||||
builder.Services.Configure<RabbitMqOptions>(builder.Configuration.GetSection(RabbitMqOptions.Section));
|
||||
builder.Services.Configure<SuppressionOptions>(builder.Configuration.GetSection(SuppressionOptions.SectionName));
|
||||
|
||||
var jwt = builder.Configuration.GetSection("Jwt");
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(o =>
|
||||
{
|
||||
o.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwt["Issuer"],
|
||||
ValidAudience = jwt["Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(jwt["SigningKey"]!))
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
builder.Services.AddHttpClient("central");
|
||||
|
||||
builder.Services.AddSingleton<CentralReachabilityService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CentralReachabilityService>());
|
||||
builder.Services.AddHostedService<GatewayHeartbeatService>();
|
||||
builder.Services.AddHostedService<EncounterReplicaSyncService>();
|
||||
builder.Services.AddHostedService<SyncUploaderService>();
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
|
||||
builder.Services.AddSingleton<RabbitMqTopologyProvisioner>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<RabbitMqTopologyProvisioner>());
|
||||
builder.Services.AddSingleton<LocalPagingPublisher>();
|
||||
builder.Services.AddHostedService<LocalPagingWorkerService>();
|
||||
builder.Services.AddHostedService<LocalEscalationWorkerService>();
|
||||
builder.Services.AddScoped<LocalWarningEvaluator>();
|
||||
builder.Services.AddScoped<ILocalObservationService, LocalObservationService>();
|
||||
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
|
||||
builder.Services.AddScoped<ILocalAlertService, LocalAlertService>();
|
||||
builder.Services.AddScoped<IEncounterReadService, EncounterReadService>();
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<GatewayDbContext>("postgresql", tags: ["ready"])
|
||||
.AddCheck<RedisHealthCheck>("redis", tags: ["ready"])
|
||||
.AddCheck<RabbitMqHealthCheck>("rabbitmq", tags: ["ready"])
|
||||
.AddCheck<EncounterReplicaReadyCheck>("encounter_replica", tags: ["ready"]);
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
builder.Services.AddCors(o => o.AddPolicy("Dashboard", p =>
|
||||
p.WithOrigins("http://localhost:5173").AllowAnyHeader().AllowAnyMethod()));
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseMiddleware<ExceptionHandlerMiddleware>();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
app.UseSerilogRequestLogging();
|
||||
}
|
||||
|
||||
app.UseCors("Dashboard");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseHttpMetrics();
|
||||
app.MapControllers();
|
||||
app.MapMetrics("/metrics");
|
||||
|
||||
app.MapHealthChecks("/health/live", new()
|
||||
{
|
||||
Predicate = _ => false,
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteAsync
|
||||
});
|
||||
app.MapHealthChecks("/health/ready", new()
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("ready"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteAsync
|
||||
});
|
||||
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:50602",
|
||||
"sslPort": 44341
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5249",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7257;http://localhost:5249",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
|
||||
public static class BufferedSyncWriter
|
||||
{
|
||||
public static void Enqueue(
|
||||
GatewayDbContext db,
|
||||
BufferedSyncItemType itemType,
|
||||
object payload,
|
||||
string idempotencyKey,
|
||||
Guid encounterId,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
db.BufferedSyncItems.Add(new BufferedSyncItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ItemType = itemType,
|
||||
Payload = JsonSerializer.Serialize(payload),
|
||||
IdempotencyKey = idempotencyKey,
|
||||
EncounterId = encounterId,
|
||||
RecordedAt = recordedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class EncounterReadService : IEncounterReadService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public EncounterReadService(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<List<WardEncounterSummary>> ListActiveAsync(string? department)
|
||||
{
|
||||
var q = _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.Where(e => e.Status == EncounterStatus.Active);
|
||||
|
||||
if (!string.IsNullOrEmpty(department))
|
||||
{
|
||||
var dept = DepartmentExtensions.FromDbString(department);
|
||||
q = q.Where(e => e.Department == dept);
|
||||
}
|
||||
|
||||
var encounters = await q
|
||||
.OrderByDescending(e => e.AdmittedAt)
|
||||
.ToListAsync();
|
||||
|
||||
if (encounters.Count == 0)
|
||||
return [];
|
||||
|
||||
var encounterIds = encounters.Select(e => e.Id).ToList();
|
||||
var openAlertCounts = await _db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => encounterIds.Contains(a.EncounterId) && a.Status == AlertStatus.Open)
|
||||
.GroupBy(a => a.EncounterId)
|
||||
.Select(g => new { EncounterId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.EncounterId, x => x.Count);
|
||||
|
||||
return encounters.Select(e => new WardEncounterSummary(
|
||||
e.Id,
|
||||
e.Patient.Mrn,
|
||||
e.Patient.FirstName,
|
||||
e.Patient.LastName,
|
||||
e.Department,
|
||||
e.RoomBed,
|
||||
e.AttendingPhysician,
|
||||
e.AdmittedAt,
|
||||
openAlertCounts.GetValueOrDefault(e.Id),
|
||||
News2Score: null,
|
||||
QsofaScore: null,
|
||||
SepsisBundleStatus: null)).ToList();
|
||||
}
|
||||
|
||||
public async Task<EncounterDetail?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var enc = await _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
if (enc is null) return null;
|
||||
|
||||
var observations = await _db.Observations
|
||||
.AsNoTracking()
|
||||
.Where(o => o.EncounterId == id)
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
var alerts = await _db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.EncounterId == id && a.Status == AlertStatus.Open)
|
||||
.OrderByDescending(a => a.TriggeredAt)
|
||||
.ToListAsync();
|
||||
|
||||
return new EncounterDetail(
|
||||
enc.Id,
|
||||
enc.PatientId,
|
||||
enc.Patient.Mrn,
|
||||
enc.Patient.FirstName,
|
||||
enc.Patient.LastName,
|
||||
enc.Department,
|
||||
enc.Status,
|
||||
enc.RoomBed,
|
||||
enc.AttendingPhysician,
|
||||
enc.AdmittedAt,
|
||||
observations,
|
||||
alerts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public interface IEncounterReadService
|
||||
{
|
||||
Task<List<WardEncounterSummary>> ListActiveAsync(string? department);
|
||||
Task<EncounterDetail?> GetByIdAsync(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public interface ILocalAlertService
|
||||
{
|
||||
Task<LocalClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req, string clinicianId);
|
||||
Task<LocalClinicalAlert> ResolveAsync(Guid id);
|
||||
Task<PagedResult<LocalClinicalAlert>> ListAsync(Guid? encounterId, string? status, int page, int pageSize);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public interface ILocalObservationService
|
||||
{
|
||||
Task<LocalIngestResult> IngestAsync(Guid encounterId, IngestObservationRequest req);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
public interface IObservationQueryService
|
||||
{
|
||||
Task<CursorPage<LocalObservation>> GetHistoryAsync(
|
||||
Guid encounterId,
|
||||
string? code,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursorToken);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class LocalAlertService : ILocalAlertService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public LocalAlertService(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<LocalClinicalAlert> AcknowledgeAsync(
|
||||
Guid id, AcknowledgeAlertRequest req, string clinicianId)
|
||||
{
|
||||
var alert = await _db.ClinicalAlerts.FindAsync(id)
|
||||
?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
|
||||
|
||||
if (alert.Status is not (AlertStatus.Open or AlertStatus.Escalated))
|
||||
throw new ConflictException("Alert cannot be acknowledged.", "ALERT_NOT_ACKNOWLEDGEABLE");
|
||||
|
||||
alert.Status = AlertStatus.Acknowledged;
|
||||
alert.AcknowledgedAt = DateTimeOffset.UtcNow;
|
||||
alert.AcknowledgedBy = clinicianId;
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Ack, new
|
||||
{
|
||||
clientRef = Guid.NewGuid(),
|
||||
alert.ClientAlertId,
|
||||
clinicianId,
|
||||
acknowledgedAt = alert.AcknowledgedAt,
|
||||
req.Note
|
||||
}, $"ack:{alert.ClientAlertId}:{alert.AcknowledgedAt:o}", alert.EncounterId, alert.AcknowledgedAt!.Value);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return alert;
|
||||
}
|
||||
|
||||
public async Task<LocalClinicalAlert> ResolveAsync(Guid id)
|
||||
{
|
||||
var alert = await _db.ClinicalAlerts.FindAsync(id)
|
||||
?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
|
||||
|
||||
if (alert.Status != AlertStatus.Acknowledged)
|
||||
throw new ConflictException("Alert must be acknowledged first.", "ALERT_NOT_ACKNOWLEDGED");
|
||||
|
||||
alert.Status = AlertStatus.Resolved;
|
||||
alert.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Resolve, new
|
||||
{
|
||||
clientRef = Guid.NewGuid(),
|
||||
alert.ClientAlertId,
|
||||
clinicianId = alert.AcknowledgedBy,
|
||||
resolvedAt = alert.ResolvedAt,
|
||||
note = (string?)null
|
||||
}, $"resolve:{alert.ClientAlertId}", alert.EncounterId, alert.ResolvedAt!.Value);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return alert;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<LocalClinicalAlert>> ListAsync(
|
||||
Guid? encounterId, string? status, int page, int pageSize)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var q = _db.ClinicalAlerts.AsNoTracking().AsQueryable();
|
||||
if (encounterId.HasValue) q = q.Where(a => a.EncounterId == encounterId);
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
var parsedStatus = AlertStatusExtensions.FromDbString(status);
|
||||
q = q.Where(a => a.Status == parsedStatus);
|
||||
}
|
||||
var total = await q.CountAsync();
|
||||
var items = await q.OrderByDescending(a => a.TriggeredAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
return new PagedResult<LocalClinicalAlert>(items, page, pageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class LocalObservationService : ILocalObservationService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly LocalWarningEvaluator _warnings;
|
||||
private readonly LocalPagingPublisher _paging;
|
||||
private readonly ILogger<LocalObservationService> _logger;
|
||||
|
||||
public LocalObservationService(
|
||||
GatewayDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
LocalWarningEvaluator warnings,
|
||||
LocalPagingPublisher paging,
|
||||
ILogger<LocalObservationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_warnings = warnings;
|
||||
_paging = paging;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<LocalIngestResult> IngestAsync(Guid encounterId, IngestObservationRequest req)
|
||||
{
|
||||
var encounter = await _db.Encounters
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == encounterId);
|
||||
if (encounter is null)
|
||||
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||
if (encounter.Status != EncounterStatus.Active)
|
||||
throw new ConflictException("Encounter is not active.", "ENCOUNTER_NOT_ACTIVE");
|
||||
|
||||
if (!string.IsNullOrEmpty(req.IdempotencyKey))
|
||||
{
|
||||
var existing = await _db.Observations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey);
|
||||
if (existing is not null)
|
||||
return LocalIngestResult.Duplicate(existing);
|
||||
}
|
||||
|
||||
if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
|
||||
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var observation = new LocalObservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
ObservationCode = req.ObservationCode,
|
||||
Value = req.Value,
|
||||
Unit = req.Unit,
|
||||
Source = req.Source,
|
||||
IdempotencyKey = req.IdempotencyKey,
|
||||
RecordedAt = req.RecordedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Observations.Add(observation);
|
||||
|
||||
var threshold = await LoadThresholdAsync(req.ObservationCode);
|
||||
if (threshold is null)
|
||||
throw new ValidationException("Unknown observation code.", "UNKNOWN_OBSERVATION_CODE");
|
||||
|
||||
LocalClinicalAlert? alert = null;
|
||||
|
||||
if (IsCriticalBreach(req.Value, threshold))
|
||||
{
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
alert = new LocalClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClientAlertId = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = encounter.PatientId,
|
||||
ObservationId = observation.Id,
|
||||
AlertType = AlertTypeExtensions.CriticalFor(req.ObservationCode),
|
||||
Severity = AlertSeverity.Critical,
|
||||
Details = BuildCriticalDetails(req, threshold),
|
||||
Status = AlertStatus.Open,
|
||||
TriggeredAt = triggeredAt
|
||||
};
|
||||
_db.ClinicalAlerts.Add(alert);
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Alert, new
|
||||
{
|
||||
alert.ClientAlertId,
|
||||
alert.EncounterId,
|
||||
alertType = alert.AlertType.ToDbString(),
|
||||
severity = alert.Severity.ToDbString(),
|
||||
alert.Details,
|
||||
generatedAt = triggeredAt
|
||||
}, $"alert:{alert.ClientAlertId}", encounterId, triggeredAt);
|
||||
|
||||
await _paging.PublishCriticalAsync(alert, encounter);
|
||||
}
|
||||
|
||||
if (alert is null)
|
||||
await _warnings.TryEvaluateAsync(
|
||||
observation.Id, encounterId, encounter.PatientId,
|
||||
req.ObservationCode, req.Value, threshold);
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Observation, new
|
||||
{
|
||||
clientRef = observation.Id,
|
||||
req.IdempotencyKey,
|
||||
encounterId,
|
||||
req.ObservationCode,
|
||||
req.Value,
|
||||
req.Unit,
|
||||
source = req.Source.ToDbString(),
|
||||
req.RecordedAt
|
||||
}, req.IdempotencyKey ?? $"obs:{observation.Id}", encounterId, req.RecordedAt);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return LocalIngestResult.Created(observation, alert);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
await tx.RollbackAsync();
|
||||
var existing = await _db.Observations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey);
|
||||
if (existing is not null) return LocalIngestResult.Duplicate(existing);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(string observationCode)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var cacheKey = $"threshold:{observationCode}";
|
||||
|
||||
var cached = await cache.StringGetAsync(cacheKey);
|
||||
if (cached.HasValue)
|
||||
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
|
||||
|
||||
var threshold = await _db.AlertThresholds
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.ObservationCode == observationCode);
|
||||
|
||||
if (threshold is null) return null;
|
||||
|
||||
var entry = new ThresholdCacheEntry(
|
||||
threshold.ObservationCode,
|
||||
threshold.CriticalLow,
|
||||
threshold.WarningLow,
|
||||
threshold.WarningHigh,
|
||||
threshold.CriticalHigh);
|
||||
|
||||
await cache.StringSetAsync(cacheKey, JsonSerializer.Serialize(entry));
|
||||
_logger.LogDebug("Cache miss for threshold {Code} — loaded from PostgreSQL", observationCode);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) =>
|
||||
(t.CriticalLow.HasValue && value < t.CriticalLow.Value) ||
|
||||
(t.CriticalHigh.HasValue && value > t.CriticalHigh.Value);
|
||||
|
||||
private static string BuildCriticalDetails(IngestObservationRequest req, ThresholdCacheEntry t)
|
||||
{
|
||||
if (t.CriticalLow.HasValue && req.Value < t.CriticalLow.Value)
|
||||
return $"{req.ObservationCode} value {req.Value} {req.Unit} is below critical low of {t.CriticalLow} {req.Unit}.";
|
||||
return $"{req.ObservationCode} value {req.Value} {req.Unit} is above critical high of {t.CriticalHigh} {req.Unit}.";
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException ex) =>
|
||||
ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
public sealed class LocalPagingPublisher
|
||||
{
|
||||
private readonly RabbitMqOptions _opts;
|
||||
private readonly ILogger<LocalPagingPublisher> _logger;
|
||||
|
||||
public LocalPagingPublisher(IOptions<RabbitMqOptions> opts, ILogger<LocalPagingPublisher> logger)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task PublishCriticalAsync(LocalClinicalAlert alert, ReplicaEncounter encounter)
|
||||
{
|
||||
try
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _opts.Host,
|
||||
Port = _opts.Port,
|
||||
UserName = _opts.Username,
|
||||
Password = _opts.Password
|
||||
};
|
||||
using var conn = factory.CreateConnection("gateway-publisher");
|
||||
using var channel = conn.CreateModel();
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId = alert.Id,
|
||||
encounterId = alert.EncounterId,
|
||||
details = alert.Details,
|
||||
attendingPhysician = encounter.AttendingPhysician
|
||||
});
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(payload);
|
||||
channel.BasicPublish(
|
||||
RabbitMqTopologyProvisioner.Exchange,
|
||||
RabbitMqTopologyProvisioner.PagingKey,
|
||||
body: body);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published critical alert {AlertId} to paging queue for encounter {EncounterId}",
|
||||
alert.Id, alert.EncounterId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to publish critical alert {AlertId} to paging queue — alert persisted locally",
|
||||
alert.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class LocalWarningEvaluator
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
private readonly ILogger<LocalWarningEvaluator> _logger;
|
||||
|
||||
public LocalWarningEvaluator(GatewayDbContext db, ILogger<LocalWarningEvaluator> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> TryEvaluateAsync(
|
||||
Guid observationId, Guid encounterId, Guid patientId,
|
||||
string observationCode, decimal value, ThresholdCacheEntry threshold)
|
||||
{
|
||||
if (!IsWarningBreach(value, threshold)) return false;
|
||||
if (IsCriticalBreach(value, threshold)) return false;
|
||||
|
||||
var alertType = AlertTypeExtensions.WarningFor(observationCode);
|
||||
var clientAlertId = Guid.NewGuid();
|
||||
var triggeredAt = DateTimeOffset.UtcNow;
|
||||
var details = BuildWarningDetails(observationCode, value, threshold);
|
||||
|
||||
var exists = await _db.ClinicalAlerts.AnyAsync(a =>
|
||||
a.EncounterId == encounterId
|
||||
&& a.AlertType == alertType
|
||||
&& (a.Status == AlertStatus.Open || a.Status == AlertStatus.Acknowledged));
|
||||
if (exists) return false;
|
||||
|
||||
var alert = new LocalClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClientAlertId = clientAlertId,
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
ObservationId = observationId,
|
||||
AlertType = alertType,
|
||||
Severity = AlertSeverity.Warning,
|
||||
Details = details,
|
||||
Status = AlertStatus.Open,
|
||||
TriggeredAt = triggeredAt
|
||||
};
|
||||
_db.ClinicalAlerts.Add(alert);
|
||||
|
||||
BufferedSyncWriter.Enqueue(_db, BufferedSyncItemType.Alert, new
|
||||
{
|
||||
clientAlertId,
|
||||
encounterId,
|
||||
alertType = alertType.ToDbString(),
|
||||
severity = AlertSeverity.Warning.ToDbString(),
|
||||
details,
|
||||
generatedAt = triggeredAt
|
||||
}, $"alert:{clientAlertId}", encounterId, triggeredAt);
|
||||
|
||||
_logger.LogInformation(
|
||||
"WARNING alert {AlertId} created locally for encounter {EncounterId}",
|
||||
alert.Id, encounterId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsWarningBreach(decimal value, ThresholdCacheEntry t) =>
|
||||
(t.WarningHigh.HasValue && value > t.WarningHigh.Value) ||
|
||||
(t.WarningLow.HasValue && value < t.WarningLow.Value);
|
||||
|
||||
private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) =>
|
||||
(t.CriticalLow.HasValue && value < t.CriticalLow.Value) ||
|
||||
(t.CriticalHigh.HasValue && value > t.CriticalHigh.Value);
|
||||
|
||||
private static string BuildWarningDetails(string code, decimal value, ThresholdCacheEntry t) =>
|
||||
t.WarningHigh.HasValue && value > t.WarningHigh.Value
|
||||
? $"{code} value {value} is above warning high of {t.WarningHigh}."
|
||||
: $"{code} value {value} is below warning low of {t.WarningLow}.";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class ObservationQueryService : IObservationQueryService
|
||||
{
|
||||
private readonly GatewayDbContext _db;
|
||||
|
||||
public ObservationQueryService(GatewayDbContext db) => _db = db;
|
||||
|
||||
public async Task<CursorPage<LocalObservation>> GetHistoryAsync(
|
||||
Guid encounterId,
|
||||
string? code,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int limit,
|
||||
string? cursorToken)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var cursor = ObservationCursor.Decode(cursorToken);
|
||||
|
||||
var query = _db.Observations
|
||||
.AsNoTracking()
|
||||
.Where(o => o.EncounterId == encounterId);
|
||||
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
query = query.Where(o => o.ObservationCode == code);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(o => o.RecordedAt >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(o => o.RecordedAt <= to.Value);
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
var cursorTime = cursor.RecordedAt;
|
||||
var cursorId = cursor.Id;
|
||||
query = query.Where(o =>
|
||||
o.RecordedAt < cursorTime ||
|
||||
(o.RecordedAt == cursorTime && o.Id.CompareTo(cursorId) < 0));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.ThenByDescending(o => o.Id)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync();
|
||||
|
||||
var hasMore = items.Count > limit;
|
||||
if (hasMore) items.RemoveAt(limit);
|
||||
|
||||
var nextCursor = hasMore
|
||||
? new ObservationCursor(items[^1].RecordedAt, items[^1].Id).Encode()
|
||||
: null;
|
||||
|
||||
return new CursorPage<LocalObservation>(items, nextCursor, hasMore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
public static class PlausibilityValidator
|
||||
{
|
||||
// Plausible ranges define the outer boundary of physically possible values.
|
||||
// These are NOT clinical thresholds — they catch device malfunctions and typos.
|
||||
private static readonly Dictionary<string, (decimal Min, decimal Max)> _ranges = new()
|
||||
{
|
||||
["HEART_RATE"] = (1, 300),
|
||||
["TEMP_C"] = (15, 50),
|
||||
["POTASSIUM_MEQ_L"] = (0.1m, 12),
|
||||
["SPO2"] = (50, 100),
|
||||
["RESP_RATE"] = (1, 80),
|
||||
["WBC_K_UL"] = (0.1m, 500),
|
||||
["GLUCOSE_MG_DL"] = (10, 1000),
|
||||
["SYSTOLIC_BP"] = (40, 300),
|
||||
["DIASTOLIC_BP"] = (20, 200),
|
||||
["LACTATE_MMOL_L"] = (0.1m, 30),
|
||||
["AVPU"] = (0, 3),
|
||||
["SUPPLEMENTAL_O2"] = (0, 1),
|
||||
["GCS_EYE"] = (1, 4),
|
||||
["GCS_VERBAL"] = (1, 5),
|
||||
["GCS_MOTOR"] = (1, 6),
|
||||
["PAO2_MMHG"] = (20, 600),
|
||||
["FIO2_PCT"] = (21, 100),
|
||||
["PLATELET_K_UL"] = (1, 1500),
|
||||
["BILIRUBIN_MG_DL"] = (0.1m, 50),
|
||||
["CREATININE_MG_DL"] = (0.1m, 20),
|
||||
["URINE_OUTPUT_ML_H"] = (0, 500),
|
||||
};
|
||||
|
||||
public static bool IsPlausible(string observationCode, decimal value, out string? reason)
|
||||
{
|
||||
if (!_ranges.TryGetValue(observationCode, out var range))
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class AcknowledgeAlertRequestValidator : AbstractValidator<AcknowledgeAlertRequest>
|
||||
{
|
||||
public AcknowledgeAlertRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Note).MaximumLength(1000).When(x => x.Note is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class IngestObservationRequestValidator : AbstractValidator<IngestObservationRequest>
|
||||
{
|
||||
public IngestObservationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
|
||||
RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.RecordedAt)
|
||||
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
.WithMessage("RecordedAt cannot be more than 5 minutes in the future.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@VigilCare.WardGateway_HostAddress = http://localhost:5249
|
||||
|
||||
GET {{VigilCare.WardGateway_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"GatewayDb": "Host=localhost;Port=5437;Database=vigilcare_ward;Username=postgres;Password=password"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6383"
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "localhost",
|
||||
"Port": 5675,
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"PagingAckTimeoutMs": 300000
|
||||
},
|
||||
"CentralApi": {
|
||||
"BaseUrl": "http://localhost:5080"
|
||||
},
|
||||
"Gateway": {
|
||||
"GatewayId": "22222222-2222-2222-2222-222222222222",
|
||||
"SiteId": "11111111-1111-1111-1111-111111111111",
|
||||
"Department": "ICU",
|
||||
"EncounterSyncIntervalMinutes": 5,
|
||||
"CentralReachabilityIntervalSeconds": 30,
|
||||
"HeartbeatIntervalSeconds": 60,
|
||||
"SyncBatchSize": 500
|
||||
},
|
||||
"ApiKey": {
|
||||
"Gateway": "dev-gateway-key-change-in-production"
|
||||
},
|
||||
"Jwt": {
|
||||
"SigningKey": "dev-signing-key-minimum-32-bytes-long!!",
|
||||
"Issuer": "vigilcare-gateway",
|
||||
"Audience": "vigilcare-dashboard"
|
||||
},
|
||||
"AlertSuppression": {
|
||||
"DefaultWindowMinutes": 30
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": { "Default": "Information" }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.ClinicalContracts
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.ClinicalContracts.Tests", "VigilCare.ClinicalContracts.Tests\VigilCare.ClinicalContracts.Tests.csproj", "{5E441BE7-F27E-461C-B4AC-034158A8A4B2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway", "VigilCare.WardGateway\VigilCare.WardGateway.csproj", "{037B097C-39C4-476E-ADC0-96CBFEECF9AF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway.Tests", "VigilCare.WardGateway.Tests\VigilCare.WardGateway.Tests.csproj", "{1C6D261E-488B-4541-8995-12C5029441A6}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -42,5 +46,13 @@ Global
|
||||
{5E441BE7-F27E-461C-B4AC-034158A8A4B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E441BE7-F27E-461C-B4AC-034158A8A4B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5E441BE7-F27E-461C-B4AC-034158A8A4B2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{037B097C-39C4-476E-ADC0-96CBFEECF9AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{037B097C-39C4-476E-ADC0-96CBFEECF9AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{037B097C-39C4-476E-ADC0-96CBFEECF9AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{037B097C-39C4-476E-ADC0-96CBFEECF9AF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -145,6 +145,82 @@ services:
|
||||
networks:
|
||||
- vigilcare_net
|
||||
|
||||
ward-gateway-db:
|
||||
image: postgres:16
|
||||
profiles: ["ward-gateway", "full"]
|
||||
environment:
|
||||
POSTGRES_DB: vigilcare_ward
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
ports:
|
||||
- "5437:5432"
|
||||
volumes:
|
||||
- ward_pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- vigilcare_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
ward-gateway-redis:
|
||||
image: redis:7-alpine
|
||||
profiles: ["ward-gateway", "full"]
|
||||
ports:
|
||||
- "6383:6379"
|
||||
networks:
|
||||
- vigilcare_net
|
||||
|
||||
ward-gateway-rabbitmq:
|
||||
image: rabbitmq:3.13-management-alpine
|
||||
profiles: ["ward-gateway", "full"]
|
||||
ports:
|
||||
- "5675:5672"
|
||||
- "15675:15672"
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: guest
|
||||
RABBITMQ_DEFAULT_PASS: guest
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- vigilcare_net
|
||||
|
||||
ward-gateway-api:
|
||||
profiles: ["ward-gateway", "full"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: VigilCare.WardGateway/Dockerfile
|
||||
ports:
|
||||
- "5081:8080"
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ConnectionStrings__GatewayDb: "Host=ward-gateway-db;Port=5432;Database=vigilcare_ward;Username=postgres;Password=password"
|
||||
Redis__ConnectionString: "ward-gateway-redis:6379"
|
||||
RabbitMq__Host: ward-gateway-rabbitmq
|
||||
RabbitMq__Port: 5672
|
||||
RabbitMq__PagingAckTimeoutMs: 300000
|
||||
CentralApi__BaseUrl: "http://host.docker.internal:5080"
|
||||
Gateway__GatewayId: "22222222-2222-2222-2222-222222222222"
|
||||
Gateway__SiteId: "11111111-1111-1111-1111-111111111111"
|
||||
Gateway__Department: "ICU"
|
||||
ApiKey__Gateway: "dev-gateway-key-change-in-production"
|
||||
Jwt__SigningKey: "dev-signing-key-minimum-32-bytes-long!!"
|
||||
Jwt__Issuer: "vigilcare-gateway"
|
||||
Jwt__Audience: "vigilcare-dashboard"
|
||||
depends_on:
|
||||
ward-gateway-db:
|
||||
condition: service_healthy
|
||||
ward-gateway-redis:
|
||||
condition: service_started
|
||||
ward-gateway-rabbitmq:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- vigilcare_net
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
seq_data:
|
||||
@@ -153,6 +229,7 @@ volumes:
|
||||
minio_data:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
ward_pg_data:
|
||||
|
||||
networks:
|
||||
vigilcare_net:
|
||||
|
||||
@@ -14,6 +14,8 @@ From repo root:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Ward gateway services (`ward-gateway-db`, `ward-gateway-redis`, `ward-gateway-rabbitmq`, `ward-gateway-api`) use Compose profiles and **do not** start with the command above. Use `--profile ward-gateway` or see §10.
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
@@ -177,6 +179,7 @@ This repo uses named volumes for persistent data:
|
||||
- `minio_data`
|
||||
- `prometheus_data`
|
||||
- `grafana_data`
|
||||
- `ward_pg_data` (ward gateway PostgreSQL — only created when ward profile is used)
|
||||
|
||||
### Why your data still exists after restart
|
||||
- `docker compose up -d --force-recreate` recreates containers, but volumes remain.
|
||||
@@ -316,3 +319,63 @@ Note: if you accidentally create a typo folder like `dashbpards`, Grafana provis
|
||||
|
||||
This avoids unnecessary full resets and speeds up local development.
|
||||
|
||||
---
|
||||
|
||||
## 10) Compose profiles (ward gateway stack)
|
||||
|
||||
Several services in `docker-compose.yml` declare a **profile** so they are optional:
|
||||
|
||||
```yaml
|
||||
profiles: ["ward-gateway", "full"]
|
||||
```
|
||||
|
||||
Affected services: `ward-gateway-db`, `ward-gateway-redis`, `ward-gateway-rabbitmq`, `ward-gateway-api`.
|
||||
|
||||
### Why ward services do not start with `docker compose up`
|
||||
|
||||
`docker compose up` (no `--profile`) only starts services **without** a profile. The central stack (`postgres`, `redis`, `kafka`, etc.) has no profile and starts normally. Ward services are gated behind `ward-gateway` or `full`.
|
||||
|
||||
Symptom: `docker compose ps` shows no `ward-gateway-*` containers after a plain `docker compose up -d`.
|
||||
|
||||
### How to start the ward stack
|
||||
|
||||
```bash
|
||||
# Ward gateway infrastructure + API (port 5081)
|
||||
docker compose --profile ward-gateway up -d
|
||||
|
||||
# Central + ward together
|
||||
docker compose --profile full up -d
|
||||
```
|
||||
|
||||
Persist the profile in your environment so you do not need the flag every time:
|
||||
|
||||
```bash
|
||||
export COMPOSE_PROFILES=ward-gateway
|
||||
# or add to a repo-root .env file: COMPOSE_PROFILES=ward-gateway
|
||||
```
|
||||
|
||||
### Ward service ports (host)
|
||||
|
||||
| Service | Host port | Purpose |
|
||||
|---|---|---|
|
||||
| `ward-gateway-db` | 5437 | PostgreSQL `vigilcare_ward` |
|
||||
| `ward-gateway-redis` | 6383 | Threshold cache |
|
||||
| `ward-gateway-rabbitmq` | 5675 / 15675 | Local paging (AMQP / management UI) |
|
||||
| `ward-gateway-api` | 5081 | Gateway HTTP API |
|
||||
|
||||
Connection strings in `VigilCare.WardGateway/appsettings.json` use these host ports when running `dotnet run` locally against Dockerized dependencies.
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
docker compose --profile ward-gateway ps
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:5081/health/ready
|
||||
```
|
||||
|
||||
Phase 21 verification script already uses the correct profile:
|
||||
|
||||
```bash
|
||||
./scripts/run-phase21-verification.sh
|
||||
# internally: docker compose --profile ward-gateway up -d
|
||||
```
|
||||
|
||||
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
GW="${GATEWAY_URL:-http://localhost:5081}"
|
||||
CENTRAL="${CENTRAL_URL:-http://localhost:5080}"
|
||||
SITE_ID="${SITE_ID:-11111111-1111-1111-1111-111111111111}"
|
||||
GW_ID="${GATEWAY_ID:-22222222-2222-2222-2222-222222222222}"
|
||||
ADMIN_JWT="${ADMIN_JWT:?Set ADMIN_JWT (central admin JWT)}"
|
||||
GATEWAY_JWT="${GATEWAY_JWT:?Set GATEWAY_JWT (JWT signed with gateway Jwt:SigningKey)}"
|
||||
|
||||
echo "==> Start ward-gateway stack"
|
||||
docker compose --profile ward-gateway up -d
|
||||
|
||||
echo "==> Ensure central API is reachable before partition test"
|
||||
if ! curl -sf "$CENTRAL/health/ready" >/dev/null; then
|
||||
echo "Central API must be running at $CENTRAL (dotnet run VigilCareClinicalAPI)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Wait for gateway ready"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "$GW/health/ready" >/dev/null; then
|
||||
echo "Gateway ready after ${i} attempt(s)"
|
||||
break
|
||||
fi
|
||||
if [[ "$i" -eq 30 ]]; then
|
||||
echo "Gateway not ready after 60s — is ward-gateway profile up?" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Stop central API to simulate partition"
|
||||
pkill -f "VigilCareClinicalAPI" 2>/dev/null || true
|
||||
for i in $(seq 1 15); do
|
||||
curl -sf "$CENTRAL/health/ready" >/dev/null || break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Wait for gateway to detect central unreachable"
|
||||
sleep 35
|
||||
|
||||
echo "==> List active encounters from local replica"
|
||||
ENCOUNTER_ID=$(curl -sf "$GW/api/v1/encounters?status=ACTIVE&department=ICU" \
|
||||
-H "Authorization: Bearer $GATEWAY_JWT" | jq -r '.data.items[0].id')
|
||||
if [[ -z "$ENCOUNTER_ID" || "$ENCOUNTER_ID" == "null" ]]; then
|
||||
echo "No ACTIVE ICU encounter on gateway — seed replica or set GATEWAY_SYNC_JWT for sync" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Using encounter $ENCOUNTER_ID"
|
||||
|
||||
echo "==> Post critical observation while central is down"
|
||||
RECORDED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
IDEM_KEY="phase21-verify-$(date +%s)"
|
||||
curl -sf -X POST "$GW/api/v1/encounters/$ENCOUNTER_ID/observations" \
|
||||
-H "Authorization: Bearer $GATEWAY_JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"observationCode\":\"HEART_RATE\",\"value\":160,\"unit\":\"bpm\",\"source\":\"DEVICE\",\"recordedAt\":\"$RECORDED_AT\",\"idempotencyKey\":\"$IDEM_KEY\"}"
|
||||
|
||||
echo "==> Verify alert exists locally"
|
||||
curl -sf "$GW/api/v1/encounters/$ENCOUNTER_ID/alerts" \
|
||||
-H "Authorization: Bearer $GATEWAY_JWT" | jq -e '.data.items | length > 0'
|
||||
|
||||
echo "==> Verify buffered sync items pending upload"
|
||||
BUFFER_DEPTH=$(docker exec "$(docker ps -qf name=ward-gateway-db)" psql -U postgres -d vigilcare_ward -tAc \
|
||||
"SELECT COUNT(*) FROM buffered_sync_items WHERE NOT synced;")
|
||||
echo "Buffered items: $BUFFER_DEPTH"
|
||||
[[ "$BUFFER_DEPTH" -gt 0 ]] || { echo "Expected buffered sync items" >&2; exit 1; }
|
||||
|
||||
echo "==> Restart central API"
|
||||
echo " Start manually: dotnet run --project VigilCareClinicalAPI"
|
||||
for i in $(seq 1 45); do
|
||||
curl -sf "$CENTRAL/health/ready" >/dev/null && break
|
||||
sleep 2
|
||||
done
|
||||
if ! curl -sf "$CENTRAL/health/ready" >/dev/null; then
|
||||
echo "Central API did not come back — restart VigilCareClinicalAPI and re-run from sync step" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Wait for gateway heartbeat/sync (up to 90s)"
|
||||
sleep 90
|
||||
|
||||
echo "==> Verify gateway reported ONLINE or buffer drained via central registry"
|
||||
curl -sf "$CENTRAL/api/v1/gateways/$GW_ID" \
|
||||
-H "Authorization: Bearer $ADMIN_JWT" | jq -e '.data.status == "ONLINE" or .data.reportedBufferDepth == 0'
|
||||
|
||||
echo "Phase 21 verification passed."
|
||||
Reference in New Issue
Block a user