diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e249cbf..5cbc68b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: Test -on: [push, pull_request] +on: [push] jobs: unit: diff --git a/cht-config/Dockerfile b/cht-config/Dockerfile new file mode 100644 index 00000000..3ba4aee1 --- /dev/null +++ b/cht-config/Dockerfile @@ -0,0 +1,20 @@ +FROM node:16-slim + +RUN apt update \ + && apt install --no-install-recommends -y \ + curl \ + git \ + python3-pip \ + python3-setuptools \ + python3-wheel \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -m pip install git+https://github.com/medic/pyxform.git@medic-conf-1.17#egg=pyxform-medic + +WORKDIR /scripts/cht-config + +COPY ./ ./ + +RUN npm install --ignore-scripts && npm install -g --ignore-scripts cht-conf + +CMD ["npm", "run", "deploy"] diff --git a/cht-config/app_settings.json b/cht-config/app_settings.json index 80522ce3..8da0d58d 100644 --- a/cht-config/app_settings.json +++ b/cht-config/app_settings.json @@ -442,11 +442,11 @@ "mark_for_outbound": true }, "outbound": { - "FHIR_patient": { - "relevant_to": "doc.type === 'person' && doc.role == 'patient'", + "patient": { + "relevant_to": "doc.type === 'person' && doc.patient_id && doc.role === 'patient'", "destination": { "base_url": "http://openhim-core:5001", - "path": "/mediator/patient", + "path": "/mediator/cht/patient", "auth": { "type": "basic", "username": "interop-client", @@ -454,19 +454,29 @@ } }, "mapping": { - "resourceType": { - "expr": "'Patient'" - }, - "identifier": { - "expr": "[{ \"system\": \"cht\", \"use\": \"official\", \"value\": doc._id }]", - "optional": false - }, - "name": { - "expr": "[ { \"use\":\"official\", \"family\": doc.name , \"given\": [ doc.short_name ] } ]", - "optional": false - }, - "gender": "doc.sex", - "birthDate": "doc.date_of_birth" + "doc._id": "doc._id", + "doc.name": "doc.name", + "doc.phone": "doc.phone", + "doc.date_of_birth": "doc.date_of_birth", + "doc.sex": "doc.sex", + "doc.patient_id": "doc.patient_id" + } + }, + "patient_id": { + "relevant_to": "doc.type === 'data_record' && doc.form === 'OPENMRS_PATIENT'", + "destination": { + "base_url": "http://openhim-core:5001", + "path": "/mediator/cht/patient_ids", + "auth": { + "type": "basic", + "username": "interop-client", + "password_key": "openhim1" + } + }, + "mapping": { + "doc._id": "doc._id", + "doc.patient_id": "doc.patient_id", + "doc.external_id": "doc.fields.external_id" } }, "FHIR_practitioner": { @@ -541,6 +551,37 @@ "expr": "[ { \"type\": [ { \"text\": \"Community health worker\" } ] } ]" } } + }, + "openmrs_height_weight": { + "relevant_to": "doc.type === 'data_record' && doc.form === 'HEIGHT_WEIGHT'", + "destination": { + "base_url": "http://openhim-core:5001", + "path": "/mediator/cht/encounter", + "auth": { + "type": "basic", + "username": "interop-client", + "password_key": "openhim1" + } + }, + "mapping": { + "id": "doc._id", + "patient_uuid": "doc.fields.patient_uuid", + "reported_date": "doc.reported_date", + "observations.0.valueQuantity.value": "doc.fields.height", + "observations.0.valueQuantity.unit": { + "expr": "\"cm\"" + }, + "observations.0.code": { + "expr": "\"5090AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"" + }, + "observations.1.valueQuantity.value": "doc.fields.weight", + "observations.1.valueQuantity.unit": { + "expr": "\"kg\"" + }, + "observations.1.code": { + "expr": "\"5089AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"" + } + } } }, "forms": { @@ -570,6 +611,173 @@ }, "set_task": true } + }, + "HEIGHT_WEIGHT": { + "meta": { + "code": "height_weight", + "translation_key": "forms.openmrs_height_weight.title", + "icon": "medic-person" + }, + "fields": { + "patient_uuid": { + "labels": { + "tiny": { + "en": "ID" + }, + "short": { + "translation_key": "patient_id" + } + }, + "position": 0, + "type": "string", + "length": [ + 1, + 13 + ], + "required": true + }, + "height": { + "labels": { + "short": { + "en": "Heght in cm" + } + }, + "position": 1, + "type": "integer", + "required": true + }, + "weight": { + "labels": { + "short": { + "en": "Weight in kg" + } + }, + "position": 2, + "type": "integer", + "required": true + } + }, + "public_form": true + }, + "OPENMRS_INCOMING": { + "meta": { + "code": "openmrs_incoming", + "translation_key": "forms.openmrs_height_weight.title", + "icon": "medic-person" + }, + "fields": { + "5090AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": { + "labels": { + "short": { + "en": "Heght in cm" + } + }, + "position": 0, + "type": "integer", + "required": true + }, + "5089AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": { + "labels": { + "short": { + "en": "Weight in kg" + } + }, + "position": 1, + "type": "integer", + "required": true + } + }, + "public_form": true + }, + "OPENMRS_PATIENT": { + "meta": { + "code": "openmrs_patient", + "translation_key": "forms.n.title", + "icon": "medic-person" + }, + "fields": { + "age_in_days": { + "labels": { + "tiny": { + "en": "Age in Days" + }, + "short": { + "en": "Age in Days" + } + }, + "position": 0, + "type": "integer", + "required": true + }, + "patient_name": { + "labels": { + "tiny": { + "en": "patient_name" + }, + "short": { + "en": "Patient Name" + } + }, + "position": 1, + "type": "string", + "length": [ + 1, + 40 + ], + "required": true + }, + "phone_number": { + "labels": { + "tiny": { + "en": "patient phone" + }, + "short": { + "en": "patient Phone" + } + }, + "position": 2, + "flags":{ + "allow_duplicate": false + }, + "type": "phone_number", + "required": true + }, + "location_id": { + "labels": { + "tiny": { + "en": "location_id" + }, + "short": { + "en": "location_id" + } + }, + "position": 3, + "type": "string", + "length": [ + 1, + 60 + ], + "required": true + }, + "external_id": { + "labels": { + "tiny": { + "en": "OpenMRS ID" + }, + "short": { + "en": "OpenMRS ID" + } + }, + "position": 4, + "type": "string", + "length": [ + 1, + 60 + ], + "required": true + } + }, + "public_form": true } } -} \ No newline at end of file +} diff --git a/cht-config/package.json b/cht-config/package.json index d84a3a85..2cebce4e 100644 --- a/cht-config/package.json +++ b/cht-config/package.json @@ -13,7 +13,7 @@ "test-targets": "npm run eslint && TZ=Africa/Nairobi mocha --reporter progress test/targets/*.spec.js --timeout 10000", "test-contact-summary": "npm run eslint && TZ=Africa/Nairobi mocha --reporter progress test/contact-summary/*.spec.js --timeout 10000", "test-unit": "TZ=Africa/Nairobi mocha --recursive --reporter spec test --timeout 20000", - "deploy": "wait-on http://api:5988/ && sleep 100 && sh ./script.sh" + "deploy": "wait-on http://api:5988/ && sh ./script.sh" }, "devDependencies": { "chai": "^4.2.0", diff --git a/configurator/Dockerfile b/configurator/Dockerfile index 55a051a3..3ca72577 100644 --- a/configurator/Dockerfile +++ b/configurator/Dockerfile @@ -1,23 +1,12 @@ -FROM node:16-alpine - -RUN apk add g++ make py3-pip git curl chromium +FROM node:22-alpine WORKDIR /scripts/configurator -COPY ./configurator ./ +COPY ./package.json ./package.json +COPY ./package-lock.json ./package-lock.json RUN npm install -WORKDIR /scripts/cht-config - -COPY ../cht-config ./ - -RUN npm install && npm install -g cht-conf && python -m pip install git+https://github.com/medic/pyxform.git@medic-conf-1.17#egg=pyxform-medic - -WORKDIR /scripts - -RUN echo "cd /scripts/configurator && npm run configure && cd /scripts/cht-config && npm run deploy && exit 0" > ./startup.sh - -RUN chmod +x ./startup.sh +COPY ./ ./ -CMD ["sh", "./startup.sh"] +CMD ["npm", "run", "configure"] diff --git a/configurator/config/index.js b/configurator/config/index.js index 68dce649..579d7fec 100644 --- a/configurator/config/index.js +++ b/configurator/config/index.js @@ -9,11 +9,23 @@ const OPENHIM_API_USERNAME = const OPENHIM_CLIENT_PASSWORD = process.env.OPENHIM_CLIENT_PASSWORD || 'interop-password'; const OPENHIM_USER_PASSWORD = process.env.OPENHIM_USER_PASSWORD || 'interop-password'; +const OPENMRS_HOST = process.env.OPENMRS_HOST; +const OPENMRS_PORT = process.env.OPENMRS_PORT || 8080; +const OPENMRS_USERNAME = process.env.OPENMRS_USERNAME; +const OPENMRS_PASSWORD = process.env.OPENMRS_PASSWORD; +const OPENMRS_PROTOCOL = process.env.OPENMRS_PROTOCOL || 'http'; + module.exports = { OPENHIM_API_HOSTNAME, OPENHIM_API_PASSWORD, OPENHIM_API_PORT, OPENHIM_API_USERNAME, OPENHIM_CLIENT_PASSWORD, - OPENHIM_USER_PASSWORD + OPENHIM_USER_PASSWORD, + + OPENMRS_HOST, + OPENMRS_PORT, + OPENMRS_USERNAME, + OPENMRS_PASSWORD, + OPENMRS_PROTOCOL }; diff --git a/configurator/env.template b/configurator/env.template index 8c172f48..fb0c5d99 100644 --- a/configurator/env.template +++ b/configurator/env.template @@ -4,3 +4,7 @@ OPENHIM_PASSWORD = 'openhim-password'; OPENHIM_USERNAME = 'root@openhim.org'; OPENHIM_CLIENT_PASSWORD = 'interop-password'; OPENHIM_USER_PASSWORD = 'interop-password'; + +OPENMRS_HOST='openmrs' +OPENMRS_PASSWORD='Admin123' +OPENMRS_USERNAME='admin' diff --git a/configurator/index.js b/configurator/index.js index 9262294e..f9202ec7 100644 --- a/configurator/index.js +++ b/configurator/index.js @@ -1,6 +1,7 @@ const {OPENHIM_USER_PASSWORD, OPENHIM_CLIENT_PASSWORD} = require('./config'); +const {OPENMRS_USERNAME, OPENMRS_PASSWORD, OPENMRS_HOST, OPENMRS_PORT, OPENMRS_PROTOCOL} = require('./config'); const {generateApiOptions, generateAuthHeaders} = require('./libs/authentication'); -const {generateUser, generateClient, generateHapiFihrChannel} = require('./libs/generators'); +const {generateUser, generateClient, generateHapiFhirChannel, generateOpenMRSChannel} = require('./libs/generators'); const {fetch} = require('./utils'); const logger = require('./logger'); @@ -13,9 +14,20 @@ async function handleConfiguration () { ContactGroups: [] }; + const openMRSConfig = { + host: OPENMRS_HOST, + port: OPENMRS_PORT, + username: OPENMRS_USERNAME, + password: OPENMRS_PASSWORD, + protocol: OPENMRS_PROTOCOL + } + metadata.Users.push(await generateUser(OPENHIM_USER_PASSWORD)); metadata.Clients.push(await generateClient(OPENHIM_CLIENT_PASSWORD)); - metadata.Channels.push(await generateHapiFihrChannel()); + metadata.Channels.push(await generateHapiFhirChannel()); + if (OPENMRS_HOST) { + metadata.Channels.push(await generateOpenMRSChannel(openMRSConfig)); + } const data = JSON.stringify(metadata); const apiOptions = generateApiOptions('/metadata'); diff --git a/configurator/libs/generators.js b/configurator/libs/generators.js index 87178838..dc9edd61 100644 --- a/configurator/libs/generators.js +++ b/configurator/libs/generators.js @@ -33,7 +33,7 @@ async function generateUser (password) { }; } -async function generateHapiFihrChannel () { +async function generateHapiFhirChannel () { return { methods: [ 'GET', @@ -94,8 +94,70 @@ async function generateHapiFihrChannel () { }; } +async function generateOpenMRSChannel (config) { + const { host, port, username, password, type } = config; + return { + methods: [ + 'GET', + 'POST', + 'DELETE', + 'PUT', + 'OPTIONS', + 'HEAD', + 'TRACE', + 'CONNECT', + 'PATCH' + ], + type: type, + allow: CLIENT_ROLES, + whitelist: [], + authType: 'private', + matchContentTypes: [], + properties: [], + txViewAcl: [], + txViewFullAcl: [], + txRerunAcl: [], + status: 'enabled', + rewriteUrls: false, + addAutoRewriteRules: true, + autoRetryEnabled: false, + autoRetryPeriodMinutes: 60, + routes: [ + { + type: type, + status: 'enabled', + forwardAuthHeader: false, + name: 'OpenMRS', + secured: false, + host: host, + port: port, + path: '', + pathTransform: 's/openmrs/openmrs\\/ws\\/fhir2\\/R4/g', + primary: true, + username: username, + password: password + } + ], + requestBody: true, + responseBody: true, + rewriteUrlsConfig: [], + name: 'OpenMRS', + description: 'OpenMRS', + urlPattern: '^/openmrs/.*$', + priority: 1, + matchContentRegex: null, + matchContentXpath: null, + matchContentValue: null, + matchContentJson: null, + pollingSchedule: null, + tcpHost: null, + tcpPort: null, + alerts: [] + }; +} module.exports = { generateClient, generateUser, - generateHapiFihrChannel + generateHapiFhirChannel, + generateOpenMRSChannel }; diff --git a/docker/docker-compose.cht-core.yml b/docker/docker-compose.cht-core.yml index d13c53cc..4f585af1 100644 --- a/docker/docker-compose.cht-core.yml +++ b/docker/docker-compose.cht-core.yml @@ -1,8 +1,7 @@ -version: '3.9' services: haproxy: - image: public.ecr.aws/medic/cht-haproxy:4.1.0-alpha + image: public.ecr.aws/medic/cht-haproxy:4.10.0 restart: always hostname: haproxy environment: @@ -12,6 +11,7 @@ services: - "COUCHDB_SERVERS=${COUCHDB_SERVERS:-couchdb}" - "HAPROXY_PORT=${HAPROXY_PORT:-5984}" - "HEALTHCHECK_ADDR=${HEALTHCHECK_ADDR:-healthcheck}" + - "DOCKER_DNS_RESOLVER=true" logging: driver: "local" options: @@ -19,14 +19,15 @@ services: max-file: "${LOG_MAX_FILES:-20}" networks: - cht-net + deploy: + resources: + limits: + memory: 1G expose: - ${HAPROXY_PORT:-5984} - ports: - - "5984:5984" - healthcheck: - image: public.ecr.aws/medic/cht-haproxy-healthcheck:4.1.0-alpha + image: public.ecr.aws/medic/cht-haproxy-healthcheck:4.10.0 restart: always environment: - "COUCHDB_SERVERS=${COUCHDB_SERVERS:-couchdb}" @@ -41,18 +42,18 @@ services: - cht-net api: - image: public.ecr.aws/medic/cht-api:4.1.0-alpha + image: public.ecr.aws/medic/cht-api:4.10.0 restart: always depends_on: - haproxy expose: - "${API_PORT:-5988}" - ports: - - "5988:5988" environment: - COUCH_URL=http://${COUCHDB_USER:-admin}:${COUCHDB_PASSWORD:-password}@haproxy:${HAPROXY_PORT:-5984}/medic - BUILDS_URL=${MARKET_URL_READ:-https://staging.dev.medicmobile.org}/${BUILDS_SERVER:-_couch/builds_4} - UPGRADE_SERVICE_URL=${UPGRADE_SERVICE_URL:-http://localhost:5100} + ports: + - "5988:5988" logging: driver: "local" options: @@ -62,7 +63,7 @@ services: - cht-net sentinel: - image: public.ecr.aws/medic/cht-sentinel:4.1.0-alpha + image: public.ecr.aws/medic/cht-sentinel:4.10.0 restart: always depends_on: - haproxy @@ -78,14 +79,14 @@ services: - cht-net nginx: - image: public.ecr.aws/medic/cht-nginx:4.1.0-alpha + image: public.ecr.aws/medic/cht-nginx:4.10.0 restart: always depends_on: - api - haproxy ports: - - "${NGINX_HTTP_PORT:-80}:80" - - "${NGINX_HTTPS_PORT:-443}:443" + - "${NGINX_HTTP_PORT:-8880}:80" + - "${NGINX_HTTPS_PORT:-8843}:443" volumes: - cht-ssl:${SSL_VOLUME_MOUNT_PATH:-/etc/nginx/private/} environment: @@ -109,9 +110,47 @@ services: networks: - cht-net + cht-configurator: + build: + context: ../cht-config + dockerfile: ./Dockerfile + environment: + - "COUCHDB_USER=${COUCHDB_USER:-admin}" + - "COUCHDB_PASSWORD=${COUCHDB_PASSWORD:-password}" + depends_on: + - couchdb + - api + networks: + - cht-net + + + couchdb: + image: public.ecr.aws/medic/cht-couchdb:4.10.0 + volumes: + - couchdb-data:/opt/couchdb/data + - cht-credentials:/opt/couchdb/etc/local.d/ + environment: + - "COUCHDB_USER=${COUCHDB_USER:-admin}" + - "COUCHDB_PASSWORD=${COUCHDB_PASSWORD:-password}" + - "COUCHDB_SECRET=${COUCHDB_SECRET:-secret}" + - "COUCHDB_UUID=${COUCHDB_UUID:-secret}" + - "SVC_NAME=${SVC_NAME:-couchdb}" + - "COUCHDB_LOG_LEVEL=${COUCHDB_LOG_LEVEL:-error}" + restart: always + logging: + driver: "local" + options: + max-size: "${LOG_MAX_SIZE:-50m}" + max-file: "${LOG_MAX_FILES:-20}" + networks: + - cht-net + + networks: cht-net: name: ${CHT_NETWORK:-cht-net} volumes: cht-ssl: + cht-credentials: + couchdb-data: diff --git a/docker/docker-compose.mediator.yml b/docker/docker-compose.mediator.yml index 6ff4d360..ee795858 100644 --- a/docker/docker-compose.mediator.yml +++ b/docker/docker-compose.mediator.yml @@ -1,5 +1,3 @@ -version: '3' - services: mediator: build: @@ -16,6 +14,9 @@ services: - "FHIR_URL=${FHIR_URL:-http://openhim-core:5001/fhir}" - "FHIR_USERNAME=${FHIR_USERNAME:-interop-client}" - "FHIR_PASSWORD=${FHIR_PASSWORD:-interop-password}" + - "OPENMRS_CHANNEL_URL=${OPENMRS_CHANNEL_URL:-http://openhim-core:5001/openmrs}" + - "OPENMRS_CHANNEL_USERNAME=${OPENMRS_CHANNEL_USERNAME:-interop-client}" + - "OPENMRS_CHANNEL_PASSWORD=${OPENMRS_CHANNEL_PASSWORD:-interop-password}" - "CHT_URL=${CHT_URL:-https://nginx}" - "CHT_USERNAME=${CHT_USERNAME:-admin}" - "CHT_PASSWORD=${CHT_PASSWORD:-password}" @@ -28,17 +29,18 @@ services: configurator: build: - context: ../ - dockerfile: ./configurator/Dockerfile + context: ../configurator + dockerfile: ./Dockerfile environment: - - "COUCHDB_USER=${COUCHDB_USER:-admin}" - - "COUCHDB_PASSWORD=${COUCHDB_PASSWORD:-password}" - "OPENHIM_API_HOSTNAME=${OPENHIM_API_HOSTNAME:-openhim-core}" - "OPENHIM_API_PORT=${OPENHIM_API_PORT:-8080}" - "OPENHIM_PASSWORD=${OPENHIM_PASSWORD:-openhim-password}" - "OPENHIM_USERNAME=${OPENHIM_USERNAME:-root@openhim.org}" - "OPENHIM_CLIENT_PASSWORD=${OPENHIM_CLIENT_PASSWORD:-interop-password}" - "OPENHIM_USER_PASSWORD=${OPENHIM_USER_PASSWORD:-interop-password}" + - "OPENMRS_HOST=${OPENMRS_HOST}" + - "OPENMRS_USERNAME=${OPENMRS_USERNAME}" + - "OPENMRS_PASSWORD=${OPENMRS_PASSWORD}" networks: - cht-net diff --git a/docker/docker-compose.openmrs.yml b/docker/docker-compose.openmrs.yml new file mode 100644 index 00000000..7ed25f40 --- /dev/null +++ b/docker/docker-compose.openmrs.yml @@ -0,0 +1,48 @@ +services: + openmrs-referenceapplication-mysql: + restart: "always" + platform: linux/x86_64 + image: mysql:5.6 + command: "mysqld --character-set-server=utf8 --collation-server=utf8_general_ci" + environment: + MYSQL_DATABASE: ${MYSQL_DB:-openmrs} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-Admin123} + MYSQL_USER: ${MYSQL_USER:-openmrs} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-Admin123} + ports: + - "3306:3306" + healthcheck: + test: "exit 0" + volumes: + - openmrs-referenceapplication-mysql-data:/var/lib/mysql + networks: + - cht-net + + openmrs: + restart: "always" + image: openmrs/openmrs-reference-application-distro:demo + depends_on: + - openmrs-referenceapplication-mysql + ports: + - "8090:8080" + environment: + DB_DATABASE: ${MYSQL_DB:-openmrs} + DB_HOST: openmrs-referenceapplication-mysql + DB_USERNAME: ${MYSQL_USER:-openmrs} + DB_PASSWORD: ${MYSQL_PASSWORD:-Admin123} + DB_CREATE_TABLES: 'true' + DB_AUTO_UPDATE: 'true' + MODULE_WEB_ADMIN: 'true' + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/openmrs/"] + timeout: 20s + volumes: + - openmrs-referenceapplication-data:/usr/local/tomcat/.OpenMRS/ + - /usr/local/tomcat/.OpenMRS/modules/ # do not store modules in data + - /usr/local/tomcat/.OpenMRS/owa/ # do not store owa in data + networks: + - cht-net + +volumes: + openmrs-referenceapplication-mysql-data: + openmrs-referenceapplication-data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e881eb68..659d8e06 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: mongo: image: mongo:4.2 @@ -11,13 +9,14 @@ services: openhim-core: container_name: openhim-core + platform: linux/x86_64 image: jembi/openhim-core:7 environment: - mongo_url=mongodb://mongo/openhim - mongo_atnaUrl=mongodb://mongo/openhim ports: - "8080:8080" - - "5000:5000" + - "5002:5000" - "5001:5001" - "5050:5050" - "5051:5051" @@ -30,6 +29,7 @@ services: openhim-console: container_name: openhim-console + platform: linux/x86_64 image: jembi/openhim-console:1.14.4 ports: - "9000:80" diff --git a/docs/local-test/OpenMRS Interop.postman_collection.json b/docs/local-test/OpenMRS Interop.postman_collection.json new file mode 100644 index 00000000..91977758 --- /dev/null +++ b/docs/local-test/OpenMRS Interop.postman_collection.json @@ -0,0 +1,862 @@ +{ + "info": { + "_postman_id": "961b51d8-e267-46d0-9dcc-0ae3349817e1", + "name": "OpenMRS Interop", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "39404962", + "_collection_link": "https://cht-ecosystem.postman.co/workspace/CHT-Ecosystem-Workspace~2c294298-272a-4a31-8c13-568b5d0706a4/collection/39404962-961b51d8-e267-46d0-9dcc-0ae3349817e1?action=share&source=collection_link&creator=39404962" + }, + "item": [ + { + "name": "Mediator Status", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"Mediator response is success\", () => {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson.status).to.eql('success');", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENHIM_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENHIM_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5001/mediator/", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "mediator", + "" + ] + } + }, + "response": [] + }, + { + "name": "OpenMRS Api Status", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"Mediator response is success\", () => {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson.status).to.eql('active');", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:8090/openmrs/ws/fhir2/R4/metadata", + "host": [ + "localhost" + ], + "port": "8090", + "path": [ + "openmrs", + "ws", + "fhir2", + "R4", + "metadata" + ] + } + }, + "response": [] + }, + { + "name": "createPatientIdentiferType", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(201);", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENMRS_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENMRS_USER}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"CHT Patient ID\",\n \"description\": \"CHT Patient ID\",\n \"required\": false,\n \"locationBehavior\": \"NOT_USED\",\n \"uniquenessBehavior\": \"Unique\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:8090/openmrs/ws/rest/v1/patientidentifiertype", + "host": [ + "localhost" + ], + "port": "8090", + "path": [ + "openmrs", + "ws", + "rest", + "v1", + "patientidentifiertype" + ] + } + }, + "response": [] + }, + { + "name": "createDocumentIdentiferType", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(201);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENMRS_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENMRS_USER}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"CHT Document ID\",\n \"description\": \"CHT Document ID\",\n \"required\": false,\n \"locationBehavior\": \"NOT_USED\",\n \"uniquenessBehavior\": \"Unique\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:8090/openmrs/ws/rest/v1/patientidentifiertype", + "host": [ + "localhost" + ], + "port": "8090", + "path": [ + "openmrs", + "ws", + "rest", + "v1", + "patientidentifiertype" + ] + } + }, + "response": [] + }, + { + "name": "CHT Create Patient", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.collectionVariables.set(\"cht_patient_id\", response.id);", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"CHT Patient creation is successful\", () => {", + " pm.expect(response.ok).to.eql(true);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{CHT_ADMIN_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{CHT_ADMIN_USER}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John Test\",\n \"phone\": \"+2548277217095\",\n \"date_of_birth\":\"1980-06-06\",\n \"sex\":\"male\",\n \"type\": \"person\",\n \"role\": \"patient\",\n \"contact_type\": \"patient\",\n \"place\": \"\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:5988/api/v1/people", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5988", + "path": [ + "api", + "v1", + "people" + ] + } + }, + "response": [] + }, + { + "name": "GET FHIR Patient", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"FHIR patient creation is successful\", () => {", + " pm.expect(response.total).to.eql(1);", + "});", + "", + "//should be the same as cht_patient_id", + "pm.collectionVariables.set(\"fhir_patient_id\", response.entry[0].resource.id);" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENHIM_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENHIM_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:5001/fhir/Patient/?identifier={{cht_patient_id}}", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "fhir", + "Patient", + "" + ], + "query": [ + { + "key": "identifier", + "value": "{{cht_patient_id}}" + } + ] + } + }, + "response": [] + }, + { + "name": "OpenMRS FHIR Sync Patient", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENHIM_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENHIM_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:5001/mediator/openmrs/sync", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "mediator", + "openmrs", + "sync" + ] + } + }, + "response": [] + }, + { + "name": "GET OpenMRS Patient", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.collectionVariables.set(\"openmrs_patient_id\", response.entry[0].resource.id);", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"OpenMRS patient creation is successful\", () => {", + " pm.expect(response.total).to.eql(1);", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENMRS_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENMRS_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:8090/openmrs/ws/fhir2/R4/Patient/?identifier={{cht_patient_id}}", + "host": [ + "localhost" + ], + "port": "8090", + "path": [ + "openmrs", + "ws", + "fhir2", + "R4", + "Patient", + "" + ], + "query": [ + { + "key": "identifier", + "value": "{{cht_patient_id}}" + } + ] + } + }, + "response": [] + }, + { + "name": "CHT Create Report", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{CHT_ADMIN_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{CHT_ADMIN_USER}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"_meta\": {\n \"form\": \"HEIGHT_WEIGHT\"\n },\n \"patient_uuid\": \"{{cht_patient_id}}\",\n \"height\": 172,\n \"weight\": 65\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:5988/api/v2/records", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5988", + "path": [ + "api", + "v2", + "records" + ] + } + }, + "response": [] + }, + { + "name": "GET FHIR Encounter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"FHIR encounter creation is successful\", () => {", + " pm.expect(response.total).to.eql(1);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENHIM_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENHIM_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:5001/fhir/Encounter/?subject=Patient/{{cht_patient_id}}", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "fhir", + "Encounter", + "" + ], + "query": [ + { + "key": "subject", + "value": "Patient/{{cht_patient_id}}" + } + ] + } + }, + "response": [] + }, + { + "name": "GET FHIR Observations", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"FHIR observation creation is successful\", () => {", + " // height weight form has 2 observations (height and weight)", + " pm.expect(response.total).to.eql(2);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENHIM_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENHIM_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:5001/fhir/Observation/?subject=Patient/{{cht_patient_id}}", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "fhir", + "Observation", + "" + ], + "query": [ + { + "key": "subject", + "value": "Patient/{{cht_patient_id}}" + } + ] + } + }, + "response": [] + }, + { + "name": "OpenMRS FHIR Sync Encounter", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENHIM_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENHIM_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:5001/mediator/openmrs/sync", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "mediator", + "openmrs", + "sync" + ] + } + }, + "response": [] + }, + { + "name": "GET OpenMRS Encounter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"OpenMRS encounter creation is successful\", () => {", + " pm.expect(response.total).to.eql(1);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENMRS_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENMRS_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:8090/openmrs/ws/fhir2/R4/Encounter", + "host": [ + "localhost" + ], + "port": "8090", + "path": [ + "openmrs", + "ws", + "fhir2", + "R4", + "Encounter" + ] + } + }, + "response": [] + }, + { + "name": "GET OpenMRS Observations", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "", + "pm.test(\"Status code is 200\", () => {", + " pm.expect(pm.response.code).to.eql(200);", + "});", + "", + "pm.test(\"OpenMRS Observation creation is successful\", () => {", + " // height weight form has 2 observations (height and weight)", + " pm.expect(response.total).to.eql(2);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{OPENMRS_PASSWORD}}", + "type": "string" + }, + { + "key": "username", + "value": "{{OPENMRS_USER}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "localhost:8090/openmrs/ws/fhir2/R4/Observation?subject={{openmrs_patient_id}}", + "host": [ + "localhost" + ], + "port": "8090", + "path": [ + "openmrs", + "ws", + "fhir2", + "R4", + "Observation" + ], + "query": [ + { + "key": "subject", + "value": "{{openmrs_patient_id}}" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "patient", + "value": "" + }, + { + "key": "place", + "value": "" + }, + { + "key": "cht_patient_id", + "value": "" + }, + { + "key": "fhir_patient_id", + "value": "" + }, + { + "key": "openmrs_patient_id", + "value": "" + }, + { + "value": "" + } + ] +} \ No newline at end of file diff --git a/docs/local-test/dev.json b/docs/local-test/dev.json index 86853620..02b89492 100644 --- a/docs/local-test/dev.json +++ b/docs/local-test/dev.json @@ -1,51 +1,63 @@ { - "id": "2e553c09-22c1-4ca2-a1b2-1ecd6d711bf4", - "name": "dev", - "values": [ - { - "key": "OPENHIM_USER", - "value": "interop-client", - "type": "default", - "enabled": true - }, - { - "key": "OPENHIM_PASSWORD", - "value": "interop-password", - "type": "default", - "enabled": true - }, - { - "key": "CHT_ADMIN_USER", - "value": "medic", - "type": "default", - "enabled": true - }, - { - "key": "CHT_ADMIN_PASSWORD", - "value": "password", - "type": "default", - "enabled": true - }, - { - "key": "CALLBACK_URL", - "value": "https://interop.free.beeceptor.com/callback", - "type": "default", - "enabled": true - }, - { - "key": "ORGANIZATION_IDENTIFIER", - "value": "test-org", - "type": "default", - "enabled": true - }, - { - "key": "ENDPOINT_IDENTIFIER", - "value": "test-endpoint", - "type": "default", - "enabled": true - } - ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2023-04-25T11:48:41.589Z", - "_postman_exported_using": "Postman/10.13.4" + "id": "6ac40f9a-94ed-44a4-abf3-cf1078dc7768", + "name": "dev", + "values": [ + { + "key": "OPENHIM_USER", + "value": "interop-client", + "type": "default", + "enabled": true + }, + { + "key": "OPENHIM_PASSWORD", + "value": "interop-password", + "type": "default", + "enabled": true + }, + { + "key": "CHT_ADMIN_USER", + "value": "medic", + "type": "default", + "enabled": true + }, + { + "key": "CHT_ADMIN_PASSWORD", + "value": "password", + "type": "default", + "enabled": true + }, + { + "key": "CALLBACK_URL", + "value": "https://interop.free.beeceptor.com/callback", + "type": "default", + "enabled": true + }, + { + "key": "ORGANIZATION_IDENTIFIER", + "value": "test-org", + "type": "default", + "enabled": true + }, + { + "key": "ENDPOINT_IDENTIFIER", + "value": "test-endpoint", + "type": "default", + "enabled": true + }, + { + "key": "OPENMRS_USER", + "value": "admin", + "type": "default", + "enabled": true + }, + { + "key": "OPENMRS_PASSWORD", + "value": "Admin123", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2025-01-09T09:27:08.965Z", + "_postman_exported_using": "Postman/11.23.3" } diff --git a/docs/sequence-diagram/cht-form-submission.png b/docs/sequence-diagram/cht-form-submission.png new file mode 100644 index 00000000..8faf098a Binary files /dev/null and b/docs/sequence-diagram/cht-form-submission.png differ diff --git a/docs/sequence-diagram/cht-form-submission.txt b/docs/sequence-diagram/cht-form-submission.txt new file mode 100644 index 00000000..71c35a7e --- /dev/null +++ b/docs/sequence-diagram/cht-form-submission.txt @@ -0,0 +1,31 @@ +title CHT Form Submission + +participant CHT + +participantgroup +participant OpenHIM +participant Mediator +participant FHIR Server +end + +participant Requesting System +autoactivation on +box over CHT: Form submission in CHT +box over CHT: Outbound push\nrecognizes a\ntracked form was submitted +CHT->OpenHIM: Outbound push POSTS Data Record\nDocument to OpenHim +OpenHIM->Mediator: +box over Mediator: Mediator creates Encounter FHIR +box over Mediator: Mediator creates Observation FHIR Resources\nfrom the fields in the data record document +box over Mediator: Mediator creates Bundle\nfrom Encounter and Observations +Mediator->FHIR Server: POST Encounter Bundle +box over FHIR Server: FHIR Server saves\nthe resources in the Bundle +FHIR Server-->Mediator: FHIR Bundle Response +Mediator-->OpenHIM: 200 Response (no body) +OpenHIM-->CHT: 200 Response (no body) +destroysilent CHT +box over Requesting System: Any FHIR Compliant Server\nthat has been set up as a client\nin OpenHIM can now request\nEncounter or Observation records from CHT +Requesting System->OpenHIM: GET FHIR Encounter +OpenHIM->FHIR Server: GET FHIR Encounter +FHIR Server-->OpenHIM: FHIR Searchset response +OpenHIM-->Requesting System: FHIR Searchset Response +autoactivation off diff --git a/docs/sequence-diagram/cht-incoming-forms.png b/docs/sequence-diagram/cht-incoming-forms.png new file mode 100644 index 00000000..94df2d87 Binary files /dev/null and b/docs/sequence-diagram/cht-incoming-forms.png differ diff --git a/docs/sequence-diagram/cht-incoming-forms.txt b/docs/sequence-diagram/cht-incoming-forms.txt new file mode 100644 index 00000000..22242f2a --- /dev/null +++ b/docs/sequence-diagram/cht-incoming-forms.txt @@ -0,0 +1,32 @@ +title CHT Incoming Forms + +participant Requesting System + +participantgroup +participant OpenHIM +participant Mediator +participant FHIR Server +end + +participant CHT + +autoactivation on +box over Requesting System: A FHIR Encounter\nwith Observations is created +activate Mediator +Mediator->Requesting System: Mediator GETs FHIR Encounters +Requesting System-->Mediator: FHIR Searchset Response +Mediator->Requesting System: Mediator GETs FHIR Observations +Requesting System-->Mediator: FHIR Searchset Response +Mediator->FHIR Server: Mediator GETs FHIR Encounters +FHIR Server-->Mediator: FHIR Searchset Response +Mediator->FHIR Server: Mediator GETs FHIR Observations +FHIR Server-->Mediator: FHIR Searchset Response +box over Mediator: Mediator compares Resources from\nFHIR Server and Requesting System +box over Mediator: Encounters found in Requesting System but\nnot in Requesting System are sent to CHT +box over Mediator: Mediator converts FHIR Observations\n for each Encounter into form fields +Mediator->CHT: Mediator POSTS to the records API +box over CHT: CHT saves a data_record\nwith the data from the patient\ncreation form +CHT-->Mediator: CHT Responds with source record id. +deactivate Mediator +autoactivation off + diff --git a/docs/sequence-diagram/cht-incoming-patients.png b/docs/sequence-diagram/cht-incoming-patients.png new file mode 100644 index 00000000..6df50795 Binary files /dev/null and b/docs/sequence-diagram/cht-incoming-patients.png differ diff --git a/docs/sequence-diagram/cht-incoming-patients.txt b/docs/sequence-diagram/cht-incoming-patients.txt new file mode 100644 index 00000000..9698cfa8 --- /dev/null +++ b/docs/sequence-diagram/cht-incoming-patients.txt @@ -0,0 +1,38 @@ +title CHT Incoming Patients + +participant Requesting System + +participantgroup +participant OpenHIM +participant Mediator +participant FHIR Server +end + +participant CHT + +autoactivation on +box over Requesting System: A FHIR Patient is created +Mediator->Requesting System: Mediator GETs FHIR Patients +activate Mediator +Requesting System-->Mediator: FHIR Searchset Response +Mediator->FHIR Server: Mediator GETs FHIR Patients +FHIR Server-->Mediator: FHIR Searchset Response +box over Mediator: Mediator compares Resources from\nFHIR Server and Requesting System +box over Mediator: Patients found in FHIR Serverbut\nnot in Requesting System are sent to Requesting System +Mediator->CHT: Mediator POSTS to the records API +box over CHT: CHT saves a data_record\nwith the data from the patient\ncreation form +CHT-->Mediator: CHT Responds with source record id. +deactivate Mediator +box over CHT: CHT creates a patient document +box over CHT: CHT adds parent using place_id +box over CHT: CHT adds patient_id and other fields with transitions +CHT->OpenHIM: Outbound push recognizes new patient and sends +OpenHIM->Mediator: +Mediator->FHIR Server: GET FHIR Patient\nusing external_id +FHIR Server-->Mediator: FHIR Patient +box over Mediator: Mediator adds CHT and Medic ID +Mediator->FHIR Server: PUT FHIR Patient\nwith updated ids +FHIR Server-->Mediator: +Mediator-->OpenHIM: +OpenHIM-->CHT: +autoactivation off diff --git a/docs/sequence-diagram/cht-outgoing-patients.png b/docs/sequence-diagram/cht-outgoing-patients.png new file mode 100644 index 00000000..7bbd521a Binary files /dev/null and b/docs/sequence-diagram/cht-outgoing-patients.png differ diff --git a/docs/sequence-diagram/cht-outgoing-patients.txt b/docs/sequence-diagram/cht-outgoing-patients.txt new file mode 100644 index 00000000..b7e0dc6b --- /dev/null +++ b/docs/sequence-diagram/cht-outgoing-patients.txt @@ -0,0 +1,25 @@ +title CHT Outgoing Patients + +participant Requesting System + +participantgroup +participant OpenHIM +participant Mediator +participant FHIR Server +end + +autoactivation on +box over Requesting System: A FHIR Patient is created +Mediator->Requesting System: Mediator GETs FHIR Patients +activate Mediator +Requesting System-->Mediator: FHIR Searchset Response +Mediator->FHIR Server: Mediator GETs FHIR Patients +FHIR Server-->Mediator: FHIR Searchset Response +box over Mediator: Mediator compares Resources from\nFHIR Server and Requesting System +box over Mediator: Patients found in FHIR Serverbut\nnot in Requesting System are sent to Requesting System +Mediator->Requesting System: Mediator POSTS new Patients +Requesting System-->Mediator: FHIR Patient Response\n(w/ Requesting System ids) +Mediator->FHIR Server: Mediator PUTS Patient\nw/ updated ids from requesting System +FHIR Server-->Mediator: +deactivate Mediator +autoactivation off diff --git a/docs/sequence-diagram/cht-patient-creation.png b/docs/sequence-diagram/cht-patient-creation.png new file mode 100644 index 00000000..1436a37e Binary files /dev/null and b/docs/sequence-diagram/cht-patient-creation.png differ diff --git a/docs/sequence-diagram/cht-patient-creation.txt b/docs/sequence-diagram/cht-patient-creation.txt new file mode 100644 index 00000000..bca1021d --- /dev/null +++ b/docs/sequence-diagram/cht-patient-creation.txt @@ -0,0 +1,29 @@ +title CHT Patient Creation + +participant CHT + +participantgroup +participant OpenHIM +participant Mediator +participant FHIR Server +end + +participant Requesting System +autoactivation on +box over CHT: Patient is created in CHT +box over CHT: Outbound push\nrecognizes changes +CHT->OpenHIM: Outbound push POSTS Patient\nDocument to OpenHim +OpenHIM->Mediator: +box over Mediator: Mediator converts Patient\nDocument to FHIR Patient +Mediator->FHIR Server: POST Patient FHIR +box over FHIR Server: FHIR Server saves\nthe Patient FHIR +FHIR Server-->Mediator: FHIR Patient Response +Mediator-->OpenHIM: 200 Response (no body) +OpenHIM-->CHT: 200 Response (no body) +destroysilent CHT +box over Requesting System: Any FHIR Compliant Server\nthat has been set up as a client\nin OpenHIM can now request\nPatient records from CHT +Requesting System->OpenHIM: GET FHIR Patient +OpenHIM->FHIR Server: GET FHIR Patient +FHIR Server-->OpenHIM: FHIR Searchset response +OpenHIM-->Requesting System: FHIR Searchset Response +autoactivation off diff --git a/mediator/config/index.ts b/mediator/config/index.ts index 7ca72253..a3c6d1ea 100644 --- a/mediator/config/index.ts +++ b/mediator/config/index.ts @@ -2,6 +2,7 @@ import * as dotenv from 'dotenv'; dotenv.config(); export const PORT = process.env.PORT || 6000; +const REQUEST_TIMEOUT = Number(getEnvironmentVariable('REQUEST_TIMEOUT', '5000')); export const OPENHIM = { username: getEnvironmentVariable('OPENHIM_USERNAME', 'interop@openhim.org'), @@ -11,17 +12,30 @@ export const OPENHIM = { }; export const FHIR = { - url: getEnvironmentVariable('FHIR_URL', 'http://openhim-core:5001/fhir'), + url: getEnvironmentVariable('FHIR_URL', 'https://openhim-core:5001/fhir'), username: getEnvironmentVariable('FHIR_USERNAME', 'interop-client'), password: getEnvironmentVariable('FHIR_PASSWORD', 'interop-password'), + timeout: REQUEST_TIMEOUT }; export const CHT = { url: getEnvironmentVariable('CHT_URL', 'https://nginx'), username: getEnvironmentVariable('CHT_USERNAME', 'admin'), password: getEnvironmentVariable('CHT_PASSWORD', 'password'), + timeout: REQUEST_TIMEOUT }; +export const OPENMRS = { + url: getEnvironmentVariable('OPENMRS_CHANNEL_URL', 'https://openhim-core:5001/openmrs'), + username: getEnvironmentVariable('OPENMRS_CHANNEL_USERNAME', 'interop-client'), + password: getEnvironmentVariable('OPENMRS_CHANNEL_PASSWORD', 'interop-password'), + timeout: REQUEST_TIMEOUT +}; + +// how often in seconds the sync should run. hardcoded to 1 minute +export const SYNC_INTERVAL = '60'; +// how far back should the sync look for new resources. Defaults to one hour +export const SYNC_PERIOD = getEnvironmentVariable('SYNC_PERIOD', '3600'); function getEnvironmentVariable(env: string, def: string) { if (process.env.NODE_ENV === 'test') { diff --git a/mediator/config/mediator.ts b/mediator/config/mediator.ts index 0cfc3793..edea2c24 100644 --- a/mediator/config/mediator.ts +++ b/mediator/config/mediator.ts @@ -1,8 +1,8 @@ export const mediatorConfig = { - urn: 'urn:mediator:ltfu-mediator', + urn: 'urn:mediator:cht-mediator', version: '1.0.0', - name: 'Loss to Follow Up Mediator', - description: 'A loss to follow up mediator for mediator for CHIS.', + name: 'CHT Mediator', + description: 'The default mediator for CHT applications', defaultChannelConfig: [ { name: 'Mediator', diff --git a/mediator/config/openmrs_mediator.ts b/mediator/config/openmrs_mediator.ts new file mode 100644 index 00000000..829878b3 --- /dev/null +++ b/mediator/config/openmrs_mediator.ts @@ -0,0 +1,35 @@ +export const openMRSMediatorConfig = { + urn: 'urn:mediator:openmrs-mediator', + version: '1.0.0', + name: 'OpenMRS Mediator', + description: 'A mediator to sync CHT data with OpenMRS', + defaultChannelConfig: [ + { + name: 'OpenMRS Sync', + urlPattern: '^/trigger$', + routes: [ + { + name: 'OpenMRS polling Mediator', + host: 'mediator', + path: '/openmrs/sync', + port: 6000, + primary: true, + type: 'http', + }, + ], + allow: ['interop'], + type: 'polling', + pollingSchedule: '1 minute' + }, + ], + endpoints: [ + { + name: 'OpenMRS Mediator', + host: 'mediator', + path: '/openmrs/sync', + port: '6000', + primary: true, + type: 'http', + }, + ], +}; diff --git a/mediator/env.template b/mediator/env.template index 1bd413bc..bb7e66c7 100644 --- a/mediator/env.template +++ b/mediator/env.template @@ -1,10 +1,17 @@ OPENHIM_USERNAME = "interop@openhim.org" OPENHIM_PASSWORD = "password" OPENHIM_API_URL = "https://openhim-core:8080" + PORT = 6000 + FHIR_URL = http://openhim-core:5001/fhir FHIR_USERNAME = interop-client FHIR_PASSWORD = interop-password + +OPENMRS_CHANNEL_URL = http://openhim-core:5001/openmrs +OPENMRS_CHANNEL_USERNAME = interop-client +OPENMRS_CHANNEL_PASSWORD = interop-password + CHT_URL = http://nginx CHT_USERNAME = medic CHT_PASSWORD = password diff --git a/mediator/index.ts b/mediator/index.ts index 767af50a..eb5a3f28 100644 --- a/mediator/index.ts +++ b/mediator/index.ts @@ -1,14 +1,17 @@ import express, {Request, Response} from 'express'; import { mediatorConfig } from './config/mediator'; +import { openMRSMediatorConfig } from './config/openmrs_mediator'; import { logger } from './logger'; import bodyParser from 'body-parser'; -import {PORT, OPENHIM} from './config'; +import {PORT, OPENHIM, OPENMRS} from './config'; import patientRoutes from './src/routes/patient'; import serviceRequestRoutes from './src/routes/service-request'; import encounterRoutes from './src/routes/encounter'; import organizationRoutes from './src/routes/organization'; import endpointRoutes from './src/routes/endpoint'; -import { registerMediatorCallback } from './src/utils/openhim'; +import chtRoutes from './src/routes/cht'; +import openMRSRoutes from './src/routes/openmrs'; +import { registerMediatorCallback, registerOpenMRSMediatorCallback } from './src/utils/openhim'; import os from 'os'; const {registerMediator} = require('openhim-mediator-utils'); @@ -18,23 +21,37 @@ const app = express(); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({extended: true})); -app.get('*', (_: Request, res: Response) => { +app.get('/', (_: Request, res: Response) => { const osUptime = os.uptime(); const processUptime = process.uptime(); res.send({status: 'success', osuptime: osUptime, processuptime: processUptime}); }); +// routes for valid fhir resources app.use('/patient', patientRoutes); app.use('/service-request', serviceRequestRoutes); app.use('/encounter', encounterRoutes); app.use('/organization', organizationRoutes); app.use('/endpoint', endpointRoutes); +// routes for CHT docs +app.use('/cht', chtRoutes); + +// routes for OpenMRS +app.use('/openmrs', openMRSRoutes); + if (process.env.NODE_ENV !== 'test') { - app.listen(PORT, () => logger.info(`Server listening on port ${PORT}`)); + app.listen(PORT, () => { + logger.info(`Server listening on port ${PORT}`); + }); // TODO => inject the 'port' and 'http scheme' into 'mediatorConfig' registerMediator(OPENHIM, mediatorConfig, registerMediatorCallback); + + // if OPENMRS is specified, register its mediator + if (OPENMRS.url) { + registerMediator(OPENHIM, openMRSMediatorConfig, registerOpenMRSMediatorCallback); + } } export default app; diff --git a/mediator/src/controllers/cht.ts b/mediator/src/controllers/cht.ts new file mode 100644 index 00000000..ccc93a1b --- /dev/null +++ b/mediator/src/controllers/cht.ts @@ -0,0 +1,97 @@ +import { + addId, + createFhirResource, + getFhirResourceByIdentifier, + getFHIRPatientResource, + replaceReference, + updateFhirResource +} from '../utils/fhir'; +import { chtEventEmitter, CHT_EVENTS } from '../utils/cht'; +import { + buildFhirObservationFromCht, + buildFhirEncounterFromCht, + buildFhirPatientFromCht, + chtPatientIdentifierType, + chtDocumentIdentifierType +} from '../mappers/cht'; +import { getPatientUUIDFromSourceId } from '../utils/cht'; + +export async function createPatient(chtPatientDoc: any) { + // hack for sms forms: if source_id but not _id, + // first get patient id from source + if (chtPatientDoc.doc.source_id){ + chtPatientDoc.doc._id = await getPatientUUIDFromSourceId(chtPatientDoc.source_id); + } + + //check if patient already exists + const patient = await getFhirResourceByIdentifier(chtPatientDoc.doc.patient_id, 'Patient'); + if (patient?.data?.total > 0){ + return { status: 200, data: { message: `Patient with the same patient_id already exists`} }; + } + + const fhirPatient = buildFhirPatientFromCht(chtPatientDoc.doc); + const result = await updateFhirResource({ ...fhirPatient, resourceType: 'Patient' }); + if (result.status === 200 || result.status === 201) { + chtEventEmitter.emit(CHT_EVENTS.PATIENT_CREATED, fhirPatient); + } + return result; +} + +export async function updatePatientIds(chtFormDoc: any) { + // first, get the existing patient from fhir server + const response = await getFHIRPatientResource(chtFormDoc.doc.external_id); + + if (response.status != 200) { + return { status: 500, data: { message: `FHIR responded with ${response.status}`} }; + } else if (response.data.total == 0){ + // in case the patient is not found, return 200 to prevent retries + return { status: 200, data: { message: `Patient not found`} }; + } + + const fhirPatient = response.data.entry[0].resource; + addId(fhirPatient, chtPatientIdentifierType, chtFormDoc.doc.patient_id); + + // now, we need to get the actual patient doc from cht... + const patient_uuid = await getPatientUUIDFromSourceId(chtFormDoc.doc._id); + if (patient_uuid){ + addId(fhirPatient, chtDocumentIdentifierType, patient_uuid); + return updateFhirResource({ ...fhirPatient, resourceType: 'Patient' }); + } else { + // in case the patient is not found, return 200 to prevent retries + return { status: 200, data: { message: `Patient not found`} }; + } +} + +export async function createEncounter(chtReport: any) { + const fhirEncounter = buildFhirEncounterFromCht(chtReport); + const references: fhir4.Resource[] = []; + + const patientResponse = await getFHIRPatientResource(chtReport.patient_uuid); + if (patientResponse.status != 200){ + // any error, just return it to caller + return patientResponse; + } else if (patientResponse.data.total == 0) { + // in case the patient is not found, return 200 to prevent retries + return { status: 200, data: { message: `Patient not found`} }; + } + + const patient = patientResponse.data.entry[0].resource as fhir4.Patient; + references.push(patient); + replaceReference(fhirEncounter, 'subject', patient); + const response = await updateFhirResource(fhirEncounter); + + if (response.status != 200 && response.status != 201){ + // in case of an error from fhir server, return it to caller + return response; + } + + for (const entry of chtReport.observations) { + const observation = buildFhirObservationFromCht(chtReport.patient_uuid, fhirEncounter, entry); + await createFhirResource(observation); + references.push(observation); + } + + const result = { status: 200, data: {} }; + chtEventEmitter.emit(CHT_EVENTS.ENCOUNTER_CREATED, { encounter: fhirEncounter, references: references }); + return result; +} diff --git a/mediator/src/controllers/openmrs.ts b/mediator/src/controllers/openmrs.ts new file mode 100644 index 00000000..e12a2898 --- /dev/null +++ b/mediator/src/controllers/openmrs.ts @@ -0,0 +1,39 @@ +import { logger } from '../../logger'; +import { syncPatients, syncEncounters } from '../utils/openmrs_sync'; +import { addListeners, removeListeners } from '../utils/openmrs-listener'; +import { SYNC_PERIOD } from '../../config'; + +export async function sync() { + try { + let now = Date.now(); + let syncPeriod = parseInt(SYNC_PERIOD, 10) * 1000; // Convert seconds to milliseconds + let startTime = new Date(now - syncPeriod); + + await syncPatients(startTime); + await syncEncounters(startTime); + return { status: 200, data: { message: `OpenMRS sync completed successfully`} }; + } catch(error: any) { + logger.error(error); + return { status: 500, data: { message: `Error during OpenMRS Sync`} }; + } +} + +export async function startListeners() { + try { + addListeners(); + return { status: 200, data: { message: 'OpenMRS listeners started successfully' } }; + } catch (error: any) { + logger.error(error); + return { status: 500, data: { message: 'Error starting OpenMRS listeners' } }; + } +} + +export async function stopListeners() { + try { + removeListeners(); + return { status: 200, data: { message: 'OpenMRS listeners stopped successfully' } }; + } catch (error: any) { + logger.error(error); + return { status: 500, data: { message: 'Error stopping OpenMRS listeners' } }; + } +} diff --git a/mediator/src/controllers/service-request.ts b/mediator/src/controllers/service-request.ts index 1437b7d1..243f762a 100644 --- a/mediator/src/controllers/service-request.ts +++ b/mediator/src/controllers/service-request.ts @@ -1,5 +1,5 @@ import { logger } from '../../logger'; -import { createChtRecord } from '../utils/cht'; +import { createChtFollowUpRecord } from '../utils/cht'; import { getFHIRPatientResource, getFHIROrgEndpointResource, @@ -21,7 +21,7 @@ export async function createServiceRequest(request: fhir4.ServiceRequest) { const url = endpointRes.data.address; const subscriptionRes = await createFHIRSubscriptionResource(patientId, url); - const recordRes = await createChtRecord(patientId); + const recordRes = await createChtFollowUpRecord(patientId); if (recordRes.data.success !== true) { await deleteFhirSubscription(subscriptionRes.data.id); diff --git a/mediator/src/controllers/tests/cht.spec.ts b/mediator/src/controllers/tests/cht.spec.ts new file mode 100644 index 00000000..56336f0b --- /dev/null +++ b/mediator/src/controllers/tests/cht.spec.ts @@ -0,0 +1,187 @@ +import { + createPatient, + updatePatientIds, + createEncounter +} from '../cht' +import { + ChtPatientFactory, + ChtSMSPatientFactory, + ChtPatientIdsFactory, + ChtPregnancyForm +} from '../../middlewares/schemas/tests/cht-request-factories'; +import { + PatientFactory, + EncounterFactory, + ObservationFactory +} from '../../middlewares/schemas/tests/fhir-resource-factories'; +import { + chtDocumentIdentifierType, + chtPatientIdentifierType +} from '../../mappers/cht'; + +import * as fhir from '../../utils/fhir'; +import * as cht from '../../utils/cht'; + +import axios from 'axios'; +import { randomUUID } from 'crypto'; + +jest.mock('axios'); + +describe('CHT outgoing document controllers', () => { + beforeEach(async () => { + // All of these tests call updateFhirResource, either to create the + // resource directly, or to update its id + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + }); + + describe('createPatient', () => { + it('creates a FHIR Patient from CHT patient doc', async () => { + const data = ChtPatientFactory.build(); + jest.spyOn(fhir, 'getFhirResourceByIdentifier').mockResolvedValue({ + data: { total: 0 }, + status: 200, + }); + + const res = await createPatient(data); + + expect(res.status).toBe(200); + + // assert that the create resource has the right identifier and type + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Patient', + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: data.doc._id + }) + ]), + }) + ); + }); + + it('creates a FHIR Patient from an SMS form using source id', async () => { + let sourceId = randomUUID(); + jest.spyOn(cht, 'getPatientUUIDFromSourceId').mockResolvedValueOnce(sourceId); + + const data = ChtSMSPatientFactory.build(); + + const res = await createPatient(data); + + expect(res.status).toBe(200); + + // assert that the createid resource has the right identifier and type + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Patient', + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: sourceId + }) + ]), + }) + ); + }); + + it('does not create a patient if one with the same patient_id already exists', async () => { + const existingPatient = PatientFactory.build(); + jest.spyOn(fhir, 'getFhirResourceByIdentifier').mockResolvedValue({ + data: { total: 1, entry: [ { resource: existingPatient } ] }, + status: 200, + }); + + const data = ChtPatientFactory.build(); + + const res = await createPatient(data); + + expect(res.status).toBe(200); + expect(fhir.updateFhirResource).not.toHaveBeenCalled(); + }); + }); + + describe('updatePatientIds', () => { + it('updates patient ids', async () => { + const existingPatient = PatientFactory.build(); + jest.spyOn(fhir, 'getFHIRPatientResource').mockResolvedValue({ + data: { total: 1, entry: [ { resource: existingPatient } ] }, + status: 200, + }); + + let sourceId = randomUUID(); + jest.spyOn(cht, 'getPatientUUIDFromSourceId').mockResolvedValueOnce(sourceId); + + const data = ChtPatientIdsFactory.build(); + + const res = await updatePatientIds(data); + + expect(res.status).toBe(200); + + // assert that the created resource has the right identifier and type + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + id: existingPatient.id, + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: sourceId + }) + ]), + }) + ); + }); + }); + + describe('createEncounter', () => { + it('creates FHIR Encounter from CHT form', async () => { + jest.spyOn(fhir, 'getFHIRPatientResource').mockResolvedValueOnce({ + data: { total: 1, entry: [ { resource: PatientFactory.build() } ] }, + status: 200, + }); + // observations use createFhirResource + jest.spyOn(fhir, 'createFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + + const data = ChtPregnancyForm.build(); + + const res = await createEncounter(data); + + expect(res.status).toBe(200); + + // assert that the encounter was created + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Encounter', + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: data.id + }) + ]), + }) + ); + + // assert that at least one observation was created with the right codes + expect(fhir.createFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Observation', + code: { + coding: expect.arrayContaining([{ + code: data.observations[0].code + }]) + }, + valueCodeableConcept: { + coding: expect.arrayContaining([{ + code: data.observations[0].valueCode + }]) + } + }) + ); + }); + }); +}); diff --git a/mediator/src/controllers/tests/openmrs.spec.ts b/mediator/src/controllers/tests/openmrs.spec.ts new file mode 100644 index 00000000..154df92e --- /dev/null +++ b/mediator/src/controllers/tests/openmrs.spec.ts @@ -0,0 +1,111 @@ +import { sync, startListeners, stopListeners } from '../openmrs'; +import * as openmrsSync from '../../utils/openmrs_sync'; +import * as openmrsListener from '../../utils/openmrs-listener'; +import { logger } from '../../../logger'; +import { SYNC_PERIOD } from '../../../config'; + +jest.mock('../../../logger'); +jest.mock('../../utils/openmrs_sync'); +jest.mock('../../utils/openmrs-listener'); + +describe('OpenMRS Controller', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('sync', () => { + it('syncs patients and encounters successfully', async () => { + const syncPatientsSpy = jest.spyOn(openmrsSync, 'syncPatients').mockResolvedValueOnce(); + const syncEncountersSpy = jest.spyOn(openmrsSync, 'syncEncounters').mockResolvedValueOnce(); + + const result = await sync(); + + // Verify sync period calculation + const expectedStartTime = new Date(Date.now() - parseInt(SYNC_PERIOD, 10) * 1000); + expect(syncPatientsSpy).toHaveBeenCalledWith(expect.any(Date)); + expect(syncEncountersSpy).toHaveBeenCalledWith(expect.any(Date)); + + // Verify the timestamps are within 1 second of expected + const patientCallTime = syncPatientsSpy.mock.calls[0][0] as Date; + const encounterCallTime = syncEncountersSpy.mock.calls[0][0] as Date; + expect(Math.abs(patientCallTime.getTime() - expectedStartTime.getTime())).toBeLessThan(1000); + expect(Math.abs(encounterCallTime.getTime() - expectedStartTime.getTime())).toBeLessThan(1000); + + expect(result).toEqual({ + status: 200, + data: { message: 'OpenMRS sync completed successfully' } + }); + }); + + it('handles sync errors', async () => { + const error = new Error('Sync failed'); + jest.spyOn(openmrsSync, 'syncPatients').mockRejectedValueOnce(error); + + const result = await sync(); + + expect(logger.error).toHaveBeenCalledWith(error); + expect(result).toEqual({ + status: 500, + data: { message: 'Error during OpenMRS Sync' } + }); + }); + }); + + describe('startListeners', () => { + it('starts listeners successfully', async () => { + const addListenersSpy = jest.spyOn(openmrsListener, 'addListeners'); + + const result = await startListeners(); + + expect(addListenersSpy).toHaveBeenCalled(); + expect(result).toEqual({ + status: 200, + data: { message: 'OpenMRS listeners started successfully' } + }); + }); + + it('handles listener start errors', async () => { + const error = new Error('Failed to start listeners'); + jest.spyOn(openmrsListener, 'addListeners').mockImplementationOnce(() => { + throw error; + }); + + const result = await startListeners(); + + expect(logger.error).toHaveBeenCalledWith(error); + expect(result).toEqual({ + status: 500, + data: { message: 'Error starting OpenMRS listeners' } + }); + }); + }); + + describe('stopListeners', () => { + it('stops listeners successfully', async () => { + const removeListenersSpy = jest.spyOn(openmrsListener, 'removeListeners'); + + const result = await stopListeners(); + + expect(removeListenersSpy).toHaveBeenCalled(); + expect(result).toEqual({ + status: 200, + data: { message: 'OpenMRS listeners stopped successfully' } + }); + }); + + it('handles listener stop errors', async () => { + const error = new Error('Failed to stop listeners'); + jest.spyOn(openmrsListener, 'removeListeners').mockImplementationOnce(() => { + throw error; + }); + + const result = await stopListeners(); + + expect(logger.error).toHaveBeenCalledWith(error); + expect(result).toEqual({ + status: 500, + data: { message: 'Error stopping OpenMRS listeners' } + }); + }); + }); +}); diff --git a/mediator/src/controllers/tests/utils.ts b/mediator/src/controllers/tests/utils.ts index 26b923cc..b582c66f 100644 --- a/mediator/src/controllers/tests/utils.ts +++ b/mediator/src/controllers/tests/utils.ts @@ -1,10 +1,11 @@ -import { createChtRecord } from '../../utils/cht'; +import { createChtFollowUpRecord } from '../../utils/cht'; import { getFHIROrgEndpointResource, getFHIRPatientResource, deleteFhirSubscription, createFHIRSubscriptionResource, } from '../../utils/fhir'; +import { queryCht } from '../../utils/cht'; jest.mock('../../utils/fhir'); jest.mock('../../utils/cht'); @@ -21,6 +22,9 @@ export const mockCreateFHIRSubscriptionResource = createFHIRSubscriptionResource as jest.MockedFn< typeof createFHIRSubscriptionResource >; -export const mockCreateChtRecord = createChtRecord as jest.MockedFn< - typeof createChtRecord +export const mockCreateChtRecord = createChtFollowUpRecord as jest.MockedFn< + typeof createChtFollowUpRecord +>; +export const mockQueryCht = queryCht as jest.MockedFn< + typeof queryCht >; diff --git a/mediator/src/mappers/cht.ts b/mediator/src/mappers/cht.ts new file mode 100644 index 00000000..01b61aa0 --- /dev/null +++ b/mediator/src/mappers/cht.ts @@ -0,0 +1,193 @@ +import { getIdType, copyIdToNamedIdentifier } from '../utils/fhir'; + +export const chtDocumentIdentifierType: fhir4.CodeableConcept = { + text: 'CHT Document ID' +} + +export const chtPatientIdentifierType: fhir4.CodeableConcept = { + text: 'CHT Patient ID' +} + +export const chtSource = 'cht'; + +const chwVisitType: fhir4.CodeableConcept = { + text: "Communtiy Health Worker Visit", +} + +const homeHealthEncounterClass: fhir4.CodeableConcept = { + text: 'HH', + coding : [{ + system: "http://terminology.hl7.org/CodeSystem/v3-ActCode", + code: "HH" + }] +} + +export function buildChtPatientFromFhir(fhirPatient: fhir4.Patient): any { + const name = fhirPatient.name?.[0]; + const given = name?.given ? name?.given : ''; + + const tc = fhirPatient.telecom?.[0]; + + const now = new Date().getTime(); + const birthDate = Date.parse(fhirPatient.birthDate || ''); + const age_in_days = Math.floor((now - birthDate) / (1000 * 60 * 60 * 24)); + + const updateObject = { + patient_name: `${given} ${name?.family}`, + phone_number: tc?.value, + sex: fhirPatient.gender, + age_in_days: age_in_days, + external_id: fhirPatient.id + }; + + return updateObject; +} + +export function buildFhirPatientFromCht(chtPatient: any): fhir4.Patient { + const nameParts = chtPatient.name.split(" "); + const familyName = nameParts.pop() || ""; + const givenNames = nameParts; + + const name: fhir4.HumanName = { + family: familyName, + given: givenNames, + }; + + const chtPatientId: fhir4.Identifier = { + type: chtPatientIdentifierType, + value: chtPatient.patient_id, + use: 'official' + }; + + const phone: fhir4.ContactPoint = { + value: chtPatient.phone + }; + + const patient: fhir4.Patient = { + resourceType: 'Patient', + name: [name], + birthDate: chtPatient.date_of_birth, + id: chtPatient._id, + identifier: [chtPatientId], + gender: chtPatient.sex, + telecom: [phone] + }; + + copyIdToNamedIdentifier(patient, patient, chtDocumentIdentifierType); + + return patient; +} + +export function buildFhirEncounterFromCht(chtReport: any): fhir4.Encounter { + const patientRef: fhir4.Reference = { + reference: `Patient/${chtReport.patient_uuid}`, + type: "Patient" + }; + + const encounter: fhir4.Encounter = { + resourceType: 'Encounter', + id: chtReport.id, + status: 'unknown', + type: [chwVisitType], + class: homeHealthEncounterClass, + subject: patientRef, + period: { + start: new Date(chtReport.reported_date).toISOString(), + end: new Date(chtReport.reported_date + 1000*60*10).toISOString() + } + } + + copyIdToNamedIdentifier(encounter, encounter, chtDocumentIdentifierType); + + return encounter +} + +/* + * Copy the value from the form mapping to observation + * value are expected to already have been validated + * for types which are native to JSON (Integer, String, Boolean) just copy them exactly + * copy Time and DateTime as strings (they have been validated already) + * valueQuantity will be an object, but also just copy it directly + */ +function copyObservationValue(observation: any, entry: any) { + const copyValueTypes = [ + 'valueString', + 'valueDateTime', + 'valueTime', + 'valueBoolean', + 'valueInteger', + 'valueQuantity' + ]; + copyValueTypes.forEach((name) => { + if (entry.hasOwnProperty(name)) { + observation[name] = entry[name]; + } + }); + + // valueCode needs a little bit of conversion; we are not requiring cht forms to + // map to codeableConcept, just to give the string + if ('valueCode' in entry){ + observation.valueCodeableConcept = { + coding: [{ + code: entry['valueCode'] + }] + }; + } +} + +export function buildFhirObservationFromCht(patient_id: string, encounter: fhir4.Encounter, entry: any): fhir4.Observation { + const patientRef: fhir4.Reference = { + reference: `Patient/${patient_id}`, + type: "Patient" + }; + + const encounterRef: fhir4.Reference = { + reference: `Encounter/${encounter.id}`, + type: "Encounter" + }; + + const now = new Date().toISOString(); + + const observation: fhir4.Observation = { + resourceType: "Observation", + subject: patientRef, + encounter: encounterRef, + status: "final", + code: { + coding: [{ + code: entry.code, + }], + }, + effectiveDateTime: encounter.period?.start, + issued: now + }; + + copyObservationValue(observation, entry); + + return observation; +} + +export function buildChtRecordFromObservations(patient: fhir4.Patient, observations: fhir4.Observation[]) { + const patientId = getIdType(patient, chtDocumentIdentifierType); + + // TODO: remove reference to openmrs + const record: any = { + _meta: { + form: "openmrs_incoming" + }, + patient_id: patientId + } + + observations.forEach((observation: fhir4.Observation) => { + if ( observation?.code?.coding && observation.code.coding.length > 0){ + const code = observation.code.coding[0].code?.toLowerCase() || ''; + if (observation.valueCodeableConcept) { + record[code] = observation.valueCodeableConcept.text; + } else if (observation.valueDateTime) { + record[code] = observation.valueDateTime.split('T')[0]; + } + } + }); + + return record; +} diff --git a/mediator/src/mappers/openmrs.ts b/mediator/src/mappers/openmrs.ts new file mode 100644 index 00000000..c1cf0424 --- /dev/null +++ b/mediator/src/mappers/openmrs.ts @@ -0,0 +1,97 @@ +import { randomUUID } from 'crypto'; + +interface OpenMRSIdentifier extends fhir4.Identifier { + id: string //uuid +} + +interface OpenMRSHumanName extends fhir4.HumanName { + id: string //uuid +} + +export const openMRSIdentifierType: fhir4.CodeableConcept = { + text: 'OpenMRS Patient UUID' +} + +export const openMRSSource = 'openmrs'; + +export const visitNoteType: fhir4.CodeableConcept = { + text: "Visit Note", + coding: [{ + system: "http://fhir.openmrs.org/code-system/encounter-type", + code: "d7151f82-c1f3-4152-a605-2f9ea7414a79", + display: "Visit Note" + }] +} + +export const visitType: fhir4.CodeableConcept = { + text: "Home Visit", + coding: [{ + system: "http://fhir.openmrs.org/code-system/visit-type", + code: "7b0f5697-27e3-40c4-8bae-f4049abfb4ed", + display: "Home Visit", + }] +} + +/* +Build an OpenMRS Visit w/ Visit Note +From a fhir Encounter +One CHT encounter will become 2 OpenMRS Encounters +*/ +export function buildOpenMRSVisit(patientId: string, fhirEncounter: fhir4.Encounter): fhir4.Encounter[] { + const openMRSVisit = { ...fhirEncounter }; + openMRSVisit.type = [visitType] + + const subjectRef: fhir4.Reference = { + reference: `Patient/${patientId}`, + type: "Patient" + }; + + openMRSVisit.subject = subjectRef; + + const visitRef: fhir4.Reference = { + reference: `Encounter/${openMRSVisit.id}`, + type: "Encounter" + }; + + const openMRSVisitNote: fhir4.Encounter = { + ...openMRSVisit, + id: randomUUID(), + type: [visitNoteType], + partOf: visitRef + } + + return [openMRSVisit, openMRSVisitNote]; +} + +/* +Build an observation that opnemrs will accept from a FHIR Observation +This means swapping refreneces, which may not be the same in both servers +*/ +export function buildOpenMRSObservation(fhirObservation: fhir4.Observation, patientId: string, encounterId: string) : fhir4.Observation { + if (fhirObservation.subject) { // to satisfy type checker, subject is not optional + fhirObservation.subject.reference = `Patient/${patientId}` + } + if (fhirObservation.encounter) { // to satisfy type checker, encounter is not optional + fhirObservation.encounter.reference = `Encounter/${encounterId}` + } + return fhirObservation; +} + +/* +Build a patient that OpenMRS will accept from a FHIR Patient +The only difference is that name and identifiers need uuids +*/ +export function buildOpenMRSPatient(fhirPatient: fhir4.Patient): fhir4.Patient { + function addId(resource: any) { + if ( resource.id ){ + return resource; + } else { + return { ...resource, id: randomUUID() }; + } + } + fhirPatient.name = fhirPatient.name?.map(addId); + fhirPatient.identifier = fhirPatient.identifier?.map(addId); + fhirPatient.telecom = fhirPatient.telecom?.map(addId); + return fhirPatient; +} + diff --git a/mediator/src/middlewares/schemas/cht.ts b/mediator/src/middlewares/schemas/cht.ts new file mode 100644 index 00000000..5bde9cf2 --- /dev/null +++ b/mediator/src/middlewares/schemas/cht.ts @@ -0,0 +1,41 @@ +import joi from 'joi'; + +export const ChtPatientSchema = joi.object({ + doc: joi.object({ + _id: joi.string().uuid().required(), + name: joi.string().required(), + phone: joi.string().required(), + date_of_birth: joi.string().required(), + sex: joi.string().required(), + patient_id: joi.string().required() + }).required() +}); + +export const ChtPatientIdsSchema = joi.object({ + doc: joi.object({ + patient_id: joi.string().required(), + external_id: joi.string().required() + }) +}); + +export const ValueTypeSchema = joi.object({ + code: joi.string().required(), + valueString: joi.string(), + valueCode: joi.string(), + valueBoolean: joi.boolean(), + valueInteger: joi.number().integer(), + valueDateTime: joi.string().isoDate(), + valueTime: joi.string().regex(/^([01]\d|2[0-3]):([0-5]\d)(:[0-5]\d(\.\d{1,3})?)?$/), // matches HH:mm[:ss[.SSS]] + valueQuantity: joi.object({ + value: joi.number().required(), + unit: joi.string().required(), + system: joi.string().uri().optional(), + code: joi.string().optional() + }) +}).or('valueString', 'valueCode', 'valueBoolean', 'valueInteger', 'valueDateTime', 'valueTime', 'valueQuantity'); + +export const ChtEncounterFormSchema = joi.object({ + patient_uuid: joi.string().required(), + reported_date: joi.number().required(), //timestamp + observations: joi.array().items(ValueTypeSchema).optional() +}); diff --git a/mediator/src/middlewares/schemas/encounter.ts b/mediator/src/middlewares/schemas/encounter.ts index d8641068..0665720a 100644 --- a/mediator/src/middlewares/schemas/encounter.ts +++ b/mediator/src/middlewares/schemas/encounter.ts @@ -1,10 +1,15 @@ import joi from 'joi'; export const EncounterSchema = joi.object({ + resourceType: joi.string(), + id: joi.string().uuid(), identifier: joi .array() .items( joi.object({ + type: joi.object({ + text: joi.string() + }), system: joi.string().valid('cht').required(), value: joi.string().uuid().required(), }) @@ -16,4 +21,8 @@ export const EncounterSchema = joi.object({ type: joi.array().length(1).required(), subject: joi.required(), participant: joi.array().length(1).required(), + period: joi.object({ + start: joi.string(), + end: joi.string() + }) }); diff --git a/mediator/src/middlewares/schemas/patient.ts b/mediator/src/middlewares/schemas/patient.ts index 385e76ae..0b05b6cc 100644 --- a/mediator/src/middlewares/schemas/patient.ts +++ b/mediator/src/middlewares/schemas/patient.ts @@ -1,10 +1,15 @@ import joi from 'joi'; export const PatientSchema = joi.object({ + resourceType: joi.string(), + id: joi.string().uuid(), identifier: joi .array() .items( joi.object({ + type: joi.object({ + text: joi.string() + }), system: joi.string().valid('cht').required(), value: joi.string().uuid().required(), }) diff --git a/mediator/src/middlewares/schemas/tests/cht-request-factories.ts b/mediator/src/middlewares/schemas/tests/cht-request-factories.ts new file mode 100644 index 00000000..9efe2b2f --- /dev/null +++ b/mediator/src/middlewares/schemas/tests/cht-request-factories.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'crypto'; +import { Factory } from 'rosie'; + +export const ChtPatientFactory = Factory.define('chtPatient') + .attr('doc', () => ChtPatientDoc.build()) + +export const ChtPatientDoc = Factory.define('chtPatientDoc') + .attr('_id', randomUUID()) + .attr('name', 'John Doe') + .attr('phone', '+9770000000') + .attr('date_of_birth', '2000-01-01') + .attr('sex', 'female') + .attr('patient_id', randomUUID()); + +export const ChtSMSPatientFactory = Factory.define('chtPatient') + .attr('doc', () => ChtSMSPatientDoc.build()) + +export const ChtSMSPatientDoc = Factory.define('chtPatientDoc') + .attr('_id', randomUUID()) + .attr('name', 'John Doe') + .attr('phone', '+9770000000') + .attr('date_of_birth', '2000-01-01') + .attr('sex', 'female') + .attr('source_id', randomUUID()); + +export const ChtPatientIdsFactory = Factory.define('chtPatientIds') + .attr('doc', () => ChtPatientIdsDoc.build()) + +export const ChtPatientIdsDoc = Factory.define('chtPatientIds') + .attr('external_id', randomUUID()) + .attr('patient_uuid', randomUUID()); + +export const ChtPregnancyForm = Factory.define('chtPregnancyDoc') + .attr('patient_uuid', randomUUID()) + .attr('reported_date', Date.now()) + .attr('observations', [ + { + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueCode": "43221561-0600-410e-8932-945665533510" + }, + { + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueCode": "070dca86-c275-4369-b405-868904d78156" + }, + { + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueCode": "117399AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + { + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueCode": "ea6a020e-05cd-4fea-b618-abd7494ac571" + }, + { + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueCode": "0d9e45d6-9288-494e-841c-80f3f9b8e126" + }, + { + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueCode": "73f56d98-207e-4e91-9a41-bc744e933cbd" + }, + { + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueCode": "121629AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + { + "code": "1427AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "valueDateTime": "2023-11-20" + }, + { + "code": "5596AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "valueDateTime": "2024-08-26" + }, + { + "code": "73179cce-a424-43d7-9ad1-dce7861946e8", + "valueString": "String" + }, + { + "code": "53179cce-a424-43d7-9ad1-dce7861946e8", + "valueQuantity": { "value": 160, "unit": "kg" } + }, + { + "code": "37a57368-5f59-42c8-aaab-f2774d21501e", + "valueBoolean": false + }, + { + "code": "47a57368-5f54-42c8-aaab-f2774d21501e", + "valueInteger": 12 + } + ]); diff --git a/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts b/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts index d35f460b..34a6befa 100644 --- a/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts +++ b/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts @@ -1,9 +1,11 @@ import { randomUUID } from 'crypto'; import { Factory } from 'rosie'; import { VALID_CODE, VALID_SYSTEM } from '../endpoint'; +import { chtDocumentIdentifierType } from '../../../mappers/cht'; const identifier = [ { + type: chtDocumentIdentifierType, system: 'cht', value: randomUUID(), }, @@ -14,18 +16,26 @@ export const HumanNameFactory = Factory.define('humanName') .attr('given', ['John']); export const PatientFactory = Factory.define('patient') + .attr('resourceType', 'Patient') + .attr('id', randomUUID()) .attr('identifier', identifier) .attr('name', () => [HumanNameFactory.build()]) .attr('gender', 'male') .attr('birthDate', '2000-01-01'); export const EncounterFactory = Factory.define('encounter') + .attr('resourceType', 'Encounter') + .attr('id', randomUUID()) .attr('identifier', identifier) - .attr('status', 'planned') + .attr('status', 'finished') .attr('class', 'outpatient') .attr('type', [{ text: 'Community health worker visit' }]) .attr('subject', { reference: 'Patient/3' }) - .attr('participant', [{ type: [{ text: 'Community health worker' }] }]); + .attr('participant', [{ type: [{ text: 'Community health worker' }] }]) + .attr('period', { + start: new Date(new Date().getTime() - 60 * 60 * 1000).toISOString(), + end: new Date(new Date().getTime() - 50 * 60 * 1000).toISOString() + }) export const EndpointFactory = Factory.define('endpoint') .attr('connectionType', { system: VALID_SYSTEM, code: VALID_CODE }) @@ -49,3 +59,14 @@ export const ServiceRequestFactory = Factory.define('serviceRequest') .attr('intent', 'order') .attr('subject', SubjectFactory.build()) .attr('requester', RequesterFactory.build()); + +export const ObservationFactory = Factory.define('Observation') + .attr('resourceType', 'Observation') + .attr('id', () => randomUUID()) + .attr('encounter', () => { reference: 'Encounter/' + randomUUID() }) + .attr('code', { + coding: [{ code: 'DANGER_SIGNS' }], + }) + .attr('valueCodeableConcept', { + coding: [{ code: 'HIGH_BLOOD_PRESSURE' }] + }); diff --git a/mediator/src/middlewares/schemas/tests/openmrs-resource-factories.ts b/mediator/src/middlewares/schemas/tests/openmrs-resource-factories.ts new file mode 100644 index 00000000..48907540 --- /dev/null +++ b/mediator/src/middlewares/schemas/tests/openmrs-resource-factories.ts @@ -0,0 +1,32 @@ +import { randomUUID } from 'crypto'; +import { Factory } from 'rosie'; +import { visitNoteType, visitType } from '../../../mappers/openmrs'; + +// creates an openmrs patient with the special address extension +export const OpenMRSPatientFactory = Factory.define('openMRSFhirPatient') + .attr('resourceType', 'Patient') + .attr('id', () => randomUUID()) // Assign a random UUID for the patient + .attr('address', ['addressKey', 'addressValue'], (addressKey, addressValue) => [ + { + extension: [{ + extension: [ + { + url: `http://fhir.openmrs.org/ext/address#${addressKey}`, + valueString: addressValue + } + ] + }] + } + ]); + +// creates an openmrs encounter with visit type +export const OpenMRSVisitFactory = Factory.define('openMRSVisit') + .attr('resourceType', 'Encounter') + .attr('id', () => randomUUID()) // Assign a random UUID for the patient + .attr('type', visitType); + +// creates an openmrs encounter with visit note type +export const OpenMRSVisitNoteFactory = Factory.define('openMRSVisit') + .attr('resourceType', 'Encounter') + .attr('id', () => randomUUID()) // Assign a random UUID for the patient + .attr('type', visitNoteType); diff --git a/mediator/src/routes/cht.ts b/mediator/src/routes/cht.ts new file mode 100644 index 00000000..7a8e0cbb --- /dev/null +++ b/mediator/src/routes/cht.ts @@ -0,0 +1,29 @@ +import { Router } from 'express'; +import { requestHandler } from '../utils/request'; +import { validateBodyAgainst } from '../middlewares'; +import { ChtPatientSchema, ChtPatientIdsSchema, ChtEncounterFormSchema } from '../middlewares/schemas/cht'; +import { createPatient, updatePatientIds, createEncounter } from '../controllers/cht' + +const router = Router(); + +const resourceType = 'Patient'; + +router.post( + '/patient', + validateBodyAgainst(ChtPatientSchema), + requestHandler((req) => createPatient(req.body)) +); + +router.post( + '/patient_ids', + validateBodyAgainst(ChtPatientIdsSchema), + requestHandler((req) => updatePatientIds(req.body)) +); + +router.post( + '/encounter', + validateBodyAgainst(ChtEncounterFormSchema), + requestHandler((req) => createEncounter(req.body)) +); + +export default router; diff --git a/mediator/src/routes/openmrs.ts b/mediator/src/routes/openmrs.ts new file mode 100644 index 00000000..7f3b4d48 --- /dev/null +++ b/mediator/src/routes/openmrs.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { requestHandler } from '../utils/request'; +import { sync, startListeners, stopListeners } from '../controllers/openmrs'; + +const router = Router(); + +router.get( + '/sync', + requestHandler((req) => sync()) +); + +router.post( + '/listeners/start', + requestHandler((req) => startListeners()) +); + +router.post( + '/listeners/stop', + requestHandler((req) => stopListeners()) +); + +export default router; diff --git a/mediator/src/routes/tests/cht.spec.ts b/mediator/src/routes/tests/cht.spec.ts new file mode 100644 index 00000000..2d733e84 --- /dev/null +++ b/mediator/src/routes/tests/cht.spec.ts @@ -0,0 +1,53 @@ +import request from 'supertest'; +import app from '../../..'; +import { ChtPatientFactory, ChtPatientIdsFactory, ChtPregnancyForm } from '../../middlewares/schemas/tests/cht-request-factories'; + +describe('POST /cht/patient', () => { + it('doesn\'t accept incoming request with invalid patient resource', async () => { + const data = ChtPatientFactory.build(); + delete data.doc._id; + + const res = await request(app).post('/cht/patient').send(data); + + expect(res.status).toBe(400); + expect(res.body.valid).toBe(false); + expect(res.body.message).toMatchInlineSnapshot( + `""doc._id" is required"` + ); + }); +}); + +describe('POST /cht/patient_ids', () => { + it('doesn\'t accept incoming request with invalid patient resource', async () => { + const data = ChtPatientIdsFactory.build(); + delete data.doc.patient_id; + + const res = await request(app).post('/cht/patient_ids').send(data); + + expect(res.status).toBe(400); + expect(res.body.valid).toBe(false); + expect(res.body.message).toMatchInlineSnapshot( + `""doc.patient_id" is required"` + ); + }); +}); + +describe('POST /cht/encounter', () => { + it('doesn\'t accept incoming request with invalid form', async () => { + const data = ChtPregnancyForm.build(); + + // push an invalid observation + data.observations.push({ + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueDateTime": "This is not a valid date" + }) + + const res = await request(app).post('/cht/encounter').send(data); + + expect(res.status).toBe(400); + expect(res.body.valid).toBe(false); + expect(res.body.message).toMatchInlineSnapshot( + `""observations[13].valueDateTime" must be in iso format"` + ); + }); +}); diff --git a/mediator/src/routes/tests/openmrs.spec.ts b/mediator/src/routes/tests/openmrs.spec.ts new file mode 100644 index 00000000..ee7e8b89 --- /dev/null +++ b/mediator/src/routes/tests/openmrs.spec.ts @@ -0,0 +1,37 @@ +import request from 'supertest'; +import app from '../../..'; +import * as openmrs_sync from '../../utils/openmrs_sync'; +import axios from 'axios'; +import { logger } from '../../../logger'; + +jest.mock('axios'); +jest.mock('../../../logger'); + +describe('GET /openmrs/sync', () => { + it('calls syncPatients and syncEncouners', async () => { + jest.spyOn(openmrs_sync, 'syncPatients').mockImplementation(async (startTime) => { + }); + + jest.spyOn(openmrs_sync, 'syncEncounters').mockImplementation(async (startTime) => { + }); + + const res = await request(app).get('/openmrs/sync').send(); + + expect(res.status).toBe(200); + + expect(openmrs_sync.syncPatients).toHaveBeenCalled(); + expect(openmrs_sync.syncEncounters).toHaveBeenCalled(); + }); + + it('returns 500 if syncPatients throws an error', async () => { + jest.spyOn(openmrs_sync, 'syncPatients').mockImplementation(async (startTime) => { + throw new Error('Sync Failed'); + }); + + const res = await request(app).get('/openmrs/sync').send(); + + expect(res.status).toBe(500); + + expect(openmrs_sync.syncPatients).toHaveBeenCalled(); + }); +}); diff --git a/mediator/src/utils/cht.ts b/mediator/src/utils/cht.ts index e0c96000..ba5d79b0 100644 --- a/mediator/src/utils/cht.ts +++ b/mediator/src/utils/cht.ts @@ -3,26 +3,194 @@ import { CHT } from '../../config'; import { generateBasicAuthUrl } from './url'; import https from 'https'; import path from 'path'; +import { buildChtPatientFromFhir, buildChtRecordFromObservations } from '../mappers/cht'; +import { logger } from '../../logger'; +import { EventEmitter } from 'events'; -export async function createChtRecord(patientId: string) { +export const CHT_EVENTS = { + PATIENT_CREATED: 'patient:created', + ENCOUNTER_CREATED: 'encounter:created' +} as const; + +export const chtEventEmitter = new EventEmitter(); + +type CouchDBQuery = { + selector: Record; + fields?: string[]; +}; + +function getOptions(){ + const options = { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + timeout: CHT.timeout + }; + return options; +} + +export async function createChtFollowUpRecord(patientId: string) { const record = { _meta: { form: 'interop_follow_up', }, patient_uuid: patientId, }; - const options = { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), - }; const chtApiUrl = generateChtRecordsApiUrl(CHT.url, CHT.username, CHT.password); - return await axios.post(chtApiUrl, record, options); + try { + const res = await axios.post(chtApiUrl, record, getOptions()); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +/* + Get the address field from an OpenMRS Patient + Assuming it is stored at a specific path in the fhir Patient +*/ +function getAddressFromOpenMRSPatient(fhirPatient: fhir4.Patient) { + // first, extract address value; is fchv area available? + const addresses = fhirPatient.address?.[0]?.extension?.[0]?.extension; + let addressKey = "http://fhir.openmrs.org/ext/address#address4" + let addressValue = addresses?.find((ext: any) => ext.url === addressKey)?.valueString; + + if (!addressValue) { + // no fchv area, use next highest address + addressKey = "http://fhir.openmrs.org/ext/address#address5" + addressValue = addresses?.find((ext: any) => ext.url === addressKey)?.valueString; + + } + return addressValue; +} + +/* + * Query CouchDB to get a place_id from a name + * This is a workaround for patients not having an place_id + * in the address field (as described above) + * Because it relies on names matching excatly, and qurying a + * CHT couchdb directly, it is not intended for general use +*/ +async function getPlaceIdFromCouch(addressValue: string) { + const query: CouchDBQuery = { + selector: { + type: "contact", + name: addressValue + }, + fields: ['place_id'] + } + const location = await queryCht(query); + + // edge cases can result in more than one location, get first matching + // if not found by name, no more we can do, give up + if (!location.data?.docs || location.data.docs.length == 0){ + return ''; + } else { + return location.data.docs[0].place_id; + } +} + +/* + * get a CHT place_id from an OpenMRS patient + * assumes that either the patient has an address containing the palce id + * (see above), or the name matches the contact name in CHT + * It is to support a specific workflow and is not intended for general use. +*/ +export async function getLocationFromOpenMRSPatient(fhirPatient: fhir4.Patient) { + // if no address found, return empty string + const addressValue = getAddressFromOpenMRSPatient(fhirPatient); + if (!addressValue) { + return ''; + } + + // does the name have a place id included? + const regex = /\[(\d+)\]/; + const match = addressValue.match(regex); + + // if so, return it and we're done + if (match) { + return match[1]; + } else { + // if not, query by name + return getPlaceIdFromCouch(addressValue); + } +} + +export async function getPatientUUIDFromSourceId(source_id: string) { + const query: CouchDBQuery = { + selector: { + source_id: source_id, + type: "person" + }, + fields: [ "_id" ] + } + + const patient = await queryCht(query); + if ( patient?.data?.docs && patient.data.docs.length > 0 ){ + return patient.data.docs[0]._id; + } else { + return '' + } +} + +export async function createChtPatient(fhirPatient: fhir4.Patient) { + const cht_patient = buildChtPatientFromFhir(fhirPatient); + + cht_patient._meta = { form: "openmrs_patient" } + + const location_id = await getLocationFromOpenMRSPatient(fhirPatient); + cht_patient.location_id = location_id; + + return chtRecordsApi(cht_patient); +} + +export async function chtRecordFromObservations(patient: fhir4.Patient, observations: fhir4.Observation[]) { + const record = buildChtRecordFromObservations(patient, observations); + return chtRecordsApi(record); +} + +export async function chtRecordsApi(doc: any) { + const chtApiUrl = generateChtRecordsApiUrl(CHT.url, CHT.username, CHT.password); + try { + const res = await axios.post(chtApiUrl, doc, getOptions()); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export async function getChtDocumentById(doc_id: string) { + const chtApiUrl = generateChtDBUrl(CHT.url, CHT.username, CHT.password); + try { + const res = await axios.get(path.join(chtApiUrl, doc_id), getOptions()); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export async function queryCht(query: any) { + const chtApiUrl = generateChtDBUrl(CHT.url, CHT.username, CHT.password); + try { + const res = await axios.post(path.join(chtApiUrl, '_find'), query, getOptions()); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } } export const generateChtRecordsApiUrl = (chtUrl: string, username: string, password: string) => { const endpoint = generateBasicAuthUrl(chtUrl, username, password); return path.join(endpoint, '/api/v2/records'); }; + +export const generateChtDBUrl = (chtUrl: string, username: string, password: string) => { + const endpoint = generateBasicAuthUrl(chtUrl, username, password); + return path.join(endpoint, '/medic/'); +}; diff --git a/mediator/src/utils/fhir.ts b/mediator/src/utils/fhir.ts index 82e817bf..68de7409 100644 --- a/mediator/src/utils/fhir.ts +++ b/mediator/src/utils/fhir.ts @@ -10,6 +10,7 @@ const axiosOptions = { username: FHIR.username, password: FHIR.password, }, + timeout: FHIR.timeout }; const fhir = new Fhir(); @@ -101,10 +102,56 @@ export async function getFHIROrgEndpointResource(id: string) { } export async function getFHIRPatientResource(patientId: string) { - return await axios.get( - `${FHIR.url}/Patient/?identifier=${patientId}`, - axiosOptions - ); + try { + const res = await axios.get( + `${FHIR.url}/Patient/?identifier=${patientId}`, + axiosOptions + ); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export function addSourceMeta(resource: fhir4.Resource, source: string) { + resource.meta = resource.meta || {}; + resource.meta['source'] = source; +} + +export function copyIdToNamedIdentifier(fromResource: any, toResource: fhir4.Patient | fhir4.Encounter, fromIdType: fhir4.CodeableConcept){ + const identifier: fhir4.Identifier = { + type: fromIdType, + value: fromResource.id, + use: "secondary" + }; + toResource.identifier = toResource.identifier || []; + const sameIdType = (id: any) => (id.type.text === fromIdType.text) + if (!toResource.identifier?.some(sameIdType)) { + toResource.identifier?.push(identifier); + } + return toResource; +} + +export function getIdType(resource: fhir4.Patient | fhir4.Encounter, idType: fhir4.CodeableConcept): string{ + return resource?.identifier?.find((id: any) => id?.type?.text == idType.text)?.value || ''; +} + +export function addId(resource: fhir4.Patient | fhir4.Encounter, idType: fhir4.CodeableConcept, value: string){ + const identifier: fhir4.Identifier = { + type: idType, + value: value + }; + resource.identifier?.push(identifier); + return resource; +} + +export function replaceReference(resource: any, referenceKey: string, referred: fhir4.Resource) { + const newReference: fhir4.Reference = { + reference: `${referred.resourceType}/${referred.id}`, + type: referred.resourceType + } + resource[referenceKey] = newReference; } export async function deleteFhirSubscription(id?: string) { @@ -113,16 +160,104 @@ export async function deleteFhirSubscription(id?: string) { export async function createFhirResource(doc: fhir4.Resource) { try { - const res = await axios.post(`${FHIR.url}/${doc.resourceType}`, doc, { - auth: { - username: FHIR.username, - password: FHIR.password, - }, - }); - - return { status: res.status, data: res.data }; + const res = await axios.post(`${FHIR.url}/${doc.resourceType}`, doc, axiosOptions); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export async function updateFhirResource(doc: fhir4.Resource) { + try { + const res = await axios.put(`${FHIR.url}/${doc.resourceType}/${doc.id}`, doc, axiosOptions); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export async function getFhirResourcesSince(lastUpdated: Date, resourceType: string) { + return getResourcesSince(FHIR.url, lastUpdated, resourceType); +} + +/* + * get the "next" url from a fhir paginated response and a base url +*/ +function getNextUrl(url: string, pagination: any) { + let nextUrl = ''; + const nextLink = pagination.link?.find((link: any) => link.relation === 'next'); + if (nextLink?.url) { + const qs = nextLink.url.split('?')[1]; + nextUrl = `${url}/?${qs}`; + } + return nextUrl; +} + +/* + * Gets the full url for a resource type, given base url + * For some resource types, it is usefult o get related resources + * This function returns the full url including include clauses + * currently it is only for encounters, to include observations + * and the subject patient +*/ +function getResourceUrl(baseUrl: string, lastUpdated: Date, resourceType: string) { + let url = `${baseUrl}/${resourceType}/?_lastUpdated=gt${lastUpdated.toISOString()}`; + // for encounters, include related resources + if (resourceType === 'Encounter') { + url = url + '&_revinclude=Observation:encounter&_include=Encounter:patient'; + } + return url +} + +/* + * get resources of a given type from url, where lastUpdated is > the given data + * if results are paginated, goes through all pages +*/ +export async function getResourcesSince(url: string, lastUpdated: Date, resourceType: string) { + try { + let results: fhir4.Resource[] = []; + let nextUrl = getResourceUrl(url, lastUpdated, resourceType); + + while (nextUrl) { + const res = await axios.get(nextUrl, axiosOptions); + + if (res.data.entry){ + results = results.concat(res.data.entry.map((entry: any) => entry.resource)); + } + + nextUrl = getNextUrl(url, res.data); + } + return { status: 200, data: results }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export async function getFhirResource(id: string, resourceType: string) { + try { + const res = await axios.get( + `${FHIR.url}/${resourceType}/${id}`, + axiosOptions + ); + return { status: res?.status, data: res?.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export async function getFhirResourceByIdentifier(identifierValue: string, resourceType: string) { + try { + const res = await axios.get( + `${FHIR.url}/${resourceType}/?identifier=${identifierValue}`, + axiosOptions + ); + return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } diff --git a/mediator/src/utils/openhim.ts b/mediator/src/utils/openhim.ts index 83891b71..2a82f0dc 100644 --- a/mediator/src/utils/openhim.ts +++ b/mediator/src/utils/openhim.ts @@ -1,4 +1,14 @@ import { logger } from '../../logger'; +import { addListeners } from './openmrs-listener'; + +export const registerOpenMRSMediatorCallback = (err?: string): void => { + if (err) { + throw new Error(`OpenMRS Mediator Registration Failed: Reason ${err}`); + } + + logger.info('Successfully registered OpenMRS mediator.'); + addListeners(); +}; export const registerMediatorCallback = (err?: string): void => { if (err) { diff --git a/mediator/src/utils/openmrs-listener.ts b/mediator/src/utils/openmrs-listener.ts new file mode 100644 index 00000000..242ea066 --- /dev/null +++ b/mediator/src/utils/openmrs-listener.ts @@ -0,0 +1,63 @@ +import { chtEventEmitter, CHT_EVENTS } from './cht'; +import { sendPatientToOpenMRS, sendEncounterToOpenMRS } from './openmrs_sync'; +import { logger } from '../../logger'; + +// Store active listeners to allow for deregistration +type PatientListener = (patient: fhir4.Patient) => Promise; +type EncounterListener = (data: { encounter: fhir4.Encounter, references: fhir4.Resource[] }) => Promise; +const activeListeners: { [key: string]: PatientListener | EncounterListener } = {}; + +function registerPatientListener() { + if (activeListeners[CHT_EVENTS.PATIENT_CREATED]) { + return; // Already registered + } + const listener = async (patient: fhir4.Patient) => { + try { + await sendPatientToOpenMRS(patient); + } catch (error) { + logger.error(`Error sending patient to OpenMRS: ${error}`); + } + }; + chtEventEmitter.on(CHT_EVENTS.PATIENT_CREATED, listener); + activeListeners[CHT_EVENTS.PATIENT_CREATED] = listener; + logger.info('Patient listener registered'); +} + +function registerEncounterListener() { + if (activeListeners[CHT_EVENTS.ENCOUNTER_CREATED]) { + return; // Already registered + } + const listener = async (data: { + encounter: fhir4.Encounter, + references: fhir4.Resource[] + }) => { + try { + await sendEncounterToOpenMRS(data.encounter, data.references); + } catch (error) { + logger.error(`Error sending encounter to OpenMRS: ${error}`); + } + }; + chtEventEmitter.on(CHT_EVENTS.ENCOUNTER_CREATED, listener); + activeListeners[CHT_EVENTS.ENCOUNTER_CREATED] = listener; + logger.info('Encounter listener registered'); +} + +function deregisterListener(eventName: string) { + if (activeListeners[eventName]) { + chtEventEmitter.off(eventName, activeListeners[eventName]); + delete activeListeners[eventName]; + logger.info(`Deregistered listener for ${eventName}`); + } +} + +export function addListeners() { + registerPatientListener(); + registerEncounterListener(); + logger.info('OpenMRS listeners added successfully'); +} + +export function removeListeners() { + deregisterListener(CHT_EVENTS.PATIENT_CREATED); + deregisterListener(CHT_EVENTS.ENCOUNTER_CREATED); + logger.info('OpenMRS listeners removed successfully'); +} diff --git a/mediator/src/utils/openmrs.ts b/mediator/src/utils/openmrs.ts new file mode 100644 index 00000000..5bc87bc3 --- /dev/null +++ b/mediator/src/utils/openmrs.ts @@ -0,0 +1,40 @@ +import { OPENMRS } from '../../config'; +import axios from 'axios'; +import { logger } from '../../logger'; +import https from 'https'; +import { getResourcesSince } from './fhir'; + +const axiosOptions = { + auth: { + username: OPENMRS.username, + password: OPENMRS.password, + }, + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + timeout: OPENMRS.timeout +}; + +export async function getOpenMRSResourcesSince(lastUpdated: Date, resourceType: string) { + return getResourcesSince(OPENMRS.url, lastUpdated, resourceType); +} + +export async function createOpenMRSResource(doc: fhir4.Resource) { + try { + const res = await axios.post(`${OPENMRS.url}/${doc.resourceType}`, doc, axiosOptions); + return { status: res.status, data: res.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} + +export async function updateOpenMRSResource(doc: fhir4.Resource) { + try { + const res = await axios.post(`${OPENMRS.url}/${doc.resourceType}/${doc.id}`, doc, axiosOptions); + return { status: res.status, data: res.data }; + } catch (error: any) { + logger.error(error); + return { status: error.response?.status, data: error.response?.data }; + } +} diff --git a/mediator/src/utils/openmrs_sync.ts b/mediator/src/utils/openmrs_sync.ts new file mode 100644 index 00000000..e723edaf --- /dev/null +++ b/mediator/src/utils/openmrs_sync.ts @@ -0,0 +1,241 @@ +import { + addId, + addSourceMeta, + copyIdToNamedIdentifier, + createFhirResource, + getFHIRPatientResource, + getFhirResourceByIdentifier, + getFhirResourcesSince, + getIdType, + replaceReference, + updateFhirResource +} from './fhir' +import { getOpenMRSResourcesSince, createOpenMRSResource } from './openmrs' +import { buildOpenMRSPatient, buildOpenMRSVisit, buildOpenMRSObservation, openMRSIdentifierType, openMRSSource } from '../mappers/openmrs' +import { chtDocumentIdentifierType, chtSource } from '../mappers/cht' +import { createChtPatient, chtRecordFromObservations } from './cht' +import { logger } from '../../logger'; + +interface ComparisonResources { + fhirResources: fhir4.Resource[], + openMRSResources: fhir4.Resource[], + references: fhir4.Resource[] +} + +/* + Get resources updates in the last day from both OpenMRS and the FHIR server +*/ +async function getResources(resourceType: string, startTime: Date): Promise { + function onlyType(resource: fhir4.Resource) { + return resource.resourceType === resourceType; + } + let references: fhir4.Resource[] = [] + + const fhirResponse = await getFhirResourcesSince(startTime, resourceType); + if (fhirResponse.status != 200) { + throw new Error(`Error ${fhirResponse.status} when requesting FHIR resources`); + } + const fhirResources = fhirResponse.data.filter(onlyType); + references = references.concat(fhirResponse.data); + + const openMRSResponse = await getOpenMRSResourcesSince(startTime, resourceType); + if (openMRSResponse.status != 200) { + throw new Error(`Error ${openMRSResponse.status} when requesting OpenMRS resources`); + } + const openMRSResources = openMRSResponse.data.filter(onlyType); + references = references.concat(openMRSResponse.data); + + return { fhirResources: fhirResources, openMRSResources: openMRSResources, references: references }; +} + +interface ComparisonResult { + toupdate: fhir4.Resource[], + incoming: fhir4.Resource[], + outgoing: fhir4.Resource[], + references: fhir4.Resource[] +} + +/* + Compares the rsources in OpenMRS and FHIR + the getKey argument is a function that gets an id for each resource + that is expected to be the same value in both OpenMRS and the FHIR Server + + returns lists of resources + that are in OpenMRS but not FHIR (incoming) + that are in FHIR but not OpenMRS (outgoing) + that are in both (toupdate) +*/ +export async function compare( + getKey: (resource: any) => string, + resourceType: string, + startTime: Date, +): Promise { + const comparison = await getResources(resourceType, startTime); + + const results: ComparisonResult = { + toupdate: [], + incoming: [], + outgoing: [], + references: comparison.references + }; + + // get the key for each resource and create a Map + const fhirIds = new Map(comparison.fhirResources.map(resource => [getKey(resource), resource])); + + comparison.openMRSResources.forEach((openMRSResource) => { + const key = getKey(openMRSResource); + if (fhirIds.has(key)) { + results.toupdate.push(openMRSResource); + fhirIds.delete(key); + } else { + results.incoming.push(openMRSResource); + } + }); + + fhirIds.forEach((resource, key) => { + results.outgoing.push(resource); + }); + + logger.info(`Comparing ${resourceType}`); + logger.info(`Incoming: ${results.incoming.map(r => r.id)}`); + logger.info(`Outgoing: ${results.outgoing.map(r => r.id)}`); + return results; +} + +/* + Send a patient from CHT to OpenMRS + And update OpenMRS Id if successful +*/ +export async function sendPatientToOpenMRS(patient: fhir4.Patient) { + logger.info(`Sending Patient ${patient.id} to OpenMRS`); + const openMRSPatient = buildOpenMRSPatient(patient); + addSourceMeta(openMRSPatient, chtSource); + const response = await createOpenMRSResource(openMRSPatient); + // copy openmrs identifier if successful + if (response.status == 200 || response.status == 201) { + copyIdToNamedIdentifier(response.data, patient, openMRSIdentifierType); + logger.info(`Updating Patient ${patient.id} with openMRSId ${response.data.id}`); + await updateFhirResource(patient); + } +} +/* + Sync Patients between OpenMRS and FHIR + compare patient resources + for incoming, creates them in the FHIR server and forwars to CHT + for outgoing, sends them to OpenMRS, receives the ID back, and updates the ID +*/ +export async function syncPatients(startTime: Date){ + const getKey = (fhirPatient: any) => { return getIdType(fhirPatient, openMRSIdentifierType) || fhirPatient.id }; + const results: ComparisonResult = await compare(getKey, 'Patient', startTime); + + const outgoingPromises = results.outgoing.map(async (resource) => { + const patient = resource as fhir4.Patient; + return sendPatientToOpenMRS(patient); + }); + + await Promise.all(outgoingPromises); +} + +/* + Get a patient from a list of resources, by an encounters subject reference +*/ +export function getPatient(encounter: fhir4.Encounter, references: fhir4.Resource[]): fhir4.Patient { + return references.filter((resource) => { + return resource.resourceType === 'Patient' && `Patient/${resource.id}` === encounter.subject?.reference + })[0] as fhir4.Patient; +} + +/* + Get a list of observations from a list of resources + where the observations encounter reference is the encounter +*/ +export function getObservations(encounter: fhir4.Encounter, references: fhir4.Resource[]): fhir4.Observation[] { + return references.filter((resource) => { + if (resource.resourceType === 'Observation') { + const observation = resource as fhir4.Observation; + return observation.encounter?.reference === `Encounter/${encounter.id}` + } else { + return false; + } + }) as fhir4.Observation[]; +} + +/* + Send an encounter from CHT to OpenMRS + Saves both a Visit and VisitNote Encounter + Updates the OpenMRS Id on the CHT encounter to the VisitNote + Sends Observations for the visitNote Encounter +*/ +export async function sendEncounterToOpenMRS( + encounter: fhir4.Encounter, + references: fhir4.Resource[] +) { + if (encounter.meta?.source == openMRSSource) { + logger.error(`Not re-sending encounter from openMRS ${encounter.id}`); + return + } + + logger.info(`Sending Encounter ${encounter.id} to OpenMRS`); + + const patient = getPatient(encounter, references); + const observations = getObservations(encounter, references); + const patientId = getIdType(patient, openMRSIdentifierType); + const openMRSVisit = buildOpenMRSVisit(patientId, encounter); + + const visitResponse = await createOpenMRSResource(openMRSVisit[0]); + if (visitResponse.status != 201) { + logger.error(`Error saving visit to OpenMRS ${encounter.id}: ${visitResponse.status}`); + return + } + + const visitNoteResponse = await createOpenMRSResource(openMRSVisit[1]); + if (visitNoteResponse.status != 201) { + logger.error(`Error saving visit note to OpenMRS ${encounter.id}: ${visitNoteResponse.status}`); + return + } + + const visitNote = visitNoteResponse.data as fhir4.Encounter; + + logger.info(`Updating Encounter ${encounter.id} with openMRSId ${visitNote.id}`); + + // save openmrs id on orignal encounter + copyIdToNamedIdentifier(visitNote, encounter, openMRSIdentifierType); + addSourceMeta(visitNote, chtSource); + + await updateFhirResource(encounter); + + observations.forEach((observation) => { + logger.info(`Sending Observation ${observation.code!.coding![0]!.code} to OpenMRS`); + const openMRSObservation = buildOpenMRSObservation(observation, patientId, visitNote.id || ''); + createOpenMRSResource(openMRSObservation); + }); +} + +/* + Send Observation from OpenMRS to FHIR + Replacing the subject reference +*/ +export async function sendObservationToFhir(observation: fhir4.Observation, patient: fhir4.Patient) { + logger.info(`Sending Observation ${observation.code!.coding![0]!.code} to FHIR`); + replaceReference(observation, 'subject', patient); + createFhirResource(observation); +} + +/* + Sync Encounters and Observations + For incoming encounters, saves them to FHIR Server, then gathers related Observations + And send to CHT the Encounter together with its observations + For outgoing, converts to OpenMRS format and sends to OpenMRS + Updates to Observations and Encounters are not allowed +*/ +export async function syncEncounters(startTime: Date){ + const getEncounterKey = (encounter: any) => { return getIdType(encounter, openMRSIdentifierType) || encounter.id }; + const encounters: ComparisonResult = await compare(getEncounterKey, 'Encounter', startTime); + + const outgoingPromises = encounters.outgoing.map(async (resource) => { + const encounter = resource as fhir4.Encounter; + return sendEncounterToOpenMRS(encounter, encounters.references) + }); + + await Promise.all(outgoingPromises); +} diff --git a/mediator/src/utils/tests/cht.spec.ts b/mediator/src/utils/tests/cht.spec.ts index 7402aaf8..23f4b91a 100644 --- a/mediator/src/utils/tests/cht.spec.ts +++ b/mediator/src/utils/tests/cht.spec.ts @@ -1,19 +1,28 @@ -import { createChtRecord, generateChtRecordsApiUrl } from '../cht'; +import { + createChtFollowUpRecord, + generateChtRecordsApiUrl, + getLocationFromOpenMRSPatient, + getPatientUUIDFromSourceId, + queryCht +} from '../cht'; import axios from 'axios'; +import { logger } from '../../../logger'; +import { OpenMRSPatientFactory } from '../../middlewares/schemas/tests/openmrs-resource-factories'; jest.mock('axios'); +jest.mock('../../../logger'); const mockAxios = axios as jest.Mocked; describe('CHT Utils', () => { - describe('createChtRecord', () => { + describe('createChtFollowUpRecord', () => { it('creates a new cht record', async () => { const patientId = 'PATIENT_ID'; const data = { status: 201, data: {} }; mockAxios.post.mockResolvedValueOnce(data); - const res = await createChtRecord(patientId); + const res = await createChtFollowUpRecord(patientId); expect(res.status).toBe(data.status); expect(res.data).toStrictEqual(data.data); @@ -36,4 +45,144 @@ describe('CHT Utils', () => { expect(res).toContain(`${username}:${password}`); }); }); + + describe('getLocationFromOpenMRSPatient', () => { + it('should return place ID if address contains place ID', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'FCHV Area [12345]' + }); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + expect(result).toBe('12345'); + }); + + it('should return an empty string if no address or place ID is found', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'Unknown Area' + }); + + const data = { status: 200, data: { docs: [] } }; + mockAxios.post.mockResolvedValue(data); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + expect(result).toBe(''); + }); + + it('should return address5 if address4 is not available', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address5', + addressValue: 'Health Center [54321]' + }); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + expect(result).toBe('54321'); + }); + + it('should handle error cases by returning an empty string when query fails', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'Unknown Location' + }); + + mockAxios.post.mockRejectedValue(new Error('Database query failed')); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + expect(result).toBe(''); + }); + + it('should return location by name if no address4 or address5', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'Area1' + }); + + const data = { + status: 200, + data: { docs: [ { place_id: 12345 } ] } }; + mockAxios.post.mockResolvedValue(data); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + expect(result).toBe(12345); + }); + }); + + describe('getPatientUUIDFromSourceId', () => { + it('should return patient UUID if patient is found', async () => { + const sourceId = '12345'; + const mockUUID = 'abcdef-123456'; + + const data = { + status: 200, + data: { docs: [{ _id: mockUUID }] } + }; + mockAxios.post.mockResolvedValue(data); + + const result = await getPatientUUIDFromSourceId(sourceId); + + expect(result).toBe(mockUUID); + }); + + it('should return an empty string if no patient is found', async () => { + const sourceId = 'not_found_id'; + + const data = { + status: 200, + data: { docs: [] } + }; + mockAxios.post.mockResolvedValue(data); + + const result = await getPatientUUIDFromSourceId(sourceId); + + expect(result).toBe(''); + }); + + it('should handle error cases by returning an empty string when query fails', async () => { + const sourceId = 'error_id'; + + mockAxios.post.mockRejectedValue(new Error('Database query failed')); + + const result = await getPatientUUIDFromSourceId(sourceId); + + expect(result).toBe(''); + }); + }); + + describe('queryCHT', () => { + it('should return data when the query is successful', async () => { + const mockQuery = { selector: { type: 'contact' } }; + const mockResponse = { status: 200, data: { docs: [{ place_id: '12345' }] } }; + + mockAxios.post.mockResolvedValue(mockResponse); // Simulate a successful response + + const result = await queryCht(mockQuery); + + expect(mockAxios.post).toHaveBeenCalledWith(expect.stringContaining('_find'), mockQuery, expect.anything()); + expect(result).toEqual(mockResponse); + }); + + it('should log an error and return error.response.data when the query fails', async () => { + const mockQuery = { selector: { type: 'contact' } }; + const mockError = { + response: { status: 500, data: 'Internal Server Error' } + } + + mockAxios.post.mockRejectedValue(mockError); // Simulate an error response + const loggerErrorSpy = jest.spyOn(logger, 'error'); // Spy on the logger's error method + + const result = await queryCht(mockQuery); + + expect(loggerErrorSpy).toHaveBeenCalledWith(mockError); + expect(result).toEqual({ + status: mockError.response.status, + data: mockError.response.data + }); + }); + }); }); diff --git a/mediator/src/utils/tests/fhir.spec.ts b/mediator/src/utils/tests/fhir.spec.ts index 63854743..a619327b 100644 --- a/mediator/src/utils/tests/fhir.spec.ts +++ b/mediator/src/utils/tests/fhir.spec.ts @@ -1,5 +1,8 @@ import { logger } from '../../../logger'; -import { EncounterFactory } from '../../middlewares/schemas/tests/fhir-resource-factories'; +import { + EncounterFactory, + PatientFactory +} from '../../middlewares/schemas/tests/fhir-resource-factories'; import { createFHIRSubscriptionResource, createFhirResource, @@ -7,8 +10,11 @@ import { generateFHIRSubscriptionResource, getFHIROrgEndpointResource, getFHIRPatientResource, + getFhirResourcesSince, + addId } from '../fhir'; import axios from 'axios'; +import { FHIR } from '../../../config'; jest.mock('axios'); jest.mock('../../../logger'); @@ -201,7 +207,9 @@ describe('FHIR Utils', () => { }); it('should return an error if the FHIR server returns an error', async () => { - const data = { status: 400, data: { message: 'Bad request' } }; + const data = { + response: { status: 400, data: { message: 'Bad request' } } + }; mockAxios.post = jest.fn().mockRejectedValue(data); @@ -211,8 +219,128 @@ describe('FHIR Utils', () => { expect(mockAxios.post.mock.calls[0][0]).toContain(resourceType); expect(mockAxios.post.mock.calls[0][1]).toEqual({...encounter, resourceType}); expect(res.status).toEqual(400); - expect(res.data).toEqual(data.data); + expect(res.data).toEqual(data.response.data); expect(logger.error).toBeCalledTimes(1); }); }); + + describe('addIds', () => { + it('should add ids to a fhir patient', () => { + const patient = PatientFactory.build(); + const idType = { coding: [{ code: 'OpenMRS ID' }] }; + const value = '12345'; + + const result = addId(patient, idType, value); + + expect(result.identifier).toBeDefined(); + // patient has one idenditifer already, so afterwards, should be 2 + expect(result.identifier?.length).toBe(2); + // and the one we are checking is the second one + expect(result.identifier?.[1]).toEqual({ + type: idType, + value: value + }); + }); + }); + + describe('getFhirResourcesSince', () => { + it('should fetch FHIR resources successfully', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Patient'; + const mockResponse = { + data: { + entry: [ + { resource: { id: '123', resourceType: 'Patient' } } + ], + link: [] + } + }; + mockAxios.get.mockResolvedValue(mockResponse); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(mockAxios.get).toHaveBeenCalledWith( + `${FHIR.url}/Patient/?_lastUpdated=gt2023-01-01T00:00:00.000Z`, + expect.anything() // axiosOptions + ); + expect(result.status).toBe(200); + expect(result.data).toEqual([{ id: '123', resourceType: 'Patient' }]); + }); + + it('should include related resources for encounters', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Encounter'; + const mockResponse = { + data: { + entry: [ + { resource: { id: 'enc-123', resourceType: 'Encounter' } } + ], + link: [] + } + }; + mockAxios.get.mockResolvedValue(mockResponse); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(mockAxios.get).toHaveBeenCalledWith( + `${FHIR.url}/Encounter/?_lastUpdated=gt2023-01-01T00:00:00.000Z&_revinclude=Observation:encounter&_include=Encounter:patient`, + expect.anything() // axiosOptions + ); + expect(result.status).toBe(200); + expect(result.data).toEqual([{ id: 'enc-123', resourceType: 'Encounter' }]); + }); + + it('should handle pagination', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Patient'; + const mockFirstPageResponse = { + data: { + entry: [ + { resource: { id: '123', resourceType: 'Patient' } } + ], + link: [ + { relation: 'next', url: `${FHIR.url}/Patient/?page=2` } + ] + } + }; + const mockSecondPageResponse = { + data: { + entry: [ + { resource: { id: '124', resourceType: 'Patient' } } + ], + link: [] + } + }; + mockAxios.get + .mockResolvedValueOnce(mockFirstPageResponse) + .mockResolvedValueOnce(mockSecondPageResponse); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(mockAxios.get).toHaveBeenCalledTimes(2); + expect(result.status).toBe(200); + expect(result.data).toEqual([ + { id: '123', resourceType: 'Patient' }, + { id: '124', resourceType: 'Patient' } + ]); + }); + + it('should return an error if the request fails', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Patient'; + const mockError = { + response: { + status: 500, + data: 'Internal Server Error' + } + }; + mockAxios.get.mockRejectedValue(mockError); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(logger.error).toHaveBeenCalledWith(mockError); + expect(result.status).toBe(500); + expect(result.data).toBe('Internal Server Error'); + }); + }); }); diff --git a/mediator/src/utils/tests/openmrs-listener.spec.ts b/mediator/src/utils/tests/openmrs-listener.spec.ts new file mode 100644 index 00000000..43e13c54 --- /dev/null +++ b/mediator/src/utils/tests/openmrs-listener.spec.ts @@ -0,0 +1,78 @@ +import { addListeners, removeListeners } from '../openmrs-listener'; +import { chtEventEmitter, CHT_EVENTS } from '../cht'; +import * as openmrsSync from '../openmrs_sync'; +import { logger } from '../../../logger'; +import { PatientFactory } from '../../middlewares/schemas/tests/fhir-resource-factories'; + +jest.mock('../../../logger'); +jest.mock('../openmrs_sync'); + +describe('OpenMRS Listener', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('addListeners', () => { + it('registers patient and encounter listeners', () => { + const spyOn = jest.spyOn(chtEventEmitter, 'on'); + + addListeners(); + + expect(spyOn).toHaveBeenCalledWith(CHT_EVENTS.PATIENT_CREATED, expect.any(Function)); + expect(spyOn).toHaveBeenCalledWith(CHT_EVENTS.ENCOUNTER_CREATED, expect.any(Function)); + expect(logger.info).toHaveBeenCalledWith('OpenMRS listeners added successfully'); + }); + + it('does not register duplicate listeners', () => { + // First call should register both listeners + addListeners(); + + // Reset the spy after first registration + const spyOn = jest.spyOn(chtEventEmitter, 'on'); + + // Second call should not register new listeners + addListeners(); + + // Expect no new listeners to be registered + expect(spyOn).not.toHaveBeenCalled(); + }); + }); + + describe('removeListeners', () => { + it('deregisters patient and encounter listeners', () => { + const spyOff = jest.spyOn(chtEventEmitter, 'off'); + + addListeners(); + removeListeners(); + + expect(spyOff).toHaveBeenCalledWith(CHT_EVENTS.PATIENT_CREATED, expect.any(Function)); + expect(spyOff).toHaveBeenCalledWith(CHT_EVENTS.ENCOUNTER_CREATED, expect.any(Function)); + expect(logger.info).toHaveBeenCalledWith('OpenMRS listeners removed successfully'); + }); + }); + + describe('Patient Listener', () => { + it('sends patient to OpenMRS when patient created event is emitted', async () => { + const patient = PatientFactory.build(); + const sendPatientSpy = jest.spyOn(openmrsSync, 'sendPatientToOpenMRS'); + + addListeners(); + await chtEventEmitter.emit(CHT_EVENTS.PATIENT_CREATED, patient); + + expect(sendPatientSpy).toHaveBeenCalledWith(patient); + }); + }); + + describe('Encounter Listener', () => { + it('sends encounter to OpenMRS when encounter created event is emitted', async () => { + const encounter = { id: '123', resourceType: 'Encounter' }; + const references = [{ id: '456', resourceType: 'Patient' }]; + const sendEncounterSpy = jest.spyOn(openmrsSync, 'sendEncounterToOpenMRS'); + + addListeners(); + await chtEventEmitter.emit(CHT_EVENTS.ENCOUNTER_CREATED, { encounter, references }); + + expect(sendEncounterSpy).toHaveBeenCalledWith(encounter, references); + }); + }); +}); diff --git a/mediator/src/utils/tests/openmrs.spec.ts b/mediator/src/utils/tests/openmrs.spec.ts new file mode 100644 index 00000000..1a40447c --- /dev/null +++ b/mediator/src/utils/tests/openmrs.spec.ts @@ -0,0 +1,82 @@ +import axios from 'axios'; +import { createOpenMRSResource, updateOpenMRSResource, getOpenMRSResourcesSince } from '../openmrs'; +import { logger } from '../../../logger'; +import { OPENMRS } from '../../../config'; + +jest.mock('axios'); +jest.mock('../../../logger'); + +describe('OpenMRS utility functions', () => { + const mockAxiosGet = axios.get as jest.Mock; + const mockAxiosPost = axios.post as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createOpenMRSResource', () => { + it('should create a new OpenMRS resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockResponse = { status: 201, data: mockResource }; + mockAxiosPost.mockResolvedValue(mockResponse); + + const result = await createOpenMRSResource(mockResource); + + expect(mockAxiosPost).toHaveBeenCalledWith( + `${OPENMRS.url}/Patient`, + mockResource, + expect.anything() // axiosOptions + ); + expect(result).toEqual({ status: 201, data: mockResource }); + }); + + it('should handle errors when creating a resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockError = { + response: { status: 500, data: 'Internal Server Error' } + }; + mockAxiosPost.mockRejectedValue(mockError); + + const result = await createOpenMRSResource(mockResource); + + expect(logger.error).toHaveBeenCalledWith(mockError); + expect(result).toEqual({ + status: mockError.response.status, + data: mockError.response.data + }); + }); + }); + + describe('updateOpenMRSResource', () => { + it('should update an existing OpenMRS resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockResponse = { status: 200, data: mockResource }; + mockAxiosPost.mockResolvedValue(mockResponse); + + const result = await updateOpenMRSResource(mockResource); + + expect(mockAxiosPost).toHaveBeenCalledWith( + `${OPENMRS.url}/Patient/456`, + mockResource, + expect.anything() // axiosOptions + ); + expect(result).toEqual({ status: 200, data: mockResource }); + }); + + it('should handle errors when updating a resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockError = { + response: { status: 500, data: 'Internal Server Error' } + }; + mockAxiosPost.mockRejectedValue(mockError); + + const result = await updateOpenMRSResource(mockResource); + + expect(logger.error).toHaveBeenCalledWith(mockError); + expect(result).toEqual({ + status: mockError.response.status, + data: mockError.response.data + }); + }); + }); +}); diff --git a/mediator/src/utils/tests/openmrs_sync.spec.ts b/mediator/src/utils/tests/openmrs_sync.spec.ts new file mode 100644 index 00000000..0c08f61c --- /dev/null +++ b/mediator/src/utils/tests/openmrs_sync.spec.ts @@ -0,0 +1,233 @@ +import { + compare, + syncPatients, + syncEncounters, + getPatient +} from '../openmrs_sync'; +import * as fhir from '../fhir'; +import * as openmrs from '../openmrs'; +import * as cht from '../cht'; + +import { PatientFactory, EncounterFactory, ObservationFactory } from '../../middlewares/schemas/tests/fhir-resource-factories'; +import { visitType, visitNoteType } from '../../mappers/openmrs'; +import { chtDocumentIdentifierType, chtPatientIdentifierType } from '../../mappers/cht'; +import { getIdType } from '../../utils/fhir'; + +import axios from 'axios'; +import { logger } from '../../../logger'; +jest.mock('axios'); +jest.mock('../../../logger'); + +describe('OpenMRS Sync', () => { + describe('compare', () => { + it('correctly identifies incoming, outgoing, and to-be-updated resources based on the given key', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + + const constants = { + resourceType: 'Patient', + meta: { lastUpdated: lastUpdated } + } + + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [ + { id: 'outgoing', ...constants }, + { id: 'toupdate', ...constants } + ], + status: 200, + }); + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [ + { id: 'incoming', ...constants }, + { id: 'toupdate', ...constants } + ], + status: 200, + }); + + const getKey = (obj: any) => { return obj.id }; + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await compare(getKey, 'Patient', startTime) + + expect(comparison.incoming).toEqual([{id: 'incoming', ...constants }]); + expect(comparison.outgoing).toEqual([{id: 'outgoing', ...constants }]); + expect(comparison.toupdate).toEqual([{id: 'toupdate', ...constants }]); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + }); + + it('loads references for related resources', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + const reference = { + id: 'reference0', + resourceType: 'Patient', + meta: { lastUpdated: lastUpdated } + }; + const resource = { + id: 'resource0', + resourceType: 'Encounter', + meta: { lastUpdated: lastUpdated } + }; + + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [ resource, reference ], + status: 200, + }); + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [ resource ], + status: 200, + }); + + const getKey = (obj: any) => { return obj.id }; + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await compare(getKey, 'Encounter', startTime) + + expect(comparison.references).toContainEqual(reference); + expect(comparison.toupdate).toEqual([resource]); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + }); + }); + + describe('syncPatients', () => { + it('sends outgoing Patients to OpenMRS', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + + const fhirPatient = PatientFactory.build(); + fhirPatient.meta = { lastUpdated: lastUpdated }; + + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [fhirPatient], + status: 200, + }); + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [], + status: 200, + }); + jest.spyOn(openmrs, 'createOpenMRSResource').mockResolvedValueOnce({ + data: fhirPatient, + status: 201 + }); + jest.spyOn(fhir, 'updateFhirResource') + + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await syncPatients(startTime); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith(fhirPatient); + // updating with openmrs id + expect(fhir.updateFhirResource).toHaveBeenCalledWith(fhirPatient); + }); + }); + describe('syncEncounters', () => { + it('sends outgoing Encounters to OpenMRS', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + + const fhirEncounter = EncounterFactory.build(); + fhirEncounter.meta = { lastUpdated: lastUpdated }; + const fhirObservation = ObservationFactory.build(); + fhirObservation.encounter = { reference: 'Encounter/' + fhirEncounter.id } + const chtDocId = { + system: "cht", + type: chtDocumentIdentifierType, + value: getIdType(fhirEncounter, chtDocumentIdentifierType) + } + + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [fhirEncounter, fhirObservation], + status: 200, + }); + + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [], + status: 200, + }); + + jest.spyOn(openmrs, 'createOpenMRSResource').mockResolvedValue({ + data: [], + status: 201, + }); + + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await syncEncounters(startTime); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith( + expect.objectContaining({ + "type": expect.arrayContaining([visitType]), + "identifier": expect.arrayContaining([chtDocId]) + }) + ); + + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith( + expect.objectContaining({ + "type": expect.arrayContaining([visitNoteType]), + }) + ); + + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith(fhirObservation); + }); + it('does not send incoming Encounters to FHIR and CHT if OpenMRS identifier exists', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + + const openMRSPatient = PatientFactory.build(); + openMRSPatient.meta = { lastUpdated: lastUpdated }; + const openMRSEncounter = EncounterFactory.build(); + openMRSEncounter.meta = { lastUpdated: lastUpdated }; + openMRSEncounter.subject = { + reference: `Patient/${openMRSPatient.id}` + }; + const openMRSObservation = ObservationFactory.build(); + openMRSObservation.encounter = { reference: 'Encounter/' + openMRSEncounter.id } + + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [], + status: 200, + }); + + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [openMRSEncounter, openMRSPatient, openMRSObservation], + status: 200, + }); + + jest.spyOn(fhir, 'getFHIRPatientResource').mockResolvedValueOnce({ + data: { entry: [{ resource: openMRSPatient }] }, + status: 200, + }); + + jest.spyOn(fhir, 'getFhirResourceByIdentifier').mockResolvedValue({ + data: { total: 1, entry: [ { resource: openMRSPatient } ] }, + status: 200, + }); + + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValue({ + data: [], + status: 201, + }); + + jest.spyOn(fhir, 'createFhirResource') + + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await syncEncounters(startTime); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + + expect(fhir.updateFhirResource).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/mediator/test/cht-resource-factories.ts b/mediator/test/cht-resource-factories.ts index cd3830b1..2a0f8a99 100644 --- a/mediator/test/cht-resource-factories.ts +++ b/mediator/test/cht-resource-factories.ts @@ -1,11 +1,11 @@ import { randomUUID } from 'crypto'; import { Factory } from 'rosie'; -const PlaceFactory = Factory.define('place') +export const PlaceFactory = Factory.define('place') .option('placeId', randomUUID()) .attr('name', 'CHP Branch One') .attr('type', 'district_hospital') - .attr('parent', ['placeId'], function (placeId) { + .attr('placeId', ['placeId'], function (placeId) { return placeId; }); @@ -14,19 +14,23 @@ const ContactFactory = Factory.define('contact') .attr('phone', '+2868917046'); export const UserFactory = Factory.define('user') - .option('placeId') + .option('parentPlace') .attr('password', 'Dakar1234') .attr('username', 'maria') .attr('type', 'chw') - .attr('place', ['placeId'], function (placeId) { - return PlaceFactory.build({}, { placeId }); + .attr('place', ['parentPlace'], function (parentPlace) { + return { + "name": "Mary's Area", + "type": "health_center", + "parent": parentPlace + }; }) .attr('contact', function () { return ContactFactory.build(); }); export const PatientFactory = Factory.define('patient') - .option('placeId') + .option('place') .attr('name', 'John Test') .attr('phone', '+2548277217095') .attr('date_of_birth', '1980-06-06') @@ -34,8 +38,8 @@ export const PatientFactory = Factory.define('patient') .attr('type', 'person') .attr('role', 'patient') .attr('contact_type', 'patient') - .attr('place', ['placeId'], function (placeId) { - return placeId; + .attr('place', ['place'], function (place) { + return place; }); const DocsFieldsFactory = Factory.define('fields') @@ -80,3 +84,12 @@ export const TaskReportFactory = Factory.define('report') return [DocsFactory.build({}, { placeId, contactId, patientId })]; }) .attr('new_edits', false); + + export const HeightWeightReportFactory = Factory.define('report') + .option('patientUuid') + .attr('_meta', { 'form': 'HEIGHT_WEIGHT' }) + .attr('patient_uuid', ['patientUuid'], function (patientUuid) { + return patientUuid; + }) + .attr('height', 172) + .attr('weight', 65); diff --git a/mediator/test/e2e-test.sh b/mediator/test/e2e-test.sh index ff3922a5..a0de0bef 100755 --- a/mediator/test/e2e-test.sh +++ b/mediator/test/e2e-test.sh @@ -6,33 +6,54 @@ MEDIATORDIR="${BASEDIR}/mediator" export NODE_ENV=integration export NODE_TLS_REJECT_UNAUTHORIZED=0 -retry_startup() { - max_attempts=5 - count=0 - until ./startup.sh init || [ $count -eq $max_attempts ]; do - echo "Attempt $((count+1)) of $max_attempts to start containers failed, retrying in 30 seconds..." - count=$((count+1)) - sleep 30 - done - - if [ $count -eq $max_attempts ]; then - echo "Failed to start containers after $max_attempts attempts." - exit 1 - fi -} - echo 'Cleanup from last test, in case of interruptions...' cd $BASEDIR ./startup.sh destroy + +export OPENMRS_HOST=openmrs +export OPENMRS_USERNAME=admin +export OPENMRS_PASSWORD=Admin123 + +echo 'Pulling Docker images with retry mechanism...' +services=("haproxy" "healthcheck" "api" "sentinel" "nginx" "couchdb") +max_retries=3 +retry_delay=10 # seconds + +# Retry pulling the images +for service in "${services[@]}"; do + attempt=1 + success=false + while [[ $attempt -le $max_retries ]]; do + echo "Pulling service: $service (Attempt $attempt of $max_retries)" + + # Attempt to pull the image for the specific service + if docker compose -f ./docker/docker-compose.cht-core.yml pull $service; then + echo "$service pulled successfully!" + success=true + break + else + echo "Failed to pull $service. Retrying in $retry_delay seconds..." + sleep $retry_delay + fi + attempt=$(( attempt + 1 )) + done + + # Check if we exhausted all retries without success + if [[ $success == false ]]; then + echo "ERROR: Failed to pull $service after $max_retries attempts." + exit 1 # Exit the script if pulling the image fails after retries + fi +done + echo 'Starting the interoperability containers...' -cd $BASEDIR -retry_startup +./startup.sh up-test echo 'Waiting for configurator to finish...' -docker container wait chis-interop-configurator-1 +docker container wait chis-interop-cht-configurator-1 +echo 'Waiting for OpenMRS to be ready' +sleep 280 -echo 'Executing mediator e2e tests...' cd $MEDIATORDIR export OPENHIM_API_URL='https://localhost:8080' export FHIR_URL='http://localhost:5001' @@ -43,11 +64,18 @@ export FHIR_USERNAME='interop-client' export FHIR_PASSWORD='interop-password' export CHT_USERNAME='admin' export CHT_PASSWORD='password' -npm test ltfu-flow.spec.ts +export OPENMRS_CHANNEL_URL='http://localhost:5001/openmrs' +export OPENMRS_CHANNEL_USERNAME='interop-client' +export OPENMRS_CHANNEL_PASSWORD='interop-password' +echo 'Executing mediator e2e tests...' +npm run test -t workflows.spec.ts echo 'Cleanup after test...' unset NODE_ENV unset NODE_TLS_REJECT_UNAUTHORIZED +unset OPENMRS_HOST +unset OPENMRS_USERNAME +unset OPENMRS_PASSWORD unset OPENHIM_API_URL unset FHIR_URL unset CHT_URL @@ -57,6 +85,8 @@ unset FHIR_USERNAME unset FHIR_PASSWORD unset CHT_USERNAME unset CHT_PASSWORD +unset OPENMRS_CHANNEL_URL +unset OPENMRS_CHANNEL_USERNAME +unset OPENMRS_CHANNEL_PASSWORD cd $BASEDIR ./startup.sh destroy - diff --git a/mediator/test/ltfu-flow.spec.ts b/mediator/test/ltfu-flow.spec.ts deleted file mode 100644 index 8e7d8478..00000000 --- a/mediator/test/ltfu-flow.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import request from 'supertest'; -import { OPENHIM, CHT, FHIR } from '../config'; -import { - UserFactory, PatientFactory, TaskReportFactory -} from './cht-resource-factories'; -import { - EndpointFactory as EndpointFactoryBase, - OrganizationFactory as OrganizationFactoryBase, - ServiceRequestFactory as ServiceRequestFactoryBase -} from '../src/middlewares/schemas/tests/fhir-resource-factories'; -const { generateAuthHeaders } = require('../../configurator/libs/authentication'); - -jest.setTimeout(10000); - -const EndpointFactory = EndpointFactoryBase.attr('status', 'active') - .attr('address', 'https://interop.free.beeceptor.com/callback') - .attr('payloadType', [{ text: 'application/json' }]); - -const endpointIdentifier = 'test-endpoint'; -const organizationIdentifier = 'test-org'; -const OrganizationFactory = OrganizationFactoryBase.attr('identifier', [{ system: 'official', value: organizationIdentifier }]); - -const ServiceRequestFactory = ServiceRequestFactoryBase.attr('status', 'active'); - -const installMediatorConfiguration = async () => { - const authHeaders = await generateAuthHeaders({ - apiURL: OPENHIM.apiURL, - username: OPENHIM.username, - password: OPENHIM.password, - rejectUnauthorized: false, - }); - try { - const res = await request(OPENHIM.apiURL) - .post('/mediators/urn:mediator:ltfu-mediator/channels') - .send(['Mediator']) - .set('auth-username', authHeaders['auth-username']) - .set('auth-ts', authHeaders['auth-ts']) - .set('auth-salt', authHeaders['auth-salt']) - .set('auth-token', authHeaders['auth-token']); - - if (res.status !== 201) { - throw new Error(`Mediator channel installation failed: Reason ${res.status}`); - } - } catch (error) { - throw new Error(`Mediator channel installation failed ${error}`); - } -}; -let placeId: string; -let chwUserName: string; -let chwPassword: string; -let contactId: string; - -const configureCHT = async () => { - const createPlaceResponse = await request(CHT.url) - .post('/api/v1/places') - .auth(CHT.username, CHT.password) - .send({ 'name': 'CHP Branch Two', 'type': 'district_hospital' }); - - if (createPlaceResponse.status === 200 && createPlaceResponse.body.ok === true) { - placeId = createPlaceResponse.body.id; - } else { - throw new Error(`CHT place creation failed: Reason ${createPlaceResponse.status}`); - } - - const user = UserFactory.build({}, { placeId: placeId }); - - chwUserName = user.username; - chwPassword = user.password; - - const createUserResponse = await request(CHT.url) - .post('/api/v2/users') - .auth(CHT.username, CHT.password) - .send(user); - if (createUserResponse.status === 200) { - contactId = createUserResponse.body.contact.id; - } else { - throw new Error(`CHT user creation failed: Reason ${createUserResponse.status}`); - } -}; - -describe('Steps to follow the Loss To Follow-Up (LTFU) workflow', () => { - let patientId: string; - let encounterUrl: string; - let endpointId: string; - - beforeAll(async () => { - await installMediatorConfiguration(); - await configureCHT(); - }); - - it('Should follow the LTFU workflow', async () => { - const checkMediatorResponse = await request(FHIR.url) - .get('/mediator/') - .auth(FHIR.username, FHIR.password); - - expect(checkMediatorResponse.status).toBe(200); - expect(checkMediatorResponse.body.status).toBe('success'); - - const identifier = [{ system: 'official', value: endpointIdentifier }]; - const endpoint = EndpointFactory.build({ identifier: identifier }); - const createMediatorEndpointResponse = await request(FHIR.url) - .post('/mediator/endpoint') - .auth(FHIR.username, FHIR.password) - .send(endpoint); - - expect(createMediatorEndpointResponse.status).toBe(201); - endpointId = createMediatorEndpointResponse.body.id; - - const retrieveEndpointResponse = await request(FHIR.url) - .get('/fhir/Endpoint/?identifier=' + endpointIdentifier) - .auth(FHIR.username, FHIR.password); - - expect(retrieveEndpointResponse.status).toBe(200); - expect(retrieveEndpointResponse.body.total).toBe(1); - - const organization = OrganizationFactory.build(); - organization.endpoint[0].reference = `Endpoint/${endpointId}`; - - const createMediatorOrganizationResponse = await request(FHIR.url) - .post('/mediator/organization') - .auth(FHIR.username, FHIR.password) - .send(organization); - - expect(createMediatorOrganizationResponse.status).toBe(201); - - const retrieveOrganizationResponse = await request(FHIR.url) - .get('/fhir/Organization/?identifier=' + organizationIdentifier) - .auth(FHIR.username, FHIR.password); - - expect(retrieveOrganizationResponse.status).toBe(200); - expect(retrieveOrganizationResponse.body.total).toBe(1); - - const patient = PatientFactory.build({}, { placeId: placeId }); - - const createPatientResponse = await request(CHT.url) - .post('/api/v1/people') - .auth(chwUserName, chwPassword) - .send(patient); - - expect(createPatientResponse.status).toBe(200); - expect(createPatientResponse.body.ok).toEqual(true); - patientId = createPatientResponse.body.id; - - const retrieveFhirPatientIdResponse = await request(FHIR.url) - .get('/fhir/Patient/?identifier=' + patientId) - .auth(FHIR.username, FHIR.password); - - expect(retrieveFhirPatientIdResponse.status).toBe(200); - - const serviceRequest = ServiceRequestFactory.build(); - serviceRequest.subject.reference = `Patient/${patientId}`; - serviceRequest.requester.reference = `Organization/${organizationIdentifier}`; - - const sendMediatorServiceRequestResponse = await request(FHIR.url) - .post('/mediator/service-request') - .auth(FHIR.username, FHIR.password) - .send(serviceRequest); - expect(sendMediatorServiceRequestResponse.status).toBe(201); - encounterUrl = sendMediatorServiceRequestResponse.body.criteria; - - const taskReport = TaskReportFactory.build({}, { placeId, contactId, patientId }); - - const submitChtTaskResponse = await request(CHT.url) - .post('/medic/_bulk_docs') - .auth(chwUserName, chwPassword) - .send(taskReport); - - expect(submitChtTaskResponse.status).toBe(201); - - await new Promise((r) => setTimeout(r, 2000)); - - const retrieveFhirDbEncounter = await request(FHIR.url) - .get('/fhir/' + encounterUrl) - .auth(FHIR.username, FHIR.password); - - expect(retrieveFhirDbEncounter.status).toBe(200); - expect(retrieveFhirDbEncounter.body.total).toBe(1); - }); -}); - diff --git a/mediator/test/openmrs-resource-factories.ts b/mediator/test/openmrs-resource-factories.ts new file mode 100644 index 00000000..39e164dc --- /dev/null +++ b/mediator/test/openmrs-resource-factories.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'crypto'; +import { Factory } from 'rosie'; + +export const OpenMRSPatientFactory = new Factory() + .option('placeId') + .attr('resourceType', 'Patient') + .attr('id', () => randomUUID()) + .attr('meta', () => ({ + versionId: '2', + lastUpdated: new Date().toISOString(), + source: 'cht#rjEgeBRWROBrChB7' + })) + .attr('text', () => ({ + status: 'generated', + div: '
OpenMRS PATIENT
Identifier52802
Date of birth06 June 1980
' + })) + .attr('identifier', () => [ + { + id: randomUUID(), + use: 'official', + type: { text: 'CHT Patient ID' }, + value: Math.floor(Math.random() * 100000).toString() + }, + { + id: randomUUID(), + use: 'secondary', + type: { text: 'CHT Document ID' }, + value: randomUUID() + }, + { + id: randomUUID(), + use: 'secondary', + type: { text: 'OpenMRS Patient UUID' }, + value: randomUUID() + } + ]) + .attr('name', () => [ + { + id: randomUUID(), + family: 'Patient', + given: ['OpenMRS'] + } + ]) + .attr('telecom', () => [ + { + id: randomUUID(), + value: '+2548277217095' + } + ]) + .attr('gender', 'male') + .attr('birthDate', '1980-06-06') + .attr('address', ['placeId'], (placeId) => [ + { + id: randomUUID(), + line: ['123 Main St'], + city: 'Nairobi', + country: 'Kenya', + extension: [{ + extension: [ + { + url: 'http://fhir.openmrs.org/ext/address#address4', + valueString: `FCHV Area [${placeId}]` + } + ] + }] + } + ]); diff --git a/mediator/test/workflows.spec.ts b/mediator/test/workflows.spec.ts new file mode 100644 index 00000000..d5f39b23 --- /dev/null +++ b/mediator/test/workflows.spec.ts @@ -0,0 +1,337 @@ +import request from 'supertest'; +import { OPENHIM, CHT, FHIR, OPENMRS } from '../config'; +import { + UserFactory, PatientFactory, TaskReportFactory, PlaceFactory, HeightWeightReportFactory, +} from './cht-resource-factories'; +import { + EndpointFactory as EndpointFactoryBase, + OrganizationFactory as OrganizationFactoryBase, + ServiceRequestFactory as ServiceRequestFactoryBase +} from '../src/middlewares/schemas/tests/fhir-resource-factories'; + +const { generateAuthHeaders } = require('../../configurator/libs/authentication'); + +jest.setTimeout(50000); + +const EndpointFactory = EndpointFactoryBase.attr('status', 'active') + .attr('address', 'https://interop.free.beeceptor.com/callback') + .attr('payloadType', [{ text: 'application/json' }]); + +const endpointIdentifier = 'test-endpoint'; +const organizationIdentifier = 'test-org'; +const OrganizationFactory = OrganizationFactoryBase.attr('identifier', [{ system: 'official', value: organizationIdentifier }]); + +const ServiceRequestFactory = ServiceRequestFactoryBase.attr('status', 'active'); + +const OPENMRS_APP_URL = 'http://localhost:8090/openmrs'; +const OPENMRS_APP_USER = 'admin'; +const OPENMRS_APP_PASSWORD = 'Admin123'; + +const installMediatorConfiguration = async () => { + const authHeaders = await generateAuthHeaders({ + apiURL: OPENHIM.apiURL, + username: OPENHIM.username, + password: OPENHIM.password, + rejectUnauthorized: false, + }); + try { + const res = await request(OPENHIM.apiURL) + .post('/mediators/urn:mediator:cht-mediator/channels') + .send(['Mediator']) + .set('auth-username', authHeaders['auth-username']) + .set('auth-ts', authHeaders['auth-ts']) + .set('auth-salt', authHeaders['auth-salt']) + .set('auth-token', authHeaders['auth-token']); + + if (res.status !== 201) { + throw new Error(`Mediator channel installation failed: Reason ${res.status}`); + } + } catch (error) { + throw new Error(`Mediator channel installation failed ${error}`); + } +}; + +const createOpenMRSIdType = async (name: string) => { + const patientIdType = { + name: name, + description: name, + required: false, + locationBehavior: "NOT_USED", + uniquenessBehavior: "Unique" + } + try { + const res = await request(OPENMRS_APP_URL) + .post('/ws/rest/v1/patientidentifiertype') + .auth(OPENMRS_APP_USER, OPENMRS_APP_PASSWORD) + .send(patientIdType) + if (res.status !== 201) { + console.error('Response:', res); + throw new Error(`create OpenMRS Id Type failed: Reason ${JSON.stringify(res.body || res)}`); + } + } catch (error) { + throw new Error(`create OpenMRS Id Type failed ${error}`); + } +}; + +const parentPlace = PlaceFactory.build(); +let chwUserName: string; +let chwPassword: string; +let contactId: string; +let patientId: string; +let parentPlaceId: string; +let placeId: string; + + +const configureCHT = async () => { + const createPlaceResponse = await request(CHT.url) + .post('/api/v1/places') + .auth(CHT.username, CHT.password) + .send(parentPlace); + + if (createPlaceResponse.status === 200 && createPlaceResponse.body.ok === true) { + parentPlaceId = createPlaceResponse.body.id; + } else { + throw new Error(`CHT place creation failed: Reason ${createPlaceResponse.status}`); + } + + const user = UserFactory.build({}, { parentPlace: parentPlaceId }); + chwUserName = user.username; + chwPassword = user.password; + + const createUserResponse = await request(CHT.url) + .post('/api/v2/users') + .auth(CHT.username, CHT.password) + .send(user); + if (createUserResponse.status === 200) { + contactId = createUserResponse.body.contact.id; + } else { + throw new Error(`CHT user creation failed: Reason ${createUserResponse.status}`); + } + + const retrieveChtHealthCenterResponse = await request(CHT.url) + .get('/api/v2/users/maria') + .auth(CHT.username, CHT.password); + if (retrieveChtHealthCenterResponse.status === 200) { + placeId = retrieveChtHealthCenterResponse.body.place[0]._id; + } else { + throw new Error(`CHT health center retrieval failed: Reason ${retrieveChtHealthCenterResponse.status}`); + } +}; + +describe('Workflows', () => { + + beforeAll(async () => { + await installMediatorConfiguration(); + await configureCHT(); + await new Promise((r) => setTimeout(r, 3000)); + }); + + describe('OpenMRS workflow', () => { + it('should follow the CHT Patient to OpenMRS workflow', async () => { + await createOpenMRSIdType('CHT Patient ID'); + await createOpenMRSIdType('CHT Document ID'); + + const checkMediatorResponse = await request(FHIR.url) + .get('/mediator/') + .auth(FHIR.username, FHIR.password); + expect(checkMediatorResponse.status).toBe(200); + expect(checkMediatorResponse.body.status).toBe('success'); + + const patient = PatientFactory.build({name: 'CHTOpenMRS Patient', phone: '+2548277217095'}, { place: placeId }); + + const createPatientResponse = await request(CHT.url) + .post('/api/v1/people') + .auth(chwUserName, chwPassword) + .send(patient); + + expect(createPatientResponse.status).toBe(200); + expect(createPatientResponse.body.ok).toEqual(true); + patientId = createPatientResponse.body.id; + + await new Promise((r) => setTimeout(r, 10000)); + + const retrieveFhirPatientIdResponse = await request(FHIR.url) + .get('/fhir/Patient/?identifier=' + patientId) + .auth(FHIR.username, FHIR.password); + expect(retrieveFhirPatientIdResponse.status).toBe(200); + expect(retrieveFhirPatientIdResponse.body.total).toBe(1); + + const triggerOpenMrsSyncPatientResponse = await request(FHIR.url) + .get('/mediator/openmrs/sync') + .auth(FHIR.username, FHIR.password) + .send(); + expect(triggerOpenMrsSyncPatientResponse.status).toBe(200); + + await new Promise((r) => setTimeout(r, 10000)); + + const retrieveOpenMrsPatientIdResponse = await request(OPENMRS.url) + .get('/Patient/?identifier=' + patientId) + .auth(OPENMRS.username, OPENMRS.password); + expect(retrieveOpenMrsPatientIdResponse.status).toBe(200); + expect(retrieveOpenMrsPatientIdResponse.body.total).toBe(1); + + const openMrsPatientId = retrieveOpenMrsPatientIdResponse.body.entry[0].resource.id; + const retrieveUpdatedFhirPatientResponse = await request(FHIR.url) + .get(`/fhir/Patient/${patientId}`) + .auth(FHIR.username, FHIR.password); + expect(retrieveUpdatedFhirPatientResponse.status).toBe(200); + expect(retrieveUpdatedFhirPatientResponse.body.identifier).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: openMrsPatientId, + }) + ]) + ); + + const searchOpenMrsPatientResponse = await request(OPENMRS.url) + .get(`/Patient/?given=CHTOpenMRS&family=Patient`) + .auth(OPENMRS.username, OPENMRS.password); + expect(searchOpenMrsPatientResponse.status).toBe(200); + expect(searchOpenMrsPatientResponse.body.total).toBe(1); + expect(searchOpenMrsPatientResponse.body.entry[0].resource.id).toBe(openMrsPatientId); + + const heightWeightReport = HeightWeightReportFactory.build({}, { patientUuid: patientId}); + + const submitHeightWeightReport = await request(CHT.url) + .post('/api/v2/records') + .auth(chwUserName, chwPassword) + .send(heightWeightReport); + + expect(submitHeightWeightReport.status).toBe(200); + + await new Promise((r) => setTimeout(r, 2000)); + + const retrieveFhirDbEncounter = await request(FHIR.url) + .get('/fhir/Encounter/?subject=Patient/' + patientId) + .auth(FHIR.username, FHIR.password); + + expect(retrieveFhirDbEncounter.status).toBe(200); + expect(retrieveFhirDbEncounter.body.total).toBe(1); + + const retrieveFhirDbObservation = await request(FHIR.url) + .get('/fhir/Observation/?subject=Patient/' + patientId) + .auth(FHIR.username, FHIR.password); + + expect(retrieveFhirDbObservation.status).toBe(200); + expect(retrieveFhirDbObservation.body.total).toBe(2); + + const triggerOpenMrsSyncEncounterResponse = await request(FHIR.url) + .get('/mediator/openmrs/sync') + .auth(FHIR.username, FHIR.password) + .send(); + expect(triggerOpenMrsSyncEncounterResponse.status).toBe(200); + + await new Promise((r) => setTimeout(r, 2000)); + + const retrieveOpenMrsEncounterResponse = await request(OPENMRS_APP_URL) + .get('/ws/fhir2/R4/Encounter') + .auth(OPENMRS_APP_USER, OPENMRS_APP_PASSWORD); + expect(retrieveOpenMrsEncounterResponse.status).toBe(200); + expect(retrieveOpenMrsEncounterResponse.body.total).toBe(1); + + const retrieveOpenMrsObservationResponse = await request(OPENMRS_APP_URL) + .get('/ws/fhir2/R4/Observation') + .auth(OPENMRS_APP_USER, OPENMRS_APP_PASSWORD); + expect(retrieveOpenMrsObservationResponse.status).toBe(200); + expect(retrieveOpenMrsObservationResponse.body.total).toBe(2); + }); + + }); + + describe('Loss To Follow-Up (LTFU) workflow', () => { + let encounterUrl: string; + let endpointId: string; + + it('Should follow the LTFU workflow', async () => { + const checkMediatorResponse = await request(FHIR.url) + .get('/mediator/') + .auth(FHIR.username, FHIR.password); + + expect(checkMediatorResponse.status).toBe(200); + expect(checkMediatorResponse.body.status).toBe('success'); + + const identifier = [{ system: 'official', value: endpointIdentifier }]; + const endpoint = EndpointFactory.build({ identifier: identifier }); + const createMediatorEndpointResponse = await request(FHIR.url) + .post('/mediator/endpoint') + .auth(FHIR.username, FHIR.password) + .send(endpoint); + + expect(createMediatorEndpointResponse.status).toBe(201); + endpointId = createMediatorEndpointResponse.body.id; + + const retrieveEndpointResponse = await request(FHIR.url) + .get('/fhir/Endpoint/?identifier=' + endpointIdentifier) + .auth(FHIR.username, FHIR.password); + + expect(retrieveEndpointResponse.status).toBe(200); + expect(retrieveEndpointResponse.body.total).toBe(1); + + const organization = OrganizationFactory.build(); + organization.endpoint[0].reference = `Endpoint/${endpointId}`; + + const createMediatorOrganizationResponse = await request(FHIR.url) + .post('/mediator/organization') + .auth(FHIR.username, FHIR.password) + .send(organization); + + expect(createMediatorOrganizationResponse.status).toBe(201); + + const retrieveOrganizationResponse = await request(FHIR.url) + .get('/fhir/Organization/?identifier=' + organizationIdentifier) + .auth(FHIR.username, FHIR.password); + + expect(retrieveOrganizationResponse.status).toBe(200); + expect(retrieveOrganizationResponse.body.total).toBe(1); + + const patient = PatientFactory.build({}, { name: 'LTFU patient', place: placeId }); + + const createPatientResponse = await request(CHT.url) + .post('/api/v1/people') + .auth(chwUserName, chwPassword) + .send(patient); + + expect(createPatientResponse.status).toBe(200); + expect(createPatientResponse.body.ok).toEqual(true); + patientId = createPatientResponse.body.id; + + await new Promise((r) => setTimeout(r, 3000)); + + const retrieveFhirPatientIdResponse = await request(FHIR.url) + .get('/fhir/Patient/?identifier=' + patientId) + .auth(FHIR.username, FHIR.password); + + expect(retrieveFhirPatientIdResponse.status).toBe(200); + expect(retrieveFhirPatientIdResponse.body.total).toBe(1); + + const serviceRequest = ServiceRequestFactory.build(); + serviceRequest.subject.reference = `Patient/${patientId}`; + serviceRequest.requester.reference = `Organization/${organizationIdentifier}`; + + const sendMediatorServiceRequestResponse = await request(FHIR.url) + .post('/mediator/service-request') + .auth(FHIR.username, FHIR.password) + .send(serviceRequest); + expect(sendMediatorServiceRequestResponse.status).toBe(201); + encounterUrl = sendMediatorServiceRequestResponse.body.criteria; + + const taskReport = TaskReportFactory.build({}, { placeId: placeId, contactId, patientId }); + + const submitChtTaskResponse = await request(CHT.url) + .post('/medic/_bulk_docs') + .auth(chwUserName, chwPassword) + .send(taskReport); + + expect(submitChtTaskResponse.status).toBe(201); + + await new Promise((r) => setTimeout(r, 2000)); + + const retrieveFhirDbEncounter = await request(FHIR.url) + .get('/fhir/' + encounterUrl) + .auth(FHIR.username, FHIR.password); + + expect(retrieveFhirDbEncounter.status).toBe(200); + expect(retrieveFhirDbEncounter.body.total).toBe(1); + }); + }); +}); diff --git a/startup.sh b/startup.sh index 6e7dcf30..209ba1a3 100755 --- a/startup.sh +++ b/startup.sh @@ -2,24 +2,41 @@ if [ "$1" == "init" ]; then # start up docker containers - docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.cht-couchdb.yml -f ./docker/docker-compose.mediator.yml up -d --build + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml up -d --build elif [ "$1" == "up" ]; then - docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.cht-couchdb.yml up -d + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml up -d elif [ "$1" == "up-dev" ]; then - docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.cht-couchdb.yml -f ./docker/docker-compose.mediator.yml up -d --build + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml up -d --build elif [ "$1" == "down" ]; then - docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.cht-couchdb.yml stop + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.openmrs.yml stop elif [ "$1" == "destroy" ]; then - docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.cht-couchdb.yml down -v -else + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.openmrs.yml down -v +elif [ "$1" == "up-test" ]; then + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.openmrs.yml up -d --build +elif [ "$1" == "up-openmrs" ]; then + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.openmrs.yml up -d --build +else echo "Invalid option $1 - + Help: - init starts the docker containers and configures OpenHIM - up starts the docker containers - up-dev starts the docker containers with updated files. - down stops the docker containers - destroy shutdown the docker containers and deletes volumes + init starts the docker containers and configures OpenHIM + up starts the docker containers + up-dev starts the docker containers with updated files. + up-test starts the docker containers with updated files, including CHT Core + up-openmrs starts the docker containers with updated files, including CHT Core and OpenMRS + down stops the docker containers + destroy shutdown the docker containers and deletes volumes " fi + +echo " + + Possible URLs after startup: + ----------- + OpenMRS http://localhost:8090/ + OpenMRS MySQL localhost:3306 + CHT Core https://localhost:8843/ + OpenHIM Console http://localhost:9000/ + +"