Files
vigilcare-clinical/docs/patient-encounter-api-lifecycle.md
T

66 KiB
Raw Blame History

Patient Encounter API Lifecycle

Audience: API integrators, simulator authors, dashboard developers, and anyone who needs to understand how a patient moves through VigilCare from registration to discharge.

What is VigilCare? VigilCare is a clinical monitoring system that tracks patients in a hospital. It continuously watches vital signs (like heart rate, blood pressure, and temperature), calculates safety scores, and fires alerts when something looks wrong — so doctors and nurses can respond quickly.

Companion docs:


What this document covers

A hospital stay in VigilCare is called an encounter, and it belongs to a patient. Think of it this way: a patient is a person, and an encounter is one visit to the hospital. The same patient can have many encounters over their lifetime.

Everything clinical — measurements, alerts, medication records, lab orders, safety scores, sepsis bundles — is attached to an encounter while it is ACTIVE (meaning the patient is still in the hospital).

This guide walks through the lifecycle in order: what API calls to make, what happens right away in the response, what happens a few seconds later in the background, and what stops working after the patient leaves.

There is no single "admit patient" button. Instead, you combine the standard REST endpoints below.


Quick glossary of medical terms

Before we dive in, here are the medical terms you will see throughout this document:

Term What it means
Vital signs Basic body measurements that show how a patient is doing — heart rate, blood pressure, temperature, breathing rate, oxygen level
Observation Any measurement recorded for a patient (vitals, lab results, consciousness checks)
Sepsis A life-threatening condition where the body's response to an infection starts damaging its own organs. It can kill quickly if not treated
NEWS2 National Early Warning Score 2 — a scoring system that combines 7 vital signs into a single number (020) to detect patients who are getting sicker
SOFA Sequential Organ Failure Assessment — a score (024) that measures how well 6 organ systems are working. Used to detect organ failure and sepsis
qSOFA Quick SOFA — a fast bedside check using just 3 things (breathing rate, blood pressure, consciousness). Used as an early screen: "should we look more closely for sepsis?"
GCS Glasgow Coma Scale — measures how conscious/awake a patient is (315). Lower is worse. A score of 3 means completely unresponsive, 15 means fully alert
AVPU A simpler consciousness scale: Alert, responds to Voice, responds to Pain, Unresponsive
Threshold A boundary value. If a measurement crosses a threshold, it triggers an alert
Alert A notification to clinical staff that something needs attention — like an alarm going off
Sepsis bundle A checklist of 4 things that must be done within 1 hour when sepsis is suspected (blood tests, antibiotics, IV fluids)
MRN Medical Record Number — the human-readable ID for a patient (e.g. MRN-000042)

Quick glossary of technology terms

VigilCare uses several specialized technologies behind the scenes. Here is what each one does and why the system needs it:

Technology What it is How VigilCare uses it
PostgreSQL A relational database — think of it as a giant spreadsheet that stores data permanently and safely The main database. Stores all patients, encounters, observations, alerts, orders, medications, scores, and sepsis bundles. When you call any API endpoint, the data is read from or written to PostgreSQL
Redis An in-memory cache — like a sticky note board that is extremely fast to read but does not survive a full restart without backup Stores data that needs to be looked up instantly: alert thresholds, NEWS2 parameter windows, qSOFA criteria, SOFA lab values, trend history, and alert suppression timers. Much faster than querying the database every time
Kafka A message bus — like a conveyor belt that carries messages from one part of the system to another When a measurement is recorded, the API puts a message on the Kafka conveyor belt. Multiple background workers pick up that message independently: one calculates NEWS2, another checks for sepsis, another updates the search index, etc. This is why scoring happens in the background, not in the HTTP response
RabbitMQ A message queue — like a to-do list for background tasks that need to happen in order and must not be lost Handles clinician notifications (paging), alert escalation (when nobody responds to a critical alert within 5 minutes), and discharge summary generation. Unlike Kafka (which broadcasts to many listeners), RabbitMQ ensures each task is done exactly once
Elasticsearch A search and analytics engine — like a search engine built specifically for this hospital's data Powers the analytics endpoints (patient search, observation trends, alert summaries). Data flows from Kafka into Elasticsearch so you can run fast searches and aggregations across all patients and encounters without slowing down the main database
MinIO An object store — like a file cabinet for large files, compatible with Amazon S3 Stores two things: (1) discharge summary PDFs generated when patients leave, and (2) Parquet data files (a compact format for large datasets) used for long-term analysis and machine learning
Prometheus A monitoring tool — watches the system itself (not the patients) Collects metrics like "how many observations were recorded this hour," "how many alerts fired," "how long does an ingest take." Helps operators know if the system is healthy
Grafana A dashboard tool for Prometheus metrics Displays charts and graphs about system health — things like observation ingest rate, alert volumes, consumer lag, and response times
Docker A container platform — packages each technology into an isolated box that runs the same way everywhere All the services above (PostgreSQL, Redis, Kafka, etc.) run as Docker containers, orchestrated by a single docker-compose.yml file. One command starts the entire system

How the technology stack works together

Understanding how data flows through these technologies helps explain why some things happen immediately and others take a few seconds.

The outbox pattern — how data gets from the API to background workers

When a nurse records a heart rate of 95 bpm, here is what happens under the hood:

1. API receives POST /observations
2. Inside ONE database transaction (all-or-nothing):
   - Save the observation to PostgreSQL
   - If critical threshold breached: save the alert to PostgreSQL too
   - Write an "outbox event" row to PostgreSQL (a message waiting to be sent)
3. COMMIT — everything saved atomically

4. OutboxRelayService (runs every 500ms):
   - Reads unsent outbox events from PostgreSQL
   - Sends them to the correct Kafka topic
   - Marks them as processed

Why this pattern? It guarantees that if the observation is saved, the message to Kafka will also be sent — even if Kafka is temporarily down. The database acts as a reliable staging area. This is called the "transactional outbox pattern."

Kafka topics — the conveyor belts

Each Kafka topic carries a specific type of message. Think of them as labeled conveyor belts in a factory:

Topic What flows through it Who produces it Who consumes it
observation.recorded Every measurement recorded by any device, nurse, or lab Outbox relay (from API) Warning evaluator, NEWS2 scorer, sepsis engine, SOFA scorer, GCS scorer, trend analyzer, ES indexer, data lake writer
alert.generated Every alert created by any engine Outbox relay (from scoring engines) ES indexer, data lake writer, notification publisher (→ RabbitMQ)
encounter.status.changed Admissions, discharges, cancellations Outbox relay (from API) ES indexer, data lake writer, notification publisher (→ discharge queue)
sepsis.bundle.created New sepsis bundles Outbox relay (from SOFA engine) ES indexer
sepsis.bundle.updated Bundle element completions and compliance changes Outbox relay (from order results) ES indexer
gcs.scored GCS score calculations Outbox relay (from GCS scorer) ES indexer

Key detail: One message on observation.recorded is consumed by up to 8 different workers independently. Each worker has its own "consumer group" — Kafka tracks where each group left off, so every worker gets every message even if they process at different speeds.

Redis — the fast-lookup layer

Redis stores data that needs to be checked on every observation ingest or scoring calculation. Reading from Redis takes less than 1 millisecond, compared to 550ms for a database query.

