Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions aidbox-features/aidbox-orchestration-service/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# SMART Backend Services credentials
# Test RSA private key (PKCS#8) — matches public key in general-practice-config/init.json
# Use \n for newlines
SMART_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+EfYcGCDTGRh8\natyBcMQP5ehtE3bv06VaQCLk+uDV0+CPjsLxO+5XRiAUOg9ea2Gufw72DLNpUd30\ndo7d50iaqmDfC/ixCy0UXT0UHjHQNfqUizhJY1/AVj4HaxubGkpK/FVeIzpBEG8L\nruDqGCZflPkjDMYVTeb6Gk4ZnnSLIJWZE6ZCwWvFT9fFI+x2sf/JhpyPTsomhPN6\n6qja//MVjzw0HRy4Nb6SOhprGbK55fhKke3oz8hW2Y90nrBEn/HUCdqV9IQvaAeK\nILETxmEZYnni0+9IrefgD4AyIfFR4KCNfqPG7KyxJvC1o7wrd/yuXCyz03+sZjWX\ni6DXM7mxAgMBAAECggEAXuK+l0XgVSIpHCuIy0HNTxZ6UsGt1Yo1+PkdsmwgA/9T\nEre1UBKYKI+EgjR96af3ytH5WRH8Gu7YvCrXpaXJlBTMaW0jiNbIeWsWi82LFqNr\n5e4eelyWt4EWVEO/M04LmqWfxHAXq9WVaiKye4r01TCcs0e0N3x9e4vYQ2fcTHto\n2jybdna/dIuFzgnhk5fGC7nJ17Dnf00/8pJHd6JRoYb/EecuSbB4OjGuPgtF7N9j\nlKto8TwEeIIcKnUpqUxQD5keVluTqDVZt38LS62V7wRnwLzINIlzsTZ7fdgd/CPm\nnjY3Sa8t+j4ULzvWm4goW4kBYJ76G8hXRvA+oKkYAQKBgQD6BylXOswFlDgO6VoP\nQVrujxjalpSp9Vvovo/sSS40UO9hqsNHjuuwPNtLsV1e+GSC14oKfmKt4YooeES5\nmDkzAtlyKUcLtIRJifqQRyBbqf+8ZqtZkXavEp2L8VsKu45rNalHvgwqgCNNVdJ/\n1vnpiGt9z0wrCrQ63fWVOcXnMQKBgQDCnC16ZRgWLKAMc5j6gBAjsMk/SbLk7yM9\n7Ru1YSDFubQ/d8RTNcWDsM1r7OuwsD3zpTHk7b2BFSXtHKgtmV02TPlen9IFA1AV\nUT7rF840ONJSZzUq1/737kSuznQh83WL6BksDfWZRV2iYMuUZNcO0tTX+aUJAgt4\nQwvFY6NagQKBgQDRT018iOxjf0Guugt62euV6pWT6Jtr7MuUfHNgC6NyiI7d5Ga2\ncR892rR7GXBhIPCD2IznXAagKj/OwWBHPvgjjC8dMxEW63gTWD86qVCdbCN7RTgN\nM4l35s2daeAdjAYeGj4soRzuN3dWNpKSExYEOwBBwlixb7SR017UHhlfAQKBgGp1\nWx+Ia/u9X7RQDFCEe8+6ZuzbGSTJeMLokW7QekgPxX2uu9Q1Jx5aOpWennQihVFi\nff/Y2gDiG8QxGAMR0X7h7syHqzEY1ddDgaLDfAbvSobPdLNCQ3VHf4UM5VSpRRVK\n23JRFJhK7OTmBJfh7g9q4Aphw5lA6Btaufa6AeOBAoGAXmF6ImjDJcxTzN+f1hVa\nTBdE+QhdLPN8NHMzKK5xkurqBEc+nKx5RWBiVZ+EhnDO59jQ6Jgimkw4LUCuVNGG\nd/p1+ddTsZ88qby+iFbFnDljo/q29SOgXaySc9Hi723sQgmTiPPPKc5wXe/1mUY0\nrkSM+azcO0Wyc+OAAfX7u6A=\n-----END PRIVATE KEY-----"
SMART_KEY_ID=test-key-001
SMART_CLIENT_ID=orchestration-service
32 changes: 32 additions & 0 deletions aidbox-features/aidbox-orchestration-service/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Dependencies
node_modules/

# Environment
.env
.env.local
.env.*.local

# Build
dist/
*.tsbuildinfo

# IDE
.idea/
.vscode/
*.swp
*.swo
*~

# OS
.DS_Store
Thumbs.db

# Logs
*.log
npm-debug.log*

# Bun
bun.lock

# Codegen
.codegen-cache/
41 changes: 41 additions & 0 deletions aidbox-features/aidbox-orchestration-service/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# CLAUDE.md