What is stored Redis key pattern Why it is in Redis How long it stays (TTL)
Alert thresholds (critical/warning boundaries) threshold:{observationCode} Checked on every single observation — must be instant No expiry (loaded at startup from PostgreSQL)
Alert suppression timers suppress:{encounterId}:{alertType} Prevents duplicate warning alerts after a clinician acknowledges one 30 minutes
Trend history (recent vital sign readings) trend:{encounterId}:{observationCode} The trend analyzer needs the last 10 readings within 30 minutes to calculate rate of change 2 hours
SOFA lab values sofa:{encounterId}:{observationCode} Lab results arrive infrequently; Redis caches the latest value so SOFA can be recalculated whenever any new observation arrives 24 hours (considered stale after 12 hours)

Example flow: A heart rate of 105 bpm arrives. The API reads threshold:HEART_RATE from Redis (instant) and finds the warning high is 100. Since 105 > 100 but < 150 (critical), it is not a critical alert — but the outbox event goes to Kafka, where the warning evaluator picks it up and checks suppress:{encounterId}:WARNING_HEART_RATE in Redis. If no suppression exists, a warning alert is created.

RabbitMQ — notifications and escalation

RabbitMQ handles tasks where order matters and each task must be completed exactly once. It manages four queues:

Queue What it does How it works
alerts.paging.queue Pages clinicians when a critical alert fires A Kafka consumer picks up alert.generated events and, if the severity is CRITICAL, drops a message into this queue. The paging worker processes one alert at a time and waits for acknowledgment
alerts.paging.dlq (Dead Letter Queue) Catches unacknowledged pages If a paging message is not acknowledged within 5 minutes, RabbitMQ automatically moves it to this "dead letter" queue. After a timeout, it re-routes to the escalation queue
alerts.escalation.queue Escalates alerts that nobody responded to Picks up messages from the DLQ and marks the alert as "Escalated" in the database. This would trigger an on-call page to a more senior clinician
notifications.discharge.queue Generates discharge summary PDFs When a patient is discharged, a message arrives here. The discharge worker gathers all encounter data (observations, alerts, orders, medications) and builds a PDF summary, then uploads it to MinIO

Escalation timeline example:

0:00  Critical alert fires → message enters paging queue
0:00  Paging worker picks it up, simulates clinician page
5:00  Nobody acknowledged → message is NACKed → goes to DLQ
5:00+ DLQ TTL expires → message re-routes to escalation queue
      Escalation worker marks alert as "Escalated" in database

Elasticsearch — search and analytics

Elasticsearch is a search-optimized copy of the data. It does not replace PostgreSQL — it mirrors selected data for fast searching and aggregation.

Three indices (like specialized lookup tables):

Index What it contains Updated when
patient_encounters One document per encounter: patient info, department, NEWS2 score, alert count, sepsis bundle status Encounter created/discharged, alerts generated, bundle updated
observations One document per observation: code, value, unit, timestamp, patient/encounter IDs Every observation recorded
clinical_alerts One document per alert: type, severity, status, department, timestamp Every alert generated

Why not just query PostgreSQL? Some queries are expensive on a relational database — for example, "show me hourly average heart rates for patient X over the last week" or "how many critical alerts fired in the ICU today." Elasticsearch is built for these kinds of aggregations and full-text searches. The trade-off is that data arrives in Elasticsearch 13 seconds after it is written to PostgreSQL.

MinIO — long-term file storage

MinIO stores files organized by path, like a file system in the cloud:

Path pattern What is stored How it gets there
discharge-summaries/{encounterId}/summary.pdf Discharge summary for each encounter Generated by the discharge worker (RabbitMQ) when a patient is discharged
observations/{YYYY}/{MM}/{DD}/partition-{p}-offset-{o}.parquet Daily observation data in Parquet format Written by the data lake writer (Kafka consumer) in batches of 1000 events or every 5 minutes
alerts/{YYYY}/{MM}/{DD}/partition-{p}-offset-{o}.parquet Daily alert data in Parquet format Same as above
encounters/{YYYY}/{MM}/{DD}/partition-{p}-offset-{o}.parquet Daily encounter status changes in Parquet format Same as above

What is Parquet? It is a file format designed for large-scale data analysis. Unlike a CSV or JSON file, Parquet files are compressed and organized by column, making them very efficient for queries like "give me all heart rate values from the last month." This data lake is the foundation for future reporting, auditing, and machine learning.

Prometheus and Grafana — system health monitoring

Prometheus scrapes the /metrics endpoint every 15 seconds and records operational metrics:

Metric Type What it tracks
observations_ingested_total Counter Total observations recorded, labeled by code and source (DEVICE/MANUAL/LAB)
clinical_alerts_total Counter Total alerts generated, labeled by type and severity
news2_scores_total Counter NEWS2 scores calculated, labeled by risk level
escalations_total Counter Alerts that escalated because nobody acknowledged them
observation_ingest_duration_seconds Histogram How long each observation ingest takes (target: under 50ms)
news2_scoring_duration_seconds Histogram How long each NEWS2 calculation takes
sofa_scoring_duration_seconds Histogram How long each SOFA calculation takes
alerts_unacknowledged_gauge Gauge Current count of critical alerts open for more than 5 minutes
outbox_pending_events Gauge Unprocessed outbox events (if this grows, the relay is falling behind)
kafka_consumer_lag Gauge How far behind each Kafka consumer is (if this grows, scoring is delayed)

Grafana displays these metrics as charts, and operators can set up alerts (system alerts, not clinical alerts) if things like consumer lag or pending outbox events climb too high.

Docker Compose — running everything locally

All services run in Docker containers defined in docker-compose.yml:

Service Port (host) Purpose
PostgreSQL 16 5436 Main database
Redis 7 6382 Fast cache
Kafka 3.7 (KRaft mode) 9092 Message bus
RabbitMQ 3.13 5674 (AMQP), 15674 (management UI) Task queues
Elasticsearch 8.13 9200 Search and analytics
MinIO 9005 (API), 9006 (console UI) File/object storage
Prometheus 9101 Metrics collection
Grafana 3101 Metrics dashboards

Starting the full stack: docker compose up -d brings up all 8 services. The .NET API runs separately (outside Docker) and connects to these services on the ports listed above.


Conventions

Item Value
Base path /api/v1
Response envelope { success, statusCode, data, error }
Correlation Optional X-Correlation-Id request header; echoed on the response
Active encounter guard POST observations, medications, and orders return 409 (ENCOUNTER_NOT_ACTIVE) when the encounter is discharged or cancelled
Async latency Warning alerts, NEWS2 scores, qSOFA, SOFA, trend alerts, and sepsis bundles are created by background workers — allow a few seconds after recording a measurement before polling for results

Lifecycle overview

flowchart LR
    subgraph admission [Admission]
        A1[POST /patients]
        A2[POST /patients/id/encounters]
    end
    subgraph monitoring [Active monitoring]
        M1[POST /encounters/id/observations]
        M2[POST /encounters/id/medications]
        M3[POST /encounters/id/orders]
    end
    subgraph async [Background scoring engines]
        K1[warning-evaluator]
        K2[news2-scoring]
        K3[sepsis-engine]
        K4[trend-analyzer]
        K5[sofa-scoring]
        K6[gcs-scoring]
    end
    subgraph triage [Clinician response]
        T1[GET /alerts]
        T2[POST /alerts/id/acknowledge]
        T3[POST /alerts/id/resolve]
        T4[PATCH /orders/id/result]
    end
    subgraph discharge [Discharge]
        D1[PATCH /encounters/id/status]
        D2[encounter.status.changed → discharge summary]
    end

    A1 --> A2 --> M1
    M1 --> K1 & K2 & K3 & K4 & K5 & K6
    K1 & K2 & K3 & K4 & K5 & K6 --> T1
    M2 & M3 --> T1
    T1 --> T2 --> T3
    M3 --> T4
    T3 --> D1 --> D2

How encounter status works (enforced by PATCH /encounters/{id}/status):

scheduled → active → discharged
          → cancelled

POST /patients/{id}/encounters creates encounters directly in ACTIVE. Illegal transitions (like going from discharged back to active) return 409 (ILLEGAL_STATUS_TRANSITION).


All measurements the system tracks

VigilCare tracks measurements (called "observations") across three categories: vital signs, lab results, and consciousness assessments. Each measurement has a plausible range — values outside this range are rejected as likely errors.

Vital signs (measured at the bedside)

These are the basic measurements that nurses and devices collect regularly:

Code What it measures Unit Normal range (approx.) Plausible range Example
HEART_RATE How fast the heart beats bpm (beats per minute) 60100 1300 A resting adult is ~70 bpm. Over 150 or under 30 is critical
RESP_RATE How many breaths per minute breaths/min 1220 180 Normal adults breathe 1220 times/min. Over 30 is critical
SYSTOLIC_BP Blood pressure (the top number) — pressure when the heart squeezes mmHg 90140 40300 120/80 is "normal." Under 70 systolic is critical (not enough blood reaching organs)
DIASTOLIC_BP Blood pressure (the bottom number) — pressure when the heart relaxes mmHg 6090 20200 The lower number in a blood pressure reading
TEMP_C Body temperature °C (Celsius) 36.137.2 1550 Normal is ~37°C (98.6°F). Over 40°C or under 35°C is critical
SPO2 Oxygen saturation — how much oxygen is in the blood % 95100 50100 Measured with a clip on the finger. Below 88% is critical
SUPPLEMENTAL_O2 Whether the patient is receiving extra oxygen flag (0 or 1) 0 (room air) 01 0 = breathing normal air, 1 = on supplemental oxygen. Being on oxygen adds points to the NEWS2 score

Lab results (from blood tests and other samples)

These require a blood draw or lab analysis — they take longer to get but reveal what is happening inside the body:

Code What it measures Unit Normal range (approx.) Plausible range Why it matters
POTASSIUM_MEQ_L Potassium level in blood mEq/L 3.55.0 0.112 Too high or too low can cause dangerous heart rhythms
WBC_K_UL White blood cell count k/uL (thousands per microliter) 4.012.0 0.1500 White blood cells fight infection. Very high = possible infection. Very low = weak immune system
GLUCOSE_MG_DL Blood sugar level mg/dL 70100 (fasting) 101000 Too low (hypoglycemia) can cause seizures. Too high (hyperglycemia) damages organs over time
LACTATE_MMOL_L Lactic acid in blood mmol/L 0.52.0 0.130 High lactate means tissues are not getting enough oxygen — a key sign of sepsis or shock
PAO2_MMHG Partial pressure of oxygen in arterial blood mmHg 80100 20600 Requires an arterial blood draw. Shows how well lungs transfer oxygen. Used in SOFA respiratory scoring
FIO2_PCT Fraction of inspired oxygen — how concentrated the oxygen the patient breathes is % 21 (room air) 21100 Room air is 21%. A patient on a ventilator might be at 40100%. Used with PaO2 to calculate SOFA respiratory score
PLATELET_K_UL Platelet count k/uL 150400 11500 Platelets help blood clot. Very low platelets = bleeding risk. Used in SOFA coagulation scoring
BILIRUBIN_MG_DL Bilirubin level mg/dL 0.11.2 0.150 Produced when the liver breaks down old blood cells. High bilirubin = liver is struggling. Used in SOFA liver scoring
CREATININE_MG_DL Creatinine level mg/dL 0.61.2 0.120 A waste product filtered by the kidneys. High creatinine = kidneys are not filtering properly. Used in SOFA renal scoring
URINE_OUTPUT_ML_H How much urine the patient produces per hour mL/h >50 0500 Low urine output = kidneys may be failing. Used in SOFA renal scoring

Consciousness assessments

These measure how awake and responsive the patient is:

Code What it measures Range What the numbers mean
AVPU Quick consciousness check 03 0 = Alert (awake and talking), 1 = responds to Voice, 2 = responds to Pain only, 3 = Unresponsive. Used in NEWS2 scoring
GCS_EYE Eye opening response 14 4 = opens eyes on own, 3 = opens to voice, 2 = opens to pain, 1 = no eye opening
GCS_VERBAL Verbal response 15 5 = oriented/normal conversation, 4 = confused, 3 = inappropriate words, 2 = incomprehensible sounds, 1 = none
GCS_MOTOR Motor (movement) response 16 6 = obeys commands, 5 = localizes pain, 4 = withdraws from pain, 3 = abnormal flexion, 2 = extension, 1 = none

GCS total (315) is the sum of all three components. A GCS of 15 means fully alert. A GCS of 3 means completely unresponsive.


Alert thresholds — when do alarms go off?

Every measurement is checked against configured thresholds. There are two levels:

  • Critical — something is dangerously wrong right now. The alert fires immediately (in the same HTTP response). These are never suppressed.
  • Warning — something is concerning and needs attention. The alert fires in the background (via Kafka). Warnings can be temporarily suppressed after a clinician acknowledges them (30-minute window).
Measurement Critical Low Warning Low Warning High Critical High
Heart Rate (bpm) 30 50 100 150
Temperature (°C) 35.0 36.0 38.3 40.0
Potassium (mEq/L) 2.5 3.5 5.0 6.5
Oxygen Saturation (%) 88 92
Respiratory Rate (breaths/min) 12 20 30
White Blood Cells (k/uL) 2.0 4.0 12.0 20.0
Systolic BP (mmHg) 70 90 160 180
Diastolic BP (mmHg) 40 60 90 110
Lactate (mmol/L) 2.0 4.0
AVPU 2
Glucose (mg/dL) 40 70 180 400
PaO2 (mmHg) 60 80
Platelets (k/uL) 20 50
Bilirubin (mg/dL) 2.0 6.0
Creatinine (mg/dL) 2.0 3.5

Example: A heart rate of 155 bpm crosses the critical high threshold (150), so a CRITICAL_HEART_RATE alert fires immediately. A heart rate of 105 bpm crosses only the warning high threshold (100), so a WARNING_HEART_RATE alert fires in the background.

A dash (—) means there is no threshold configured on that side. For example, oxygen saturation has no high threshold because high oxygen is generally not dangerous in this context.


Clinical scoring systems explained

VigilCare calculates several clinical scores automatically. Each score looks at patient data from a different angle. Together, they give clinicians a complete picture of how the patient is doing.

NEWS2 — National Early Warning Score 2

What it does: Combines 7 vital signs into a single number (020) that tells staff "how sick is this patient right now?" It is the most widely used early warning system in UK hospitals.

Why it matters: A rising NEWS2 score often means the patient is getting worse — even if no single measurement has crossed a critical threshold yet. It catches deterioration early.

The 7 required measurements:

All 7 must be recorded within a 4-hour window for a score to be calculated. If any are missing, no score is produced.