## Project Overview

FHIR Orchestration Service POC implementing `$getstructuredrecord` (GP Connect). Fetches patient data from 2 FHIR sources (SMART Backend Services + Basic Auth), deduplicates via ConceptMap/$translate, stores with Provenance, and returns merged Bundle.

## FHIR Type Generation

FHIR TypeScript types are generated using `@atomic-ehr/codegen`. The config is at `scripts/generate-types.ts`.

Generated types are output to `src/fhir-types/` — do not edit these files manually.

```ts
import type { Patient, Bundle, Provenance } from "./fhir-types/hl7-fhir-r4-core";
import type { UKCoreAllergyIntolerance } from "./fhir-types/fhir-r4-ukcore-stu2/profiles/UkcoreAllergyIntolerance";
import { UKCorePatientProfile } from "./fhir-types/fhir-r4-ukcore-stu2/profiles";
```

To regenerate types: `bun run generate-types`.

## Commands

| Command | Description |
|---------|-------------|
| `bun run start` | Start orchestration service |
| `bun run typecheck` | TypeScript type check |
| `bun run generate-types` | Regenerate FHIR TypeScript types |
| `docker compose up -d --build` | Start all services |

## Project Structure

- `src/index.ts` — HTTP server (Bun.serve), routing, error handling
- `src/orchestration.ts` — Main orchestration flow (fetch, deduplicate, store)
- `src/deduplication.ts` — Per-resource deduplication logic (Patient merge, AllergyIntolerance code match, Observation time/value match)
- `src/provenance.ts` — Provenance resource creation for audit trail
- `src/fhir-clients.ts` — Auth providers (SMART + Basic), source fetching
- `src/fhir-types/` — Auto-generated FHIR types (do not edit)
- `scripts/generate-types.ts` — Codegen configuration
- `aidbox-config/init.json` — ConceptMap for LOINC->SNOMED CT translation
- `general-practice-config/init.json` — GP test data (SNOMED CT)
- `hospital-config/init.json` — Hospital test data (LOINC)
25 changes: 25 additions & 0 deletions aidbox-features/aidbox-orchestration-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM oven/bun:1 AS base
WORKDIR /app

# Install dependencies
FROM base AS install
RUN apt-get update && apt-get install -y curl
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install

# Production stage
FROM base AS release
RUN apt-get update && apt-get install -y curl
COPY --from=install /app/node_modules node_modules
COPY src ./src
COPY package.json .
COPY tsconfig.json .

USER bun

EXPOSE 3000

HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1

CMD ["bun", "run", "src/index.ts"]
252 changes: 252 additions & 0 deletions aidbox-features/aidbox-orchestration-service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
---
features: [FHIR Orchestration, FHIR Facade, Deduplication, ConceptMap, Provenance, SMART on FHIR]
languages: [TypeScript]
---

# FHIR Orchestration Service