Measurement Score 3 Score 2 Score 1 Score 0 (normal) Score 1 Score 2 Score 3
Respiratory Rate ≤8 911 1220 2124 ≥25
Oxygen Saturation (%) ≤91 9293 9495 ≥96
Systolic BP (mmHg) ≤90 91100 101110 111219 ≥220
Heart Rate (bpm) ≤40 4150 5190 91110 111130 ≥131
Consciousness (AVPU) Alert Any other (V, P, or U)
Temperature (°C) ≤35.0 35.136.0 36.138.0 38.139.0 ≥39.1
Supplemental O2 On oxygen (2) Room air (0)

How the total score maps to risk levels:

Total Score Risk Level What it means Alert type
04 LOW Routine monitoring. Patient is stable No alert
04 but any single parameter scores 3 LOW-MEDIUM One vital sign is individually concerning, even though the total looks OK NEWS2_WARNING
56 MEDIUM Patient may be deteriorating. Increase monitoring frequency NEWS2_WARNING
7 or higher HIGH Urgent — patient is at significant risk. Senior clinician review needed NEWS2_EMERGENCY

Example: A patient has: respiratory rate 24 (score 2), SpO2 94% (score 1), systolic BP 115 (score 0), heart rate 115 (score 2), AVPU alert (score 0), temperature 38.5°C (score 1), on room air (score 0). Total = 6 → MEDIUM risk → NEWS2_WARNING alert fires.


SOFA — Sequential Organ Failure Assessment

What it does: Scores how well 6 organ systems are functioning, on a scale of 04 each (total 024). Higher scores mean worse organ function. It is the gold standard for detecting sepsis-related organ damage.

Why it matters: Sepsis kills by damaging organs. SOFA catches this damage early. A jump of 2+ points from the patient's baseline is the clinical definition of sepsis (per Sepsis-3 guidelines).

The 6 organ systems scored:

Organ System What it measures Data needed Score 0 (normal) Score 1 Score 2 Score 3 Score 4 (worst)
Respiratory (lungs) How well lungs transfer oxygen PaO2 and FiO2 (or SpO2 if PaO2 unavailable) PaO2/FiO2 ≥400 300399 200299 100199 <100
Coagulation (blood clotting) Platelet count Platelet count ≥150 k/uL 100149 5099 2049 <20
Liver Liver function Bilirubin <1.2 mg/dL 1.21.9 2.05.9 6.011.9 ≥12.0
Cardiovascular (heart/circulation) Blood pressure support needed MAP (mean arterial pressure) MAP ≥70 MAP <70 Low-dose vasopressor Moderate vasopressor High-dose vasopressor
CNS (brain) Consciousness level GCS total score 15 1314 1012 69 <6
Renal (kidneys) Kidney function Creatinine and/or urine output Creatinine <1.2 1.21.9 2.03.4 3.54.9 ≥5.0 or urine <200 mL/day

How SOFA alerts work:

The system establishes a "baseline" SOFA score once at least 4 of the 6 organ systems have data. Then it watches for changes:

Change from baseline Alert Severity What it means
Increase of 2+ points SOFA_SEPSIS CRITICAL Meets the Sepsis-3 definition of sepsis. Immediate clinical response needed
Increase of 1 point SOFA_WARNING WARNING Organs are trending worse. Watch closely

Lab staleness: SOFA uses lab results that may not be measured frequently. The system considers lab values stale after 12 hours and expired after 24 hours, meaning the score becomes less reliable over time without fresh data.

Example: A patient has a baseline SOFA of 3. New labs come in showing worsening kidney function and lower platelets, pushing the SOFA to 6. That is a jump of 3 points → SOFA_SEPSIS alert fires.


qSOFA — Quick SOFA (bedside screening)

What it does: A fast, simple check using just 3 things you can measure at the bedside — no lab work needed. It asks: "should we be worried about sepsis?"

Why it matters: Lab results take time. qSOFA gives an answer in seconds using measurements that are already being collected. It is a screening tool — it does not diagnose sepsis, but it flags patients who need deeper evaluation (like a full SOFA score).

The 3 criteria:

Criteria Threshold What it means
Respiratory rate ≥22 breaths/min Patient is breathing faster than normal — body may be compensating for something
Systolic blood pressure ≤100 mmHg Blood pressure is dropping — organs may not be getting enough blood
Altered mental status AVPU ≥1 (not fully alert) or GCS <15 Patient is confused or not fully conscious

Scoring: Each criterion met = 1 point. Range is 03. When 2 or more criteria are met, a QSOFA_SCREEN alert fires.

Important: qSOFA is a screen, not a diagnosis. It says "look closer" — it does not by itself trigger a sepsis bundle. The full SOFA score is used for sepsis determination.

Example: A patient has a respiratory rate of 24 (meets criterion), systolic BP of 95 (meets criterion), and is alert (AVPU = 0, does not meet criterion). Score = 2 → QSOFA_SCREEN alert fires.


GCS — Glasgow Coma Scale

What it does: Measures how conscious a patient is by testing three types of responses: eye opening, verbal response, and motor (movement) response. Total score ranges from 3 (completely unresponsive) to 15 (fully alert).

Why it matters: Changes in consciousness can signal brain injury, stroke, medication effects, or worsening illness. A dropping GCS is an emergency.

The three components:

Component Best response (highest score) Worst response (lowest score)
Eye opening 4 — opens eyes spontaneously 1 — no eye opening
Verbal response 5 — oriented, normal conversation 1 — no verbal response
Motor response 6 — obeys commands 1 — no movement

Severity classification:

GCS Total Classification Alert What it means
1315 Mild No alert Patient is mostly or fully alert
912 Moderate GCS_WARNING Significant impairment — needs monitoring
38 Severe (coma) GCS_CRITICAL Patient is in or near coma — urgent intervention needed

How GCS connects to other scores:

  • NEWS2: GCS 15 maps to AVPU = "Alert" (score 0). Any GCS below 15 maps to "not alert" (score 3 in NEWS2). This is why even a small drop in consciousness adds 3 points to NEWS2.
  • qSOFA: GCS below 15 counts as "altered mental status" — one of the 3 qSOFA criteria.
  • SOFA CNS component: GCS maps directly to the SOFA brain score (15→0, 1314→1, 1012→2, 69→3, <6→4).

Trend detection — Rapid Deterioration

What it does: Watches how fast certain vital signs are changing over time, not just whether they have crossed a threshold. Even if a value is still in a "normal" range, a rapid change can signal trouble.

Why it matters: A heart rate going from 70 to 100 in 30 minutes is more alarming than a stable heart rate of 100. The trend tells you the patient is getting worse fast.

Monitored vital signs and velocity thresholds:

The system looks at changes over a 30-minute sliding window:

Vital Sign Trigger rate In plain terms Alert
Heart Rate ≥0.5 bpm/min Rising by 15+ bpm in 30 minutes RAPID_DETERIORATION
Respiratory Rate ≥0.3 breaths/min Rising by 9+ breaths/min in 30 minutes RAPID_DETERIORATION
Systolic BP ≥1.0 mmHg/min decline Dropping by 30+ mmHg in 30 minutes RAPID_DETERIORATION
Temperature ≥0.05 °C/min Rising by 1.5+°C in 30 minutes RAPID_DETERIORATION
Oxygen Saturation ≥0.2 %/min decline Dropping by 6+% in 30 minutes RAPID_DETERIORATION

Example: A patient's heart rate readings: 75 bpm at 2:00 PM, 82 bpm at 2:10 PM, 92 bpm at 2:25 PM. That is a rise of 17 bpm in 25 minutes (0.68 bpm/min) — above the 0.5 threshold → RAPID_DETERIORATION alert fires, even though 92 bpm is not above the warning threshold (100) yet.

RAPID_DETERIORATION alerts are never suppressed — every rapid change triggers a new alert.


Phase 0 — Platform prerequisites (before any patient)

These are typically set up once at deploy time, not called per patient. This is where the technology stack gets initialized.

What How Technology involved Why it matters
Alert thresholds Seeded in PostgreSQL; ThresholdCacheLoader copies them into Redis at startup; manageable via POST/GET/PUT /alert-thresholds PostgreSQL → Redis Every measurement is validated against thresholds from Redis (fast) to decide if alerts fire. Without this cache, every observation ingest would need a database query
Kafka topics KafkaTopicProvisioner creates 7 topics on startup (6 partitions each, auto-creation disabled) Kafka observation.recorded, alert.generated, encounter.status.changed, sepsis.bundle.created, sepsis.bundle.updated, gcs.scored. These must exist before any messages can flow
Elasticsearch indices ElasticIndexProvisioner creates 3 indices with proper field mappings Elasticsearch patient_encounters, observations, clinical_alerts — must exist before data can be indexed
RabbitMQ topology RabbitMqTopologyProvisioner declares the exchange and 5 queues RabbitMQ Sets up the clinical.notifications.exchange and all queues (paging, DLQ, escalation, discharge, reconciliation) with proper routing and dead-letter configuration
MinIO bucket Created if it does not exist MinIO The vigilcare bucket must exist before discharge summaries or data lake files can be written
Drug-vital mappings MedicationCorrelation section in appsettings.json Configuration Links medications to vital signs so alerts can include context like "heart rate may be elevated due to epinephrine given 20 min ago"

Phase 1 — Patient arrives (registration and admission)

1.1 Register the patient

POST /api/v1/patients
Field Required Notes
firstName, lastName, dateOfBirth, gender yes
bloodType, allergies, emergencyContactName, emergencyContactPhone no Stored for ward context

Response: 201 Createddata includes a system-generated mrn (e.g. MRN-000042) and id (UUID). Save both; the MRN is what nurses see on wristbands, the UUID is used in all API paths.

Later lookups:

Need Endpoint
Search by name or MRN GET /patients?q=...
Demographics + active encounter summary GET /patients/{id}

1.2 Open an encounter (admission)

POST /api/v1/patients/{patientId}/encounters
Field Required Notes
encounterType yes INPATIENT, OUTPATIENT, EMERGENCY
department yes e.g. ICU, GENERAL_MEDICINE, SURGERY
attendingPhysician yes
roomBed no Ward assignment (e.g. ICU-1A)
admissionReason no Why the patient was admitted

Response: 201 Created — encounter id, status: ACTIVE, admittedAt.

Side effects (what happens behind the scenes):

  • An encounter.status.changed outbox event is written to PostgreSQL in the same transaction as the encounter
  • The outbox relay picks it up within 500ms and sends it to the Kafka encounter.status.changed topic
  • Three Kafka consumers process it independently:
    • ES indexer → creates a document in the patient_encounters Elasticsearch index (so the patient appears in ward searches)
    • Data lake writer → buffers the event and writes it to a Parquet file in MinIO (for long-term records)
    • Notification publisher → no action for admissions (only discharges trigger the discharge queue)
  • Only one active encounter per patient per encounter type — trying to create a duplicate returns 409 (DUPLICATE_ACTIVE_ENCOUNTER)

From this point, encounterId is the key for all clinical writes.


Phase 2 — Ward visibility (who is on the floor)

While the patient is active, ward systems poll aggregated state.

Virtual ward board

GET /api/v1/encounters?status=ACTIVE&department=ICU&page=1&pageSize=20

Each row (WardEncounterSummary) includes:

Field What it shows
encounterId, patientId, mrn, firstName, lastName, roomBed, department, status Patient and encounter info
news2Score, news2RiskLevel Latest NEWS2 score and risk level (LOW, LOW_MEDIUM, MEDIUM, HIGH)
qsofaScore Current qSOFA criteria count (03)
sepsisActive Whether the patient has a sepsis investigation in progress
sepsisBundleStatus IN_PROGRESS, COMPLIANT, or NON_COMPLIANT
openAlertCount How many unresolved alerts exist for this patient

The dashboard sorts patients by NEWS2 score — sickest patients appear first.

Single-patient chart header

GET /api/v1/encounters/{id}

Returns the encounter with the patient's info, the 10 most recent observations, and open alerts. Use this for a quick summary; use the dedicated list endpoints for full history.


Phase 3 — Continuous monitoring (recording measurements)

Observations are the heartbeat of the system. Bedside devices, manual nurse entries, and lab interfaces all use the same endpoint.

Record measurements

POST /api/v1/encounters/{encounterId}/observations

Body: { "observations": [ ... ] }1 to 10 measurements per call.

Observation field Required Notes
observationCode yes One of the measurement codes listed above (e.g. HEART_RATE, RESP_RATE)
value, unit yes The numeric value and its unit. Checked for plausibility (e.g. a heart rate of 500 would be rejected)
recordedAt yes When the measurement was actually taken
source no DEVICE (default), MANUAL, LAB

Optional header: Idempotency-Key — if you accidentally send the same measurement twice, the system returns the original record without creating a duplicate.

What happens immediately (in the HTTP response)

  1. Encounter must be ACTIVE — otherwise 409
  2. Idempotency check — skip if already recorded
  3. Plausibility validation — reject impossible values with 422
  4. Save the observation row to PostgreSQL
  5. Read the alert thresholds from Redis (sub-millisecond lookup via key threshold:{observationCode})
  6. If a critical threshold is breached: insert a clinical_alert row + an alert.generated outbox event into PostgreSQL — all in the same transaction (so the alert is never lost)
  7. Insert an observation.recorded outbox event into PostgreSQL
  8. COMMIT — everything saved atomically. Within 500ms, the outbox relay picks up the events and sends them to Kafka

HTTP response (201): for each measurement, you get observation, alertGenerated (true only for critical alerts), alertId, and duplicate.

What happens in the background (not in the HTTP response)

Once the observation.recorded message lands on Kafka, multiple consumers pick it up independently. Each consumer belongs to its own consumer group, so Kafka delivers the same message to all of them. This is why one observation can trigger NEWS2, SOFA, qSOFA, trend detection, and search indexing all at the same time.

Engine (Kafka consumer group) What it does Technology used Alert produced
warning-evaluator Checks if the value crosses a warning threshold. Reads suppression status from Redis (suppress:{encounterId}:{alertType}). Optionally adds medication context (e.g. "patient was given morphine 30 min ago") Redis, PostgreSQL WARNING_* alerts
news2-scoring Reads latest 7 parameters from Redis (4-hour window). If all 7 are present, calculates NEWS2 and writes score to PostgreSQL Redis, PostgreSQL NEWS2_WARNING or NEWS2_EMERGENCY
sepsis-engine Recalculates qSOFA criteria count using latest values from Redis Redis, PostgreSQL QSOFA_SCREEN (when 2+ criteria met)
sofa-scoring Reads cached lab values from Redis (sofa:{encounterId}:{code}), recalculates per-organ SOFA scores, compares to baseline in PostgreSQL Redis, PostgreSQL SOFA_WARNING or SOFA_SEPSIS
gcs-scoring Recalculates GCS when eye/verbal/motor components are recorded, writes score to PostgreSQL PostgreSQL GCS_WARNING or GCS_CRITICAL
trend-analyzer Reads recent readings from Redis (trend:{encounterId}:{code}, up to 10 entries), calculates rate of change over 30-minute window Redis, PostgreSQL RAPID_DETERIORATION
es-indexer Upserts the observation into the observations Elasticsearch index. Also updates patient_encounters index (e.g. lastObservationAt) Elasticsearch (no alert)
data-lake-writer Buffers observations and writes them to MinIO as Parquet files (batches of 1000 events or every 5 minutes) MinIO (no alert)