Implements [`$getstructuredrecord`](https://simplifier.net/guide/gpconnect-data-model/Home/FHIR-Assets/All-assets/OperationDefinitions/OperationDefinition-GPConnect-GetStructuredRecord-Operation-1?version=current) operation that fetches patient data from multiple FHIR sources, deduplicates resources using terminology translation, and returns a merged Bundle with Provenance tracking.

## Problem

Patient data is often spread across multiple healthcare systems that use different terminologies (SNOMED CT, LOINC) and authentication methods (SMART Backend Services, Basic Auth). A client application needs a single, deduplicated view of the patient record.

### Requirements

1. **Multi-source aggregation**: Fetch data from General Practice (SMART on FHIR) and Hospital (Basic Auth) in parallel
2. **Cross-terminology deduplication**: Match resources coded in different systems (LOINC vs SNOMED CT) using [ConceptMap/$translate](https://hl7.org/fhir/R4/conceptmap-operation-translate.html)
3. **Audit trail**: Store source bundles and merged results with [Provenance](https://hl7.org/fhir/R4/provenance.html)

## Architecture

```mermaid
flowchart LR
CA(Client Application):::blue
OL(Orchestration Service):::green
FS[(Aidbox)]:::green
GP[(Aidbox<br/>General Practice<br/>SMART on FHIR)]:::orange
HOSP[(Aidbox<br/>Hospital<br/>Basic Auth)]:::orange

CA -->|"$getstructuredrecord"| OL
OL -->|"Bundle"| CA
OL <-->|"Store + Deduplicate"| FS
OL -->|"GET Bundle"| GP
OL -->|"GET Bundle"| HOSP

classDef blue fill:#e1f5fe,stroke:#01579b
classDef green fill:#e8f5e9,stroke:#2e7d32
classDef orange fill:#fff3e0,stroke:#ef6c00
```

For the POC, all three FHIR servers are Aidbox instances. In production, the external sources would be real GP and Hospital FHIR servers.

## Sequence Diagram

```mermaid
sequenceDiagram
participant Client
participant FS as FHIR Server
participant Orch as Orchestration
participant GP as General Practice
participant Hospital

Client->>Orch: $getstructuredrecord (NHS Number)

par Fetch from sources
Orch->>GP: POST /auth/token (JWT)
GP-->>Orch: access_token
Orch->>GP: GET /fhir/Bundle (Bearer)
GP-->>Orch: Bundle
and
Orch->>Hospital: GET /fhir/Bundle (Basic auth)
Hospital-->>Orch: Bundle
end

Orch->>FS: Store 2 source Bundles
Orch->>FS: ConceptMap/$translate
FS-->>Orch: Translated code
Note over Orch: Deduplicate & Merge
Orch->>FS: Store merged Bundle + Provenance

Orch-->>Client: Merged Bundle
```

### Flow

1. **Client request** - Client calls `$getstructuredrecord` with patient's NHS Number
2. **Parallel fetch** - Orchestration fetches bundles from both sources simultaneously:
- General Practice: SMART Backend Services auth (JWT client assertion -> Bearer token)
- Hospital: Basic authentication
3. **Store for audit** - Source bundles, merged bundle, and Provenance stored in main FHIR Server
4. **Terminology normalization** - `ConceptMap/$translate` converts LOINC codes to SNOMED CT for cross-system matching
5. **Deduplication** - Resources with matching normalized codes are deduplicated
6. **Response** - Merged bundle with deduplicated resources returned to client

## Deduplication Algorithms

| Resource | Key | Algorithm | Why? |
| ---------------------- | ----------------------- | ------------------------ | ---------------------------------------------------------------- |
| **Patient** | NHS Number | Merge | One person = one patient, combine data from sources |
| **AllergyIntolerance** | code (normalized) | Match + select by status | Same allergy in different terminologies -> ConceptMap translation |
| **Observation** | code + time +/-1h + value | Match if all equal | Same measurement, but different values = clinically significant |
| **Encounter** | - | No deduplication | Each visit is unique, even on the same day |

### Patient

```
1. Group by NHS Number
2. Merge: keep most complete name (more given names wins)
3. Merge: take first non-null telecom, address
4. Result: single Patient with merged data
```

### AllergyIntolerance

```
1. Translate LOINC codes to SNOMED CT via ConceptMap/$translate
2. Group by normalized SNOMED code
3. Select canonical: prefer confirmed verificationStatus
```

Example:

```
General Practice: SNOMED 91936005 (confirmed) -+
Hospital: LOINC LA30099-6 (unconfirmed) -+-> translate -> match -> keep GP (confirmed)
```

### Observation

```
1. Group by: code + effectiveDateTime (+/-1h)
2. Compare values:
- Same value -> deduplicate
- Different value -> keep both (clinical significance)
3. Select canonical: prefer has interpretation, has referenceRange
```

Example:

```
GP: HbA1c = 7.2% @ 10:00 -+-> same code, +/-1h, same value -> deduplicate
Hospital: HbA1c = 7.2% @ 10:30 -+

GP: HbA1c = 7.2% @ 10:00 -> keep
Hospital: HbA1c = 6.8% @ 10:30 -> keep (different value = clinically significant)
```

## Quick Start

### 1. Configure environment

```bash
cp .env.example .env
```

The `.env.example` includes a test RSA private key for SMART Backend Services authentication. The matching public key is already configured in `general-practice-config/init.json`.

### 2. Start services

```bash
docker compose up -d --build
```

Wait for all services to become healthy:

```bash
docker compose ps
```

All 4 services should show "healthy" status:
- http://localhost:8080 - Main FHIR server (Aidbox UI)
- http://localhost:8081 - General Practice FHIR server
- http://localhost:8082 - Hospital FHIR server
- http://localhost:3000 - Orchestration service

Each Aidbox instance loads its init bundle on startup:

- **fhir_server**: ConceptMap for LOINC->SNOMED CT translation (`aidbox-config/`)
- **general_practice**: Test patient bundle with SNOMED CT codes (`general-practice-config/`)
- **hospital**: Test patient bundle with LOINC codes (`hospital-config/`)

### 3. Test the orchestration

```bash
curl -X POST http://localhost:3000/fhir/Patient/\$getstructuredrecord \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Parameters",
"parameter": [{
"name": "patientNHSNumber",
"valueIdentifier": {
"system": "https://fhir.nhs.uk/Id/nhs-number",
"value": "9876543210"
}
}]
}'
```

Expected response: Merged bundle with 1 Patient, 1 AllergyIntolerance (2->1 deduplicated via ConceptMap), 2 Observations (3->2 deduplicated), 2 Encounters.

### 4. Verify Provenance

Provenance resources are stored for audit but not included in response:

```bash
curl -u root:secret http://localhost:8080/fhir/Provenance
```

## Test Data

All resources conform to [UK Core STU2](https://simplifier.net/hl7fhirukcorer4) profiles.

**General Practice (SNOMED CT):**

- [UKCore-Patient](https://simplifier.net/hl7fhirukcorer4/ukcorepatient): NHS 9876543210, Smith John William, address + telecom
- [UKCore-AllergyIntolerance](https://simplifier.net/hl7fhirukcorer4/ukcoreallergyintolerance): SNOMED `91936005` (Allergy to penicillin), confirmed, high criticality
- [UKCore-Observation](https://simplifier.net/hl7fhirukcorer4/ukcoreobservation): HbA1c = 7.2% @ 2024-01-15T10:00 (with interpretation + referenceRange)
- [UKCore-Encounter](https://simplifier.net/hl7fhirukcorer4/ukcoreencounter): ENC-SMART-001

**Hospital (LOINC):**

- [UKCore-Patient](https://simplifier.net/hl7fhirukcorer4/ukcorepatient): NHS 9876543210, Smith John, local ID H12345
- [UKCore-AllergyIntolerance](https://simplifier.net/hl7fhirukcorer4/ukcoreallergyintolerance): LOINC `LA30099-6` (Penicillin allergy), unconfirmed
- [UKCore-Observation](https://simplifier.net/hl7fhirukcorer4/ukcoreobservation): HbA1c = 7.2% @ 2024-01-15T10:30 (duplicate)
- [UKCore-Observation](https://simplifier.net/hl7fhirukcorer4/ukcoreobservation): HbA1c = 6.8% @ 2024-02-20T14:00 (unique)
- [UKCore-Encounter](https://simplifier.net/hl7fhirukcorer4/ukcoreencounter): ENC-BASIC-001

**Result after orchestration:**

| Resource | Source | Result | Reason |
| ------------------ | ------------------ | ------ | ------------------------------------------------------------------------- |
| Patient | 2 (GP + Hospital) | 1 | Merged by NHS Number, kept "John William" (more given names) |
| AllergyIntolerance | 2 (SNOMED + LOINC) | 1 | LOINC->SNOMED translation matched, kept GP's (confirmed > unconfirmed) |
| Observation (Jan) | 2 (GP + Hospital) | 1 | Same code, +/-30min, same value -> duplicate, kept GP's (has interpretation) |
| Observation (Feb) | 1 (Hospital only) | 1 | Unique date, no match |
| Encounter | 2 (GP + Hospital) | 2 | No deduplication (different identifiers) |

## Services

| Service | URL | Auth | Description |
| ------------------ | --------------------- | ---------------------- | ---------------------------- |
| `fhir_server` | http://localhost:8080 | Basic (root:secret) | Main FHIR server (storage) |
| `general_practice` | http://localhost:8081 | SMART Backend Services | General Practice (SNOMED CT) |
| `hospital` | http://localhost:8082 | Basic Auth | Hospital (LOINC) |
| `orchestration` | http://localhost:3000 | - | Orchestration service |

## FHIR Implementation Guides

| Package | Version | Description |
| ------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------- |
| [hl7.fhir.r4.core](https://hl7.org/fhir/R4/) | 4.0.1 | FHIR R4 base types |
| [fhir.r4.ukcore.stu2](https://simplifier.net/hl7fhirukcorer4) | 2.0.2 | UK Core R4 profiles (UKCorePatient, UKCoreAllergyIntolerance, UKCoreObservation, etc.) |

TypeScript types for both packages are generated into `src/fhir-types/` using `@atomic-ehr/codegen`.

## Local Development

```bash
bun install
bun run src/index.ts
bun run tsc --noEmit
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"type": "transaction",
"entry": [
{
"resource": {
"resourceType": "ConceptMap",
"id": "allergy-loinc-to-snomed",
"url": "http://example.org/ConceptMap/allergy-loinc-to-snomed",
"name": "AllergyLoincToSnomed",
"title": "Allergy LOINC to SNOMED CT Mapping",
"status": "active",
"description": "Maps LOINC answer codes to SNOMED CT for NHS deduplication",
"sourceUri": "http://loinc.org",
"targetUri": "http://snomed.info/sct",
"group": [
{
"source": "http://loinc.org",
"target": "http://snomed.info/sct",
"element": [
{
"code": "LA30099-6",
"display": "Penicillin allergy",
"target": [
{
"code": "91936005",
"display": "Allergy to penicillin",
"equivalence": "equivalent"
}
]
}
]
}
]
},
"request": {"method": "POST", "url": "/ConceptMap"}
}
]
}
Loading