Any alerts created by these engines are written to PostgreSQL with an alert.generated outbox event, which flows back through KafkaElasticsearch (for indexing) and RabbitMQ (for paging, if critical).

Important for integrators: After posting measurements, poll GET /encounters/{id}/alerts or score endpoints. Warning and scoring alerts are NOT in the initial HTTP response — they arrive 13 seconds later (the time it takes for the message to travel through Kafka and be processed).

Read measurement history

GET /api/v1/encounters/{encounterId}/observations?code=HEART_RATE&from=...&to=...&limit=50&cursor=...

Cursor-paginated (newest first). Use code to filter by measurement type.


Phase 4 — Clinical interventions (medications and orders)

These run in parallel with monitoring; they do not replace recording measurements.

Medication administrations

POST /api/v1/encounters/{encounterId}/medications
GET  /api/v1/encounters/{encounterId}/medications?since=...&page=1&pageSize=20
GET  /api/v1/medications/{id}

Recording a drug does not create alerts on its own. Instead, when a warning or NEWS2 alert fires later, the system checks: "was a relevant drug given recently?" If so, it adds context to the alert details. For example, a high heart rate warning might include: "Note: epinephrine administered 20 minutes ago."

The correlation window is 90 minutes by default — only drugs given within the last 90 minutes are linked.

Clinical orders

POST  /api/v1/encounters/{encounterId}/orders
GET   /api/v1/encounters/{encounterId}/orders?status=PENDING
GET   /api/v1/orders/{id}
PATCH /api/v1/orders/{id}/status
PATCH /api/v1/orders/{id}/result

Order status flow:

pending → in_progress → resulted
        → cancelled

PATCH .../result is how clinicians record that an order is complete — for example, recording that a blood culture was collected. For orders that are part of a sepsis bundle, completing the order also marks that bundle element as done.


Phase 5 — Alerts fire (detection to triage)

Alerts are created by multiple engines (critical thresholds, warning thresholds, NEWS2, qSOFA, SOFA, GCS, trend detection). All share the same read and lifecycle API.

List and inspect

Endpoint Use
GET /encounters/{id}/alerts?status=OPEN Alerts for one patient
GET /alerts?status=OPEN&severity=CRITICAL&department=ICU Hospital-wide alert center
GET /alerts/{id} Single alert with full details

Each alert includes alertType, severity, details (with medication context when applicable), status, triggeredAt, and optional observationId (which measurement triggered it).

All alert types:

Alert Type What triggered it Severity Can be suppressed?
CRITICAL_* (e.g. CRITICAL_HEART_RATE) A measurement crossed a critical threshold CRITICAL No
WARNING_* (e.g. WARNING_HEART_RATE) A measurement crossed a warning threshold WARNING Yes (30 min)
NEWS2_WARNING NEWS2 score is 56, or any single parameter scores 3 WARNING Yes (30 min)
NEWS2_EMERGENCY NEWS2 score is 7 or higher CRITICAL No
QSOFA_SCREEN 2 or more qSOFA criteria met WARNING Yes (30 min)
SOFA_SEPSIS SOFA score increased 2+ points from baseline CRITICAL No
SOFA_WARNING SOFA score increased 1 point from baseline WARNING Yes (30 min)
GCS_CRITICAL GCS total is 8 or below (coma) CRITICAL No
GCS_WARNING GCS total is 912 (moderate impairment) WARNING Yes (30 min)
RAPID_DETERIORATION A vital sign is changing too fast WARNING No

Acknowledge and resolve

POST /api/v1/alerts/{id}/acknowledge
Body: { "clinicianId": "DR-SMITH", "note": "optional" }

POST /api/v1/alerts/{id}/resolve

Alert lifecycle:

open → acknowledged → resolved
     → escalated   (unacknowledged CRITICAL after 5 min → pages on-call staff)

When a clinician acknowledges a suppressible warning, the system writes a key to Redis (suppress:{encounterId}:{alertType}) with a 30-minute TTL (time-to-live). During that window, the warning evaluator checks Redis before creating a new alert — if the suppression key exists, the duplicate is silently skipped. After 30 minutes, Redis automatically deletes the key, and new warnings can fire again. Critical and SOFA-sepsis alerts are never suppressed.

Escalation (no HTTP endpoint)

When a critical alert fires, the alert.generated Kafka message is picked up by the notification publisher, which drops a message into the RabbitMQ alerts.paging.queue. The paging worker processes it and waits for the clinician to acknowledge the alert in the database.

If nobody acknowledges within 5 minutes, the paging worker NACKs (rejects) the message. RabbitMQ automatically moves it to the alerts.paging.dlq (dead letter queue). After a timeout, the DLQ re-routes it to the alerts.escalation.queue, where the escalation worker marks the alert as Escalated in PostgreSQL and increments the escalations_total Prometheus metric.

Integrators see escalation through the alert's status field changing to escalated.


Phase 6 — Composite scores (NEWS2, SOFA, qSOFA, GCS)

NEWS2

Requires all 7 parameters within a 4-hour window: RESP_RATE, SPO2, SYSTOLIC_BP, HEART_RATE, AVPU, TEMP_C, SUPPLEMENTAL_O2. The NEWS2 scoring consumer reads each parameter's latest value from Redis — if all 7 are present and recent enough, it calculates the score, writes the result to PostgreSQL (news2_scores table), and creates an alert if warranted.

GET /api/v1/encounters/{id}/news2/current     → latest score (404 if not yet computed)
GET /api/v1/encounters/{id}/news2/history     → score history over time

These endpoints read from PostgreSQL. Scores are calculated in the background via Kafka — record the 7th parameter, wait a few seconds, then poll.

SOFA

Requires lab results and vitals across 6 organ systems. The SOFA scoring consumer reads cached lab values from Redis (sofa:{encounterId}:{code}) — labs are cached there because they arrive infrequently (hours apart), but SOFA needs to be recalculated every time any new observation arrives. The score is computed once at least 4 systems have data.

GET /api/v1/encounters/{id}/sofa/current      → latest SOFA score with per-organ breakdown
GET /api/v1/encounters/{id}/sofa/history      → score history

The response (from PostgreSQL) includes per-organ scores (respiratory, coagulation, liver, cardiovascular, CNS, renal) so clinicians can see which organ systems are struggling.

qSOFA

Live criteria count from Redis (no stored history — just the current state):

GET /api/v1/encounters/{id}/qsofa/current

Returns how many of the 3 criteria are currently met (03). When the count reaches 2, a QSOFA_SCREEN alert is written to PostgreSQL and flows through Kafka to Elasticsearch.

GCS

GET /api/v1/encounters/{id}/gcs/current       → latest GCS with component breakdown

Returns the total GCS score (from PostgreSQL), individual component scores (eye, verbal, motor), and severity classification (mild/moderate/severe). GCS scores also flow through Kafka (gcs.scored topic) to Elasticsearch.


Phase 7 — Sepsis bundle (when infection is suspected)

A sepsis bundle is a checklist of things that must be done within 1 hour when sepsis is suspected. VigilCare creates bundles automatically when a SOFA_SEPSIS alert fires — not by a direct API call.

GET /api/v1/encounters/{id}/sepsis-bundle/current
GET /api/v1/sepsis-bundles/{id}

The 4 bundle elements (each backed by an auto-created clinical order):

Element What it is Why within 1 hour
Blood cultures Draw blood samples to identify which bacteria is causing the infection Need to identify the infection before antibiotics potentially mask it
Serum lactate Measure lactic acid level in blood High lactate means organs are not getting enough oxygen — guides how aggressive treatment needs to be
Broad-spectrum antibiotics Give antibiotics that cover many types of bacteria Every hour of delay increases mortality risk. Start broad, then narrow down when culture results come back
IV fluid resuscitation Give fluids through an IV to restore blood pressure and organ perfusion Sepsis causes blood vessels to leak — fluids keep blood pressure up and organs working

Compliance flow:

  1. Bundle is created with complianceStatus: IN_PROGRESS and deadlineAt set to 1 hour from detection
  2. Clinicians complete orders via PATCH /orders/{id}/result
  3. Each completed order marks its bundle element COMPLETED
  4. When all 4 elements are done:
    • Within deadlineCOMPLIANT (good)
    • Past deadlineNON_COMPLIANT (needs review — delay may have worsened outcome)
  5. A background monitor checks every 5 minutes and marks overdue bundles as NON_COMPLIANT
GET /api/v1/encounters/{id}/orders

Use this to see bundle-linked orders and their status during the stay.


Phase 8 — Review during the stay (timeline and analytics)

Encounter timeline

GET /api/v1/encounters/{id}/timeline

A combined chronological stream of observations and alerts — like a scrolling history of everything that has happened to the patient. Useful for reviewing events during ward rounds or shift handovers.

Elasticsearch analytics (read-only, cross-encounter)

These endpoints query Elasticsearch — not PostgreSQL. They are fast for searches and aggregations across many patients, but the data may be 13 seconds behind the live database (because it flows through Kafka before landing in Elasticsearch).

Endpoint Purpose Elasticsearch index used
GET /analytics/patients?q=...&department=... Full-text search across patients and encounters patient_encounters
GET /analytics/observations/trend?encounterId=...&code=... Hourly avg/min/max time series for a measurement (uses date_histogram aggregation) observations
GET /analytics/alerts/summary?severity=...&department=... Alert volume by department clinical_alerts
GET /analytics/population?code=...&threshold=... Find patients above/below a measurement threshold in a time window observations

Phase 9 — Discharge (end of encounter)

Close the encounter

PATCH /api/v1/encounters/{encounterId}/status
Body: { "status": "DISCHARGED", "dischargeDiagnosis": "optional free text" }

Response: 200 OK{ encounterId, newStatus, dischargeDiagnosis }.

Side effects (the full technology chain):

  • dischargedAt is set to the current time in PostgreSQL
  • An encounter.status.changed outbox event is saved in PostgreSQL and relayed to Kafka within 500ms
  • Kafka consumers pick it up:
    • ES indexer → updates the encounter's status in the patient_encounters Elasticsearch index (patient drops off the active ward search)
    • Data lake writer → writes the discharge event to a Parquet file in MinIO
    • Notification publisher → detects this is a discharge and drops a message into the RabbitMQ notifications.discharge.queue
  • The discharge summary worker (RabbitMQ consumer) picks up the message, gathers all encounter data from PostgreSQL (observations, alerts, orders, medications, scores), builds a PDF summary, and uploads it to MinIO at discharge-summaries/{encounterId}/summary.pdf

What stops working after discharge

Action Result
POST .../observations 409 ENCOUNTER_NOT_ACTIVE — cannot record new measurements
POST .../medications 409 ENCOUNTER_NOT_ACTIVE — cannot record new medications
POST .../orders 409 ENCOUNTER_NOT_ACTIVE — cannot create new orders
GET .../alerts, GET .../observations, GET .../news2/history Still work — you can read historical data
GET /encounters?status=ACTIVE Patient drops off the active ward list

Cancellation: PATCH .../status with { "status": "CANCELLED" } from ACTIVE (e.g. an admission was created by mistake). Same write restrictions apply afterward.

A patient may later return and receive a new encounter via POST /patients/{id}/encounters. Each hospital visit is independent; the patient's MRN stays the same across their lifetime.


Complete endpoint index by lifecycle phase

Phase Method Path
0 — Config GET/POST/PUT /alert-thresholds, /alert-thresholds/{id}
1 — Admission POST /patients
POST /patients/{id}/encounters
GET /patients, /patients/{id}
2 — Ward GET /encounters, /encounters/{id}
GET /encounters/{id}/qsofa/current
3 — Monitoring POST /encounters/{id}/observations
GET /encounters/{id}/observations
4 — Interventions POST/GET /encounters/{id}/medications, /medications/{id}
POST/GET/PATCH /encounters/{id}/orders, /orders/{id}, /orders/{id}/status, /orders/{id}/result
5 — Alerts GET /encounters/{id}/alerts, /alerts, /alerts/{id}
POST /alerts/{id}/acknowledge, /alerts/{id}/resolve
6 — Scores GET /encounters/{id}/news2/current, /encounters/{id}/news2/history
GET /encounters/{id}/sofa/current, /encounters/{id}/sofa/history
GET /encounters/{id}/qsofa/current
GET /encounters/{id}/gcs/current
7 — Sepsis GET /encounters/{id}/sepsis-bundle/current, /sepsis-bundles/{id}
8 — Review GET /encounters/{id}/timeline
GET /analytics/patients, /analytics/observations/trend, /analytics/alerts/summary, /analytics/population
9 — Discharge PATCH /encounters/{id}/status

Operational (not encounter-scoped): GET /metrics (Prometheus), Swagger UI (development only).


Reference walkthrough — inpatient stay (step by step)

Here is the minimum sequence an integrator or simulator follows for a typical hospital stay:

# 1. Admit the patient
POST /api/v1/patients                                    ← register the person
POST /api/v1/patients/{patientId}/encounters             ← start their hospital visit

# 2. Record baseline vitals (first set of measurements)
POST /api/v1/encounters/{encounterId}/observations       ← heart rate, BP, temp, etc.

# 3. Give medications and order tests
POST /api/v1/encounters/{encounterId}/medications        ← record drugs given
POST /api/v1/encounters/{encounterId}/orders              ← order blood tests, cultures, etc.

# 4. Ongoing monitoring (repeat during the stay)
POST /api/v1/encounters/{encounterId}/observations       ← new vitals every few hours
GET  /api/v1/encounters/{encounterId}/alerts             ← check for alerts (wait 1-3s after posting vitals)
GET  /api/v1/encounters/{encounterId}/news2/current      ← check NEWS2 score
GET  /api/v1/encounters/{encounterId}/sofa/current       ← check SOFA score
GET  /api/v1/encounters/{encounterId}/qsofa/current      ← check qSOFA screen
GET  /api/v1/encounters/{encounterId}/gcs/current        ← check consciousness level
GET  /api/v1/encounters/{encounterId}/sepsis-bundle/current  ← check sepsis bundle if active
POST /api/v1/alerts/{alertId}/acknowledge                ← clinician acknowledges an alert
PATCH /api/v1/orders/{orderId}/result                    ← record order completion

# 5. Ward round (overview)
GET  /api/v1/encounters?status=ACTIVE                   ← see all active patients
GET  /api/v1/encounters/{encounterId}                    ← patient summary
GET  /api/v1/encounters/{encounterId}/timeline           ← full event history

# 6. Discharge
PATCH /api/v1/encounters/{encounterId}/status  { "status": "DISCHARGED", "dischargeDiagnosis": "..." }

For realistic clinical narratives (UTI progressing to sepsis, medication-related false alarms, rapid deterioration), replay the JSON scenarios in VigilCare.Simulator/Scenarios/List/ — each scenario file walks through this lifecycle with real clinical data.


Dashboard mapping

The Vue ward dashboard (vigilcare-dashboard/) implements this lifecycle through polling:

UI screen Primary API calls
Virtual Ward GET /encounters?status=ACTIVE
Patient Detail GET /encounters/{id}, GET .../news2/current, GET .../sofa/current, GET .../medications, GET .../orders, GET .../sepsis-bundle/current, GET .../observations, GET .../news2/history
Alert Center GET /alerts?status=OPEN
Alert actions POST /alerts/{id}/acknowledge, POST /alerts/{id}/resolve
Alert reasoning alert.details from alert list + client-side medication window from GET .../medications

Sync vs async quick reference

Event In the HTTP response? Technology path Where to check afterward
Measurement saved Yes (201) PostgreSQL (direct write) GET .../observations
Critical threshold alert Yes (alertGenerated: true) PostgreSQL (same transaction) + Redis (threshold lookup) GET .../alerts
Warning threshold alert No PostgreSQL → Kafka → warning evaluator → PostgreSQL GET .../alerts (after ~13 s)
NEWS2 score / alert No PostgreSQL → Kafka → NEWS2 scorer (reads Redis) → PostgreSQL GET .../news2/current, GET .../alerts
SOFA score / alert No PostgreSQL → Kafka → SOFA scorer (reads Redis lab cache) → PostgreSQL GET .../sofa/current, GET .../alerts
qSOFA screen No PostgreSQL → Kafka → sepsis engine (reads Redis) → PostgreSQL GET .../alerts, GET .../qsofa/current
GCS score / alert No PostgreSQL → Kafka → GCS scorer → PostgreSQL GET .../gcs/current, GET .../alerts
Trend alert No PostgreSQL → Kafka → trend analyzer (reads Redis history) → PostgreSQL GET .../alerts
Medication annotation on alert No — applied at alert creation Kafka consumer reads recent meds from PostgreSQL GET .../alerts → read details field
Search index updated No PostgreSQL → Kafka → ES indexer → Elasticsearch GET /analytics/... endpoints
Data lake file written No PostgreSQL → Kafka → data lake writer → MinIO (Parquet) MinIO bucket vigilcare
Clinician paged No Kafka → notification publisher → RabbitMQ paging queue Alert status field
Alert escalated No RabbitMQ paging queue → DLQ → escalation queue → PostgreSQL Alert status = escalated
Discharge summary PDF No Kafka → RabbitMQ discharge queue → worker → MinIO MinIO /discharge-summaries/{encounterId}/summary.pdf

How it all fits together — a real example

Imagine a patient named Maria arrives at the Emergency Department with a suspected urinary tract infection (UTI). Here is what happens in both the clinical and technology layers:

  1. Admission: A clerk registers Maria (POST /patients) and opens an emergency encounter (POST /patients/{id}/encounters with encounterType: EMERGENCY). Behind the scenes: patient and encounter rows are saved to PostgreSQL, an outbox event goes to Kafka, and the ES indexer creates a document in the patient_encounters Elasticsearch index — Maria now appears on the ward board.

  2. First vitals: A nurse records heart rate 95, respiratory rate 20, BP 115/75, temperature 38.8°C, SpO2 97%, AVPU = Alert, room air. The API checks each value against thresholds in Redis — no critical thresholds are crossed, so no immediate alerts. The observations are saved to PostgreSQL and outbox events flow to Kafka. The NEWS2 consumer reads all 7 parameters from Redis, calculates total = 3 → LOW risk, no alert. The trend analyzer stores these values in Redis as the starting point for rate-of-change tracking.

  3. Labs ordered: The doctor orders blood cultures, lactate, and a CBC (POST /encounters/{id}/orders). Broad-spectrum antibiotics are administered (POST /encounters/{id}/medications). These are saved to PostgreSQL — the medication record will be available for correlation if any warning alerts fire later.

  4. Two hours later — getting worse: New vitals come in: heart rate 118, respiratory rate 24, BP 95/60, temperature 39.5°C, SpO2 94%. The API reads threshold:SYSTOLIC_BP from Redis and sees 95 is below warning (100) but above critical (70) — no sync alert. The observations go to Kafka, where multiple consumers react:

    • Warning evaluator → checks Redis suppression keys, finds none → creates WARNING_SYSTOLIC_BP in PostgreSQL
    • NEWS2 scorer → reads 7 parameters from Redis, calculates total = 8 → HIGH risk → creates NEWS2_EMERGENCY in PostgreSQL
    • Sepsis engine → checks qSOFA criteria in Redis: respiratory rate ≥22 and systolic BP ≤100 → 2 criteria met → creates QSOFA_SCREEN in PostgreSQL
    • Trend analyzer → reads previous heart rate values from Redis, calculates 23 bpm rise → RAPID_DETERIORATION in PostgreSQL
    • Each alert generates an outbox event → KafkaES indexer (updates Elasticsearch) and notification publisher → critical alerts go to RabbitMQ alerts.paging.queue → clinician is paged
  5. Lab results arrive: Lactate comes back at 3.2 mmol/L (high — tissue hypoxia). Creatinine is 2.1 mg/dL (elevated — kidneys struggling). These go through Kafka to the SOFA scorer, which reads all cached lab values from Redis (sofa:{encounterId}:...) and recalculates. SOFA jumps by 3 points from baseline → SOFA_SEPSIS alert fires in PostgreSQL. The SOFA engine also creates a sepsis bundle with 4 auto-generated orders in PostgreSQL, and a sepsis.bundle.created event flows through Kafka to Elasticsearch (the ward board now shows sepsisActive: true).

  6. Sepsis bundle completion: The team draws blood cultures, gives IV fluids, and confirms antibiotics were already given. Each order is completed via PATCH /orders/{id}/result — saved to PostgreSQL with a sepsis.bundle.updated outbox event → KafkaElasticsearch (bundle status updates in real time on the ward board). All 4 elements complete within the deadline → bundle status: COMPLIANT.

  7. Stabilization and discharge: Over the next 48 hours, vitals normalize. NEWS2 drops back to 2. SOFA returns to baseline. Maria is discharged with PATCH /encounters/{id}/status. The discharge event flows through KafkaElasticsearch (Maria drops off the active ward list) → RabbitMQ notifications.discharge.queue → the discharge summary worker gathers all her encounter data from PostgreSQL, builds a PDF, and uploads it to MinIO. Meanwhile, the data lake writer has been steadily writing all her observations, alerts, and encounter events to Parquet files in MinIO — ready for future analysis.

Throughout this story, the ward board showed Maria's deterioration in real time — her NEWS2 score rising, qSOFA flagging, SOFA alerting — giving the clinical team the information they needed to act fast. All of this was powered by the data flowing through PostgreSQL → Kafka → Redis/Elasticsearch/RabbitMQ/MinIO behind the scenes.