From b178bc76242d19aeabff6b364692073df59f7582 Mon Sep 17 00:00:00 2001 From: kghoreshi Date: Wed, 16 Nov 2022 14:28:22 -0500 Subject: [PATCH 1/4] change to typescript --- .gitignore | 1 + nodemon.json | 6 ++ package.json | 21 ++-- src/cards/Card.ts | 32 ++++++ src/cards/ErrorCard.ts | 6 ++ src/hooks/Hook.ts | 9 ++ src/hooks/OrderSign.ts | 17 ++++ src/hooks/OrderSignRequest.ts | 27 +++++ src/hooks/Prefetch/OrderSignPrefetch.ts | 5 + .../Prefetch/OrderSignRequestPrefetch.ts | 7 ++ src/hooks/Prefetch/RequestPrefetch.ts | 5 + src/hooks/Prefetch/ServicePrefetch.ts | 3 + src/hooks/rems.hook.js | 98 ------------------- src/hooks/rems.hook.ts | 91 +++++++++++++++++ src/lib/winston.js | 45 --------- src/lib/winston.ts | 33 +++++++ src/{main.js => main.ts} | 8 +- src/scripts/{develop.js => develop.ts} | 6 +- src/scripts/serve.js | 3 - src/scripts/serve.ts | 3 + src/{server.js => server.ts} | 38 ++++--- tsconfig.json | 27 +++++ 22 files changed, 315 insertions(+), 176 deletions(-) create mode 100644 nodemon.json create mode 100644 src/cards/Card.ts create mode 100644 src/cards/ErrorCard.ts create mode 100644 src/hooks/Hook.ts create mode 100644 src/hooks/OrderSign.ts create mode 100644 src/hooks/OrderSignRequest.ts create mode 100644 src/hooks/Prefetch/OrderSignPrefetch.ts create mode 100644 src/hooks/Prefetch/OrderSignRequestPrefetch.ts create mode 100644 src/hooks/Prefetch/RequestPrefetch.ts create mode 100644 src/hooks/Prefetch/ServicePrefetch.ts delete mode 100644 src/hooks/rems.hook.js create mode 100644 src/hooks/rems.hook.ts delete mode 100644 src/lib/winston.js create mode 100644 src/lib/winston.ts rename src/{main.js => main.ts} (75%) rename src/scripts/{develop.js => develop.ts} (73%) delete mode 100644 src/scripts/serve.js create mode 100644 src/scripts/serve.ts rename src/{server.js => server.ts} (62%) create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index ab140fa4..0f267621 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ package-lock.json COVERAGE/ logs/ node_modules/ +dist/ \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 00000000..d09de344 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": ".ts,.js", + "ignore": [], + "exec": "ts-node ./src/main.ts" + } \ No newline at end of file diff --git a/package.json b/package.json index 1dfb74e6..152d72d8 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,13 @@ "doc": "doc" }, "scripts": { - "develop": "node src/scripts/nodemon.js", - "start": "node src/scripts/serve.js", + "develop": "node dist/scripts/develop.js", + "start": "ts-node-dev src/scripts/serve.ts", "test": "jest --maxWorkers=4 --coverage --detectOpenHandles", "lint": "eslint \"**/*.{js,ts}\"", "lint:fix": "eslint \"**/*.{js,ts}\" --quiet --fix", "prettier": "prettier --check \"**/*.{js,ts}\"", - "prettier:fix": "prettier --write \"**/*.{js,ts}\"", - "changelog": "conventional-changelog -p angular -s -i CHANGELOG.md" + "prettier:fix": "prettier --write \"**/*.{js,ts}\"" }, "repository": { "type": "git", @@ -42,6 +41,7 @@ ] }, "dependencies": { + "@types/fhir": "^0.0.35", "body-parser": "^1.19.0", "conventional-changelog-cli": "^2.0.34", "cors": "^2.8.5", @@ -49,19 +49,26 @@ "lodash": "^4.17.19", "moment": "^2.24.0", "morgan": "^1.9.1", - "nodemon": "^1.19.4", "winston": "^3.2.1", "winston-daily-rotate-file": "^4.2.1" }, "devDependencies": { - "prettier": "^2.0.5", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.14", + "@types/lodash": "^4.14.188", + "@types/morgan": "^1.9.3", + "@types/node": "^18.11.9", + "@types/nodemon": "^1.19.2", "eslint": "^8.5.0", "eslint-config-prettier": "^6.10.1", "jest": "^27.4.5", "jest-extended": "^1.2.0", "json-diff": "^0.9.0", + "nodemon": "^2.0.20", + "prettier": "^2.0.5", + "ts-node": "^10.9.1", "ts-jest": "^27.1.2", - "ts-node": "^9.1.1", + "ts-node-dev": "^2.0.0", "typescript": "^4.8.4" } } diff --git a/src/cards/Card.ts b/src/cards/Card.ts new file mode 100644 index 00000000..e9560b54 --- /dev/null +++ b/src/cards/Card.ts @@ -0,0 +1,32 @@ +export default class Card { + + summary: string + detail: string + sourceLabel: string + sourceUrl: string + indicator: string + + constructor(summary: string, detail: string, sourceLabel: string, sourceUrl: string, indicator: string = "info") { + this.summary = summary + this.detail = detail + this.sourceLabel = sourceLabel + this.sourceUrl = sourceUrl + this.indicator = indicator + } + + get card(){ + return this.generateCard() + } + + generateCard(){ + return { + summary: this.summary, + detail: this.detail, + source: { + label: this.sourceLabel, + url: this.sourceUrl + }, + indicator: this.indicator + } + } +} \ No newline at end of file diff --git a/src/cards/ErrorCard.ts b/src/cards/ErrorCard.ts new file mode 100644 index 00000000..456ba343 --- /dev/null +++ b/src/cards/ErrorCard.ts @@ -0,0 +1,6 @@ +import Card from "./Card" +export default class ErrorCard extends Card{ + constructor(summary:string, detail:string, label:string, url:string, indicator:string = "error"){ + super(summary, detail, label, url, indicator) + } +} \ No newline at end of file diff --git a/src/hooks/Hook.ts b/src/hooks/Hook.ts new file mode 100644 index 00000000..86b0bcb0 --- /dev/null +++ b/src/hooks/Hook.ts @@ -0,0 +1,9 @@ +import ServicePrefetch from "./Prefetch/ServicePrefetch"; + +export default interface Hook{ + id:string + hook: string + name: string + description: string + prefetch: ServicePrefetch +} \ No newline at end of file diff --git a/src/hooks/OrderSign.ts b/src/hooks/OrderSign.ts new file mode 100644 index 00000000..d0d21c2f --- /dev/null +++ b/src/hooks/OrderSign.ts @@ -0,0 +1,17 @@ +import Hook from "./Hook"; +import OrderSignPrefetch from "./Prefetch/OrderSignPrefetch"; + +export default class OrderSign implements Hook{ + id:string + hook: string + name: string + description: string + prefetch: OrderSignPrefetch + constructor(id:string, hook:string, name:string, description: string, prefetch: OrderSignPrefetch){ + this.id = id + this.hook = hook + this.name = name + this.description = description + this.prefetch = prefetch + } +} \ No newline at end of file diff --git a/src/hooks/OrderSignRequest.ts b/src/hooks/OrderSignRequest.ts new file mode 100644 index 00000000..09e0a544 --- /dev/null +++ b/src/hooks/OrderSignRequest.ts @@ -0,0 +1,27 @@ +import { Bundle } from "fhir/r4"; +import { Resource } from "fhir/r4"; +import { Url } from "url"; +import OrderSignRequestPrefetch from "./Prefetch/OrderSignRequestPrefetch"; +// https://cds-hooks.hl7.org/1.0/#fhir-resource-access +interface FhirAuthorization { + token_type: string + expires_in: number + scope: string + subject: string +} +// https://cds-hooks.org/hooks/order-sign/#context +interface OrderSignContext { + userId: string + patientId: string + encounterId?: string + draftOrders: Bundle +} +// https://cds-hooks.hl7.org/1.0/#calling-a-cds-service +export default interface OrderSignRequest{ + hook: string + hookInstance: string + fhirServer: Url + fhirAuthorization: FhirAuthorization + context: OrderSignContext + prefetch: OrderSignRequestPrefetch +} \ No newline at end of file diff --git a/src/hooks/Prefetch/OrderSignPrefetch.ts b/src/hooks/Prefetch/OrderSignPrefetch.ts new file mode 100644 index 00000000..dc631ae7 --- /dev/null +++ b/src/hooks/Prefetch/OrderSignPrefetch.ts @@ -0,0 +1,5 @@ +export default interface OrderSignPrefetch{ + patient?: string + request?: string + practitioner?: string +} \ No newline at end of file diff --git a/src/hooks/Prefetch/OrderSignRequestPrefetch.ts b/src/hooks/Prefetch/OrderSignRequestPrefetch.ts new file mode 100644 index 00000000..63b9b4dd --- /dev/null +++ b/src/hooks/Prefetch/OrderSignRequestPrefetch.ts @@ -0,0 +1,7 @@ +import { MedicationRequest, Patient, Practitioner } from "fhir/r4"; +import RequestPrefetch from "./RequestPrefetch"; +export default interface OrderSignRequestPrefetch extends RequestPrefetch { + patient: Patient + request: MedicationRequest + practitioner: Practitioner +} \ No newline at end of file diff --git a/src/hooks/Prefetch/RequestPrefetch.ts b/src/hooks/Prefetch/RequestPrefetch.ts new file mode 100644 index 00000000..3f8f9754 --- /dev/null +++ b/src/hooks/Prefetch/RequestPrefetch.ts @@ -0,0 +1,5 @@ +import { Resource } from "fhir/r4"; + +export default interface RequestPrefetch { + [key: string]: Resource +} \ No newline at end of file diff --git a/src/hooks/Prefetch/ServicePrefetch.ts b/src/hooks/Prefetch/ServicePrefetch.ts new file mode 100644 index 00000000..17248dab --- /dev/null +++ b/src/hooks/Prefetch/ServicePrefetch.ts @@ -0,0 +1,3 @@ +export default interface ServicePrefetch{ + patient?: string +} \ No newline at end of file diff --git a/src/hooks/rems.hook.js b/src/hooks/rems.hook.js deleted file mode 100644 index 6789f43c..00000000 --- a/src/hooks/rems.hook.js +++ /dev/null @@ -1,98 +0,0 @@ -const { kebabCase } = require('lodash'); - -const definition = { - hook: 'order-sign', - name: 'REMS Requirement Lookup', - description: 'REMS Requirement Lookup', - id: 'rems-order-sign', - prefetch: { - patient: 'Patient/{{context.patientId}}', - request: 'MedicationRequest?_id={{context.draftOrders.MedicationRequest.id}}', - practitioner: 'Practitioner/{{context.userId}}' - } -}; - -const sourceLabel = 'MCODE REMS Administrator Prototype'; -const sourceUrl = 'https://github.com/mcode/REMS'; - -function buildErrorCard(reason) { - console.log(reason); - let cards = { - cards: [ - { - summary: 'Bad Request', - detail: reason, - source: { - label: sourceLabel, - url: sourceUrl - }, - indicator: 'warning' - } - ] - }; - return cards; -} - -const handler = (req, res) => { - console.log('REMS order-sign hook'); - try { - const context = req.body.context; - const contextRequest = context.draftOrders.entry[0]; - const prefetch = req.body.prefetch; - const patient = prefetch.patient; - const prefetchRequest = prefetch.request; - const practitioner = prefetch.practitioner; - const npi = practitioner.identifier[0].value; - - console.log(' MedicationRequest: ' + prefetchRequest.id); - console.log(' Practitioner: ' + practitioner.id + ' NPI: ' + npi); - console.log(' Patient: ' + patient.id); - - // verify a MedicationRequest was sent - if (contextRequest.resourceType !== 'MedicationRequest') { - res.json(buildErrorCard('DraftOrders does not contain a MedicationRequest')); - return; - } - - // verify ids - if (patient.id.replace('Patient/', '') !== context.patientId.replace('Patient/', '')) { - res.json(buildErrorCard('Context patientId does not match prefetch Patient ID')); - return; - } - if ( - practitioner.id.replace('Practitioner/', '') !== context.userId.replace('Practitioner/', '') - ) { - res.json(buildErrorCard('Context userId does not match prefetch Practitioner ID')); - return; - } - if ( - prefetchRequest.id.replace('MedicationRequest/', '') !== - contextRequest.id.replace('MedicationRequest/', '') - ) { - res.json(buildErrorCard('Context draftOrder does not match prefetch MedicationRequest ID')); - return; - } - - const text = `REMS required for Patient ${patient.id}`; - - let cards = { - cards: [ - { - summary: `Summary: ${text}`, - detail: `Detail: ${text}`, - source: { - label: sourceLabel, - url: sourceUrl - }, - indicator: 'info' - } - ] - }; - res.json(cards); - } catch (error) { - console.log(error); - res.json(buildErrorCard('Unknown Error')); - } -}; - -module.exports = { definition, handler }; diff --git a/src/hooks/rems.hook.ts b/src/hooks/rems.hook.ts new file mode 100644 index 00000000..4b17adc8 --- /dev/null +++ b/src/hooks/rems.hook.ts @@ -0,0 +1,91 @@ +import Card from '../cards/Card' +import ErrorCard from '../cards/ErrorCard' +import OrderSign from './OrderSign'; +import OrderSignRequest from './OrderSignRequest'; +import OrderSignPrefetch from './Prefetch/OrderSignPrefetch'; + +interface TypedRequestBody extends Express.Request { + body: OrderSignRequest +} + +const prefetch: OrderSignPrefetch = { + patient: 'Patient/{{context.patientId}}', + request:'MedicationRequest?_id={{context.draftOrders.MedicationRequest.id}}', + practitioner: 'Practitioner/{{context.userId}}' +} +const definition = new OrderSign('rems-order-sign', 'order-sign', 'REMS Requirement Lookup', 'REMS Requirement Lookup', prefetch) + +const sourceLabel = 'MCODE REMS Administrator Prototype'; +const sourceUrl = 'https://github.com/mcode/REMS'; + +function buildErrorCard(reason: string) { + console.log(reason); + const errorCard = new ErrorCard("Bad Request", reason, sourceLabel, sourceUrl, "warning") + let cards = { + cards: [ + errorCard.card + ], + }; + return cards; +} + +const handler = (req: TypedRequestBody, res: any) => { + console.log("REMS order-sign hook") + try { + const context = req.body.context; + const contextRequest = context?.draftOrders?.entry?.[0].resource; + const prefetch = req.body.prefetch; + const patient = prefetch.patient; + const prefetchRequest = prefetch.request; + const practitioner = prefetch.practitioner; + const npi = prefetch.practitioner.identifier + + const apo = practitioner?.identifier?.[0].value // null checking using ? operator + + console.log(' MedicationRequest: ' + prefetchRequest.id); + console.log(' Practitioner: ' + practitioner.id + ' NPI: ' + npi); + console.log(' Patient: ' + patient.id); + + // verify a MedicationRequest was sent + if (contextRequest && contextRequest.resourceType !== "MedicationRequest") { + res.json(buildErrorCard("DraftOrders does not contain a MedicationRequest")); + return; + } + + // verify ids + if (patient.id && patient.id.replace('Patient/','') !== context.patientId.replace('Patient/','')) { + res.json(buildErrorCard("Context patientId does not match prefetch Patient ID")); + return; + } + if (practitioner.id && practitioner.id.replace('Practitioner/','') !== context.userId.replace('Practitioner/','')) { + res.json(buildErrorCard("Context userId does not match prefetch Practitioner ID")); + return; + } + if ((prefetchRequest.id && contextRequest && contextRequest.id) && prefetchRequest.id.replace('MedicationRequest/','') !== contextRequest.id.replace('MedicationRequest/','')) { + res.json(buildErrorCard("Context draftOrder does not match prefetch MedicationRequest ID")); + return; + } + + const text = `REMS required for Patient ${patient.id}`; + + let cards = { + cards: [ + { + summary: `Summary: ${text}`, + detail: `Detail: ${text}`, + source: { + label: sourceLabel, + url: sourceUrl + }, + indicator: 'info' + } + ] + }; + res.json(cards); + } catch (error) { + console.log(error); + res.json(buildErrorCard('Unknown Error')); + } +}; + +export { definition, handler }; diff --git a/src/lib/winston.js b/src/lib/winston.js deleted file mode 100644 index fb27408b..00000000 --- a/src/lib/winston.js +++ /dev/null @@ -1,45 +0,0 @@ -const { Container, transports, format } = require('winston'); -const { logging } = require('../config.js'); -const path = require('path'); - -// Load in the daily file transport -require('winston-daily-rotate-file'); - -// Create our default logging container -let container = new Container(); -let applicationTransports = []; - -// Create a console transport -let transportConsole = new transports.Console({ - level: logging.level, - timestamp: true, - colorize: true -}); - -applicationTransports.push(transportConsole); - -// Create a file transport if we have specified the directory -if (logging.directory) { - let transportDailyFile = new transports.DailyRotateFile({ - filename: path.join(logging.directory, 'application-%DATE%.log'), - datePattern: 'YYYY-MM-DD-HH', - level: logging.level, - zippedArchive: true, - maxSize: '20m' - }); - - applicationTransports.push(transportDailyFile); -} - -// Add a default application logger -container.add('application', { - format: format.combine(format.timestamp(), format.logstash()), - transports: applicationTransports -}); - -/** - * @name exports - * @static - * @summary Logging container for the application - */ -module.exports = container; diff --git a/src/lib/winston.ts b/src/lib/winston.ts new file mode 100644 index 00000000..6d387b3c --- /dev/null +++ b/src/lib/winston.ts @@ -0,0 +1,33 @@ +import { Container, transports, format } from 'winston'; +import { logging } from '../config.js'; + +interface ConfigData{ + directory?: string + level: string +} +const logConfig: ConfigData = logging +// Load in the daily file transport +require('winston-daily-rotate-file'); + +// Create our default logging container +let container = new Container(); +let applicationTransports = []; + +// Create a console transport +let transportConsole = new transports.Console({ + level: logConfig.level, +}); + +applicationTransports.push(transportConsole); + +// Add a default application logger +container.add('application', { + format: format.combine(format.timestamp(), format.logstash()), + transports: applicationTransports, +}); +/** + * @name exports + * @static + * @summary Logging container for the application + */ +export default container diff --git a/src/main.js b/src/main.ts similarity index 75% rename from src/main.js rename to src/main.ts index b04c7e92..26a85e4e 100644 --- a/src/main.js +++ b/src/main.ts @@ -1,6 +1,6 @@ -const { initialize } = require('./server'); -const container = require('./lib/winston.js'); -const config = require('./config'); +import { initialize } from './server'; +import container from './lib/winston' +import config from './config'; const remsService = require('./hooks/rems.hook'); @@ -10,7 +10,7 @@ const remsService = require('./hooks/rems.hook'); * @summary Setup server and start the application * @function main */ -module.exports = async function main() { +export default async function main() { let logger = container.get('application'); // Build our server diff --git a/src/scripts/develop.js b/src/scripts/develop.ts similarity index 73% rename from src/scripts/develop.js rename to src/scripts/develop.ts index 0c51d7c3..85297a1d 100644 --- a/src/scripts/develop.js +++ b/src/scripts/develop.ts @@ -1,5 +1,5 @@ -const container = require('../lib/winston.js'); -const nodemon = require('nodemon'); +import container from '../lib/winston' +import nodemon from 'nodemon' let logger = container.get('application'); @@ -12,7 +12,7 @@ nodemon({ }); nodemon - .on('restart', files => logger.info(`Nodemon restarting because ${files.join(',')} changed.`)) + .on('restart', (files:File)=> logger.info(`Nodemon restarting because ${files.name} changed.`)) .on('crash', () => logger.info('Nodemon crashed. Waiting for changes to restart.')); // Make sure the process actually dies when hitting ctrl + c diff --git a/src/scripts/serve.js b/src/scripts/serve.js deleted file mode 100644 index baed1efe..00000000 --- a/src/scripts/serve.js +++ /dev/null @@ -1,3 +0,0 @@ -const main = require('../main.js'); - -main(); diff --git a/src/scripts/serve.ts b/src/scripts/serve.ts new file mode 100644 index 00000000..4d6a423a --- /dev/null +++ b/src/scripts/serve.ts @@ -0,0 +1,3 @@ +import main from '../main' + +main(); diff --git a/src/server.js b/src/server.ts similarity index 62% rename from src/server.js rename to src/server.ts index fa053253..457b812b 100644 --- a/src/server.js +++ b/src/server.ts @@ -1,13 +1,14 @@ -const express = require('express'); -const cors = require('cors'); -const bodyParser = require('body-parser'); -const container = require('./lib/winston.js'); -const morgan = require('morgan'); -const _ = require('lodash'); +import express from 'express' +import cors from 'cors' +import bodyParser from 'body-parser' +import container from './lib/winston' +import morgan from 'morgan' +import _ from 'lodash' +import Hook from './hooks/Hook' let logger = container.get('application'); -const initialize = config => { +const initialize = (config: any) => { const logLevel = _.get(config, 'logging.level'); return new REMSServer().configureLogstream(logLevel).configureMiddleware(); }; @@ -19,10 +20,13 @@ const initialize = config => { * @class Server */ class REMSServer { + app: any + services: Hook[] /** * @method constructor * @description Setup defaults for the server instance */ + constructor() { this.app = express(); this.services = []; @@ -37,7 +41,7 @@ class REMSServer { this.app.set('showStackError', true); this.app.set('jsonp callback', true); this.app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); - this.app.use(bodyParser.json({ limit: '50mb', extended: true })); + this.app.use(bodyParser.json({ limit: '50mb' })); this.app.use(cors()); this.app.options('*', cors()); @@ -49,19 +53,19 @@ class REMSServer { * @method configureLogstream * @description Enable streaming logs via morgan */ - configureLogstream({ log, level = 'info' } = {}) { + configureLogstream({ log, level = 'info' } : {log?:any, level?:string} = {}) { this.app.use( log ? log : morgan('combined', { - stream: { write: message => logger[level](message) } + stream: { write: (message) => logger.log(level, message)}, }) ); return this; } - registerService({ definition, handler }) { + registerService({ definition, handler } : {definition: any, handler: any}) { this.services.push(definition); this.app.post(`/cds-services/${definition.id}`, handler); @@ -78,13 +82,15 @@ class REMSServer { * @param {number} port - Defualt port to listen on * @param {function} [callback] - Optional callback for listen */ - listen({ port, discoveryEndpoint = '/cds-services' }, callback) { - this.app.get(discoveryEndpoint, (req, res) => res.json({ services: this.services })); - this.app.get('/', (req, res) => res.send('Welcome to the REMS Administrator')); - return this.app.listen(port, callback); + listen({ port, discoveryEndpoint = '/cds-services' }: any, callback: any) { + this.app.get(discoveryEndpoint, (req: any, res: { json: (arg0: { services: any }) => any }) => res.json({ services: this.services })); + this.app.get('/', (req: any, res: { send: (arg0: string) => any }) => res.send('Welcome to the REMS Administrator')); + this.app.listen(port, callback); + + return this; } } // Start the application -module.exports = { REMSServer, initialize }; +export { REMSServer, initialize }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..ab777eb9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es6", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "commonjs", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "dist", + "jsx": "react-jsx" + }, + "include": [ + "src" + ] + } + \ No newline at end of file From 9d95c4d71da24495ea07eb6d9ef22b43ebbc0e62 Mon Sep 17 00:00:00 2001 From: kghoreshi Date: Wed, 16 Nov 2022 17:27:20 -0500 Subject: [PATCH 2/4] return card --- src/cards/Card.ts | 81 +++++++++++---- src/cards/ErrorCard.ts | 6 -- src/config.js | 4 +- src/hooks/OrderSignRequest.ts | 1 - src/hooks/rems.hook.ts | 186 +++++++++++++++++++++++++++------- 5 files changed, 217 insertions(+), 61 deletions(-) delete mode 100644 src/cards/ErrorCard.ts diff --git a/src/cards/Card.ts b/src/cards/Card.ts index e9560b54..2e1ac834 100644 --- a/src/cards/Card.ts +++ b/src/cards/Card.ts @@ -1,32 +1,77 @@ +import { Resource } from "fhir/r4" + +interface Source { + label: string + url: URL + icon?: URL +} +interface Action { + type: string + description: string + resource?: Resource | string +} +interface Suggestion { + label: string + uuid?: string + actions: Action[] +} +export interface Link { + label: string + url: URL + type: linkType + appContext?: string +} +type linkType = "absolute" | "smart" +type indicatorType = "info" | "warning" | "critical" export default class Card { summary: string - detail: string - sourceLabel: string - sourceUrl: string - indicator: string + detail?: string + indicator: indicatorType + source: Source + suggestions?: Suggestion[] + selectionBehavior?: string + links?: Link[] - constructor(summary: string, detail: string, sourceLabel: string, sourceUrl: string, indicator: string = "info") { + constructor(summary: string, detail: string, source: Source, indicator: indicatorType = "info") { this.summary = summary this.detail = detail - this.sourceLabel = sourceLabel - this.sourceUrl = sourceUrl + this.source = source this.indicator = indicator } get card(){ - return this.generateCard() + return this + } + addSuggestions(suggestions: Suggestion[]){ + if(this.suggestions){ + this.suggestions = this.suggestions.concat(suggestions) + } else { + this.suggestions = suggestions + } + } + addSuggestion(suggestion: Suggestion) { + if(this.suggestions) { + this.suggestions?.push(suggestion) + } else { + this.suggestions = [suggestion] + } + } + addLinks(links: Link[]){ + if(this.links){ + this.links = this.links.concat(links) + } else { + this.links = links + } + } + addLink(link: Link){ + if(this.links) { + this.links?.push(link) + } else { + this.links = [link] + } } - generateCard(){ - return { - summary: this.summary, - detail: this.detail, - source: { - label: this.sourceLabel, - url: this.sourceUrl - }, - indicator: this.indicator - } + return this } } \ No newline at end of file diff --git a/src/cards/ErrorCard.ts b/src/cards/ErrorCard.ts deleted file mode 100644 index 456ba343..00000000 --- a/src/cards/ErrorCard.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Card from "./Card" -export default class ErrorCard extends Card{ - constructor(summary:string, detail:string, label:string, url:string, indicator:string = "error"){ - super(summary, detail, label, url, indicator) - } -} \ No newline at end of file diff --git a/src/config.js b/src/config.js index 560c91d1..f2ca5b13 100644 --- a/src/config.js +++ b/src/config.js @@ -3,7 +3,9 @@ module.exports = { port: 8090, discoveryEndpoint: '/cds-services' }, - + smart: { + endpoint: 'http://localhost:3005/launch' + }, logging: { level: 'info' } diff --git a/src/hooks/OrderSignRequest.ts b/src/hooks/OrderSignRequest.ts index 09e0a544..abfa1bf0 100644 --- a/src/hooks/OrderSignRequest.ts +++ b/src/hooks/OrderSignRequest.ts @@ -1,5 +1,4 @@ import { Bundle } from "fhir/r4"; -import { Resource } from "fhir/r4"; import { Url } from "url"; import OrderSignRequestPrefetch from "./Prefetch/OrderSignRequestPrefetch"; // https://cds-hooks.hl7.org/1.0/#fhir-resource-access diff --git a/src/hooks/rems.hook.ts b/src/hooks/rems.hook.ts index 4b17adc8..e940ba1f 100644 --- a/src/hooks/rems.hook.ts +++ b/src/hooks/rems.hook.ts @@ -1,8 +1,111 @@ import Card from '../cards/Card' -import ErrorCard from '../cards/ErrorCard' import OrderSign from './OrderSign'; import OrderSignRequest from './OrderSignRequest'; import OrderSignPrefetch from './Prefetch/OrderSignPrefetch'; +import {Coding} from 'fhir/r4' +import { Link } from '../cards/Card'; +import config from '../config' + +const CARD_DETAILS = "Documentation Required, please complete form via Smart App link." +// TODO: this codemap should be replaced with a system similar to original CRD's questionnaire package operation +// the app doesn't necessarily have to use CQL for this. +const codeMap: {[key:string]: Link[]} = { + "2183126": [ + { + "label": "Documentation Requirements", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Turalio_2020_08_04_REMS_Full.pdf") + }, + { + "label": "Medication Guide", + "type": "absolute", + "url": new URL("https://daiichisankyo.us/prescribing-information-portlet/getPIContent?productName=Turalio_Med&inline=true") + }, + { + "label": "Patient Guide", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Turalio_2020_12_16_Patient_Guide.pdf") + }, + { + "label": "Patient Status Update Form", + "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TuralioRemsPatientStatus", + "type": "smart", + "url": new URL(config.smart.endpoint) + }, + { + "label": "Patient Enrollment Form", + "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TuralioRemsPatientEnrollment", + "type": "smart", + "url": new URL(config.smart.endpoint) + } + ], + "6064": [ + { + "label": "Documentation Requirements", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_REMS_Document.pdf") + }, + { + "label": "Fact Sheet", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Fact_Sheet.pdf") + }, + { + "label": "Guide For Patients Who Can Get Pregnant", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Guide_for_Patients_Who_Can_Get_pregnant.pdf") + }, + { + "label": "Contraceptive Counseling Guide", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Contraception_Counseling_Guide.pdf") + }, + { + "label": "Patient Enrollment Form", + "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/IPledgeRemsPatientEnrollment", + "type": "smart", + "url": new URL(config.smart.endpoint) + } + ], + "1237051":[ + { + "label": "Documentation Requirements", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/TIRF_2022_08_17_REMS_Document.pdf") + }, + { + "label": "Patient Counseling Guide", + "type": "absolute", + "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/TIRF_2022_08_17_Patient_Counseling_Guide.pdf") + }, + { + "label": "Patient FAQ", + "type": "absolute", + "url": new URL("https://tirfstorageproduction.blob.core.windows.net/tirf-public/tirf-patientfaq-frequently-asked-questions.pdf?skoid=417a7522-f809-43c4-b6a8-6b192d44b69e&sktid=59fc620e-de8c-4745-abcc-18182d1bf20e&skt=2022-09-20T19%3A06%3A21Z&ske=2022-09-26T19%3A11%3A21Z&sks=b&skv=2020-04-08&sv=2020-04-08&st=2021-03-21T21%3A27%3A00Z&se=2031-03-21T23%3A59%3A59Z&sr=b&sp=rc&sig=owSGAoUBZuCtsLE41F2XC3o12x%2BG%2Bt5ogykOIt796es%3D") + }, + { + "label": "Patient Enrollment Form", + "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TIRFRemsPatientEnrollment", + "type": "smart", + "url": new URL(config.smart.endpoint) + } + ] +} +// TODO: No hardcoding of valid codes +const validCodes: Coding[] = [ + { + code: '2183126', // Turalio + system: 'http://www.nlm.nih.gov/research/umls/rxnorm' + }, + { + code: '1237051', // TIRF + system: 'http://www.nlm.nih.gov/research/umls/rxnorm' + }, + { + code: '6064', // iPledge + system: 'http://www.nlm.nih.gov/research/umls/rxnorm' + }, +] interface TypedRequestBody extends Express.Request { body: OrderSignRequest @@ -14,13 +117,13 @@ const prefetch: OrderSignPrefetch = { practitioner: 'Practitioner/{{context.userId}}' } const definition = new OrderSign('rems-order-sign', 'order-sign', 'REMS Requirement Lookup', 'REMS Requirement Lookup', prefetch) - -const sourceLabel = 'MCODE REMS Administrator Prototype'; -const sourceUrl = 'https://github.com/mcode/REMS'; - +const source = { + label: "MCODE REMS Administrator Prototype", + url: new URL("https://github.com/mcode/REMS") +} function buildErrorCard(reason: string) { - console.log(reason); - const errorCard = new ErrorCard("Bad Request", reason, sourceLabel, sourceUrl, "warning") + + const errorCard = new Card("Bad Request", reason, source, "warning") let cards = { cards: [ errorCard.card @@ -33,18 +136,17 @@ const handler = (req: TypedRequestBody, res: any) => { console.log("REMS order-sign hook") try { const context = req.body.context; - const contextRequest = context?.draftOrders?.entry?.[0].resource; + const contextRequest = context.draftOrders?.entry?.[0].resource; + console.log(contextRequest) const prefetch = req.body.prefetch; - const patient = prefetch.patient; - const prefetchRequest = prefetch.request; - const practitioner = prefetch.practitioner; - const npi = prefetch.practitioner.identifier + const patient = prefetch?.patient; + const prefetchRequest = prefetch?.request; + const practitioner = prefetch?.practitioner; + const npi = practitioner?.identifier - const apo = practitioner?.identifier?.[0].value // null checking using ? operator - - console.log(' MedicationRequest: ' + prefetchRequest.id); - console.log(' Practitioner: ' + practitioner.id + ' NPI: ' + npi); - console.log(' Patient: ' + patient.id); + console.log(" MedicationRequest: " + prefetchRequest?.id); + console.log(" Practitioner: " + practitioner?.id + " NPI: " + npi); + console.log(" Patient: " + patient?.id); // verify a MedicationRequest was sent if (contextRequest && contextRequest.resourceType !== "MedicationRequest") { @@ -53,35 +155,49 @@ const handler = (req: TypedRequestBody, res: any) => { } // verify ids - if (patient.id && patient.id.replace('Patient/','') !== context.patientId.replace('Patient/','')) { + if (patient?.id && patient.id.replace('Patient/','') !== context.patientId.replace('Patient/','')) { res.json(buildErrorCard("Context patientId does not match prefetch Patient ID")); return; } - if (practitioner.id && practitioner.id.replace('Practitioner/','') !== context.userId.replace('Practitioner/','')) { + if (practitioner?.id && practitioner.id.replace('Practitioner/','') !== context.userId.replace('Practitioner/','')) { res.json(buildErrorCard("Context userId does not match prefetch Practitioner ID")); return; } - if ((prefetchRequest.id && contextRequest && contextRequest.id) && prefetchRequest.id.replace('MedicationRequest/','') !== contextRequest.id.replace('MedicationRequest/','')) { + if ((prefetchRequest?.id && contextRequest && contextRequest.id) && prefetchRequest.id.replace('MedicationRequest/','') !== contextRequest.id.replace('MedicationRequest/','')) { res.json(buildErrorCard("Context draftOrder does not match prefetch MedicationRequest ID")); return; } - const text = `REMS required for Patient ${patient.id}`; + const medicationCode = contextRequest?.medicationCodeableConcept?.coding?.[0] + if(medicationCode && medicationCode.code){ + const returnCard = validCodes.some((e)=> { + return e.code === medicationCode.code && e.system === medicationCode.system + }) + if(returnCard){ + const card = new Card(medicationCode.display || "Rems", CARD_DETAILS, source, 'info') + const links = codeMap[medicationCode.code] + links.forEach((e) => { + if(e.type == 'absolute'){ // no construction needed + card.addLink(e) + } else { + // link is SMART + // TODO: smart links should be built with discovered questionnaires, not hard coded ones + e.appContext = `${e.appContext}&order=${JSON.stringify(contextRequest)}&coverage=${contextRequest.insurance?.[0].reference}` + card.addLink(e) + } + }) + res.json({ + cards:[ + card + ] + }) + } else { + res.json(buildErrorCard("Unsupported code")); + } + } else { + res.json(buildErrorCard("MedicationRequest does not contain a code")); + } - let cards = { - cards: [ - { - summary: `Summary: ${text}`, - detail: `Detail: ${text}`, - source: { - label: sourceLabel, - url: sourceUrl - }, - indicator: 'info' - } - ] - }; - res.json(cards); } catch (error) { console.log(error); res.json(buildErrorCard('Unknown Error')); From e785ea3f621850e55a9821886f4cd0b9b829f800 Mon Sep 17 00:00:00 2001 From: kghoreshi Date: Thu, 17 Nov 2022 15:48:22 -0500 Subject: [PATCH 3/4] add example hook --- .gitignore | 3 +- src/resources/ExampleHook.json | 261 +++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/resources/ExampleHook.json diff --git a/.gitignore b/.gitignore index 0f267621..a56b10b3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ package-lock.json COVERAGE/ logs/ node_modules/ -dist/ \ No newline at end of file +dist/ +.idea/ \ No newline at end of file diff --git a/src/resources/ExampleHook.json b/src/resources/ExampleHook.json new file mode 100644 index 00000000..94f37f68 --- /dev/null +++ b/src/resources/ExampleHook.json @@ -0,0 +1,261 @@ +{ + "hookInstance": "d1577c69-dfbe-44ad-ba6d-3e05e953b2ea", + "hook": "order-sign", + "fhirServer": "http://localhost:8080/test-ehr/r4", + "fhirAuthorization": { + "access_token": "", + "token_type": "Bearer", + "expires_in": 300, + "scope": "patient/Patient.read patient/Observation.read", + "subject": "cds-service4" + }, + "context": { + "userId": "Practitioner/pra-sstrange", + "patientId": "pat017", + "encounterId": "enc89284", + "draftOrders": { + "resourceType": "Bundle", + "entry": [ + { + "resource": + { + "resourceType": "MedicationRequest", + "id": "pat017-mr-IPledge", + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "6064", + "display": "Isotretinoin 200 MG Oral Capsule" + } + ] + }, + "status": "active", + "intent": "order", + "subject": { + "reference": "Patient/pat017", + "display": "Jon Snow" + }, + "authoredOn": "2020-07-11", + "requester": { + "reference": "Practitioner/pra-sstrange", + "display": "Jane Doe" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "52042003", + "display": "Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)" + } + ] + } + ], + "insurance": [ + { + "reference": "Coverage/cov017" + } + ], + "dosageInstruction": [ + { + "sequence": 1, + "text": "200mg twice daily", + "timing": { + "repeat": { + "frequency": 2, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral route (qualifier value)" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 200, + "unit": "mg", + "system": "http://unitsofmeasure.org", + "code": "mg" + } + } + ] + } + ], + "dispenseRequest": { + "quantity": { + "value": 90, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "CAP" + }, + "numberOfRepeatsAllowed": 3 + } + } + } + ] + } + }, + "prefetch": { + "patient": { + "resourceType": "Patient", + "id": "pat017", + "gender": "male", + "birthDate": "1996-06-01", + "address": [ + { + "use": "home", + "type": "both", + "state": "Westeros", + "city": "Winterfell", + "postalCode": "00008", + "line": [ + "1 Winterfell Rd" + ] + } + ], + "name": [ + { + "use": "official", + "family": "Snow", + "given": [ + "Jon", + "Stark" + ] + } + ], + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-medicare", + "value": "0V843229061TB" + } + ] + }, + "request": { + "resourceType": "MedicationRequest", + "id": "pat017-mr-IPledge", + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "6064", + "display": "Isotretinoin 200 MG Oral Capsule" + } + ] + }, + "status": "active", + "intent": "order", + "subject": { + "reference": "Patient/pat017", + "display": "Jon Snow" + }, + "authoredOn": "2020-07-11", + "requester": { + "reference": "Practitioner/pra-sstrange", + "display": "Jane Doe" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "52042003", + "display": "Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)" + } + ] + } + ], + "insurance": [ + { + "reference": "Coverage/cov017" + } + ], + "dosageInstruction": [ + { + "sequence": 1, + "text": "200mg twice daily", + "timing": { + "repeat": { + "frequency": 2, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral route (qualifier value)" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 200, + "unit": "mg", + "system": "http://unitsofmeasure.org", + "code": "mg" + } + } + ] + } + ], + "dispenseRequest": { + "quantity": { + "value": 90, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "CAP" + }, + "numberOfRepeatsAllowed": 3 + } + }, + "practitioner": { + "resourceType": "Practitioner", + "id": "pra-sstrange", + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1122334466" + } + ], + "name": [ + { + "use": "official", + "family": "Strange", + "given": [ + "Stephen" + ], + "prefix": [ + "Dr." + ] + } + ] + } + } +} \ No newline at end of file From 9fbaa4c214fb3be24ebbfba450050320ce65206a Mon Sep 17 00:00:00 2001 From: kghoreshi Date: Mon, 5 Dec 2022 16:24:24 -0500 Subject: [PATCH 4/4] linting and test typescript conversion --- .eslintrc | 11 +- package.json | 15 +- src/cards/Card.ts | 121 ++++---- src/{config.js => config.ts} | 2 +- src/hooks/Hook.ts | 16 +- src/hooks/OrderSign.ts | 36 ++- src/hooks/OrderSignRequest.ts | 38 +-- src/hooks/Prefetch/OrderSignPrefetch.ts | 10 +- .../Prefetch/OrderSignRequestPrefetch.ts | 12 +- src/hooks/Prefetch/RequestPrefetch.ts | 6 +- src/hooks/Prefetch/ServicePrefetch.ts | 6 +- src/hooks/rems.hook.test.js | 23 -- src/hooks/rems.hook.test.ts | 25 ++ src/hooks/rems.hook.ts | 270 ++++++++++-------- src/lib/winston.test.js | 43 --- src/lib/winston.test.ts | 46 +++ src/lib/winston.ts | 42 +-- src/main.ts | 9 +- src/scripts/develop.ts | 8 +- src/scripts/serve.ts | 2 +- src/{server.test.js => server.test.ts} | 32 +-- src/server.ts | 38 +-- 22 files changed, 443 insertions(+), 368 deletions(-) rename src/{config.js => config.ts} (90%) delete mode 100644 src/hooks/rems.hook.test.js create mode 100644 src/hooks/rems.hook.test.ts delete mode 100644 src/lib/winston.test.js create mode 100644 src/lib/winston.test.ts rename src/{server.test.js => server.test.ts} (76%) diff --git a/.eslintrc b/.eslintrc index e63c9dd8..bbb67807 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,13 +1,20 @@ { "extends": [ - "prettier" + "prettier", + "eslint:recommended", + "plugin:@typescript-eslint/recommended" ], + "plugins": [ + "@typescript-eslint" + ], + "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2018, "sourceType": "module" }, "rules": { - "semi": ["error", "always"], + "semi": ["off"], + "@typescript-eslint/semi": ["error", "always"], "quotes": ["error", "single", { "avoidEscape": true }], } diff --git a/package.json b/package.json index 152d72d8..17d2fc7e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,12 @@ "url": "git+ssh://git@bitbucket.org/asymmetrik/carejourney-cds.git" }, "jest": { + "preset":"ts-jest", + "testEnvironment": "node", + "transform": { + "^.+\\.ts?$": "ts-jest" + }, + "transformIgnorePatterns": ["/node_modules/"], "testPathIgnorePatterns": [ "/src/config/env/test.js" ], @@ -55,20 +61,23 @@ "devDependencies": { "@types/cors": "^2.8.12", "@types/express": "^4.17.14", + "@types/jest": "^27.5.2", "@types/lodash": "^4.14.188", "@types/morgan": "^1.9.3", "@types/node": "^18.11.9", "@types/nodemon": "^1.19.2", - "eslint": "^8.5.0", + "@typescript-eslint/eslint-plugin": "^5.45.0", + "@typescript-eslint/parser": "^5.45.0", + "eslint": "^8.28.0", "eslint-config-prettier": "^6.10.1", "jest": "^27.4.5", "jest-extended": "^1.2.0", "json-diff": "^0.9.0", "nodemon": "^2.0.20", "prettier": "^2.0.5", - "ts-node": "^10.9.1", "ts-jest": "^27.1.2", + "ts-node": "^10.9.1", "ts-node-dev": "^2.0.0", - "typescript": "^4.8.4" + "typescript": "^4.9.3" } } diff --git a/src/cards/Card.ts b/src/cards/Card.ts index 2e1ac834..c79b9103 100644 --- a/src/cards/Card.ts +++ b/src/cards/Card.ts @@ -1,77 +1,76 @@ -import { Resource } from "fhir/r4" +import { Resource } from 'fhir/r4'; interface Source { - label: string - url: URL - icon?: URL + label: string; + url: URL; + icon?: URL; } interface Action { - type: string - description: string - resource?: Resource | string + type: string; + description: string; + resource?: Resource | string; } interface Suggestion { - label: string - uuid?: string - actions: Action[] + label: string; + uuid?: string; + actions: Action[]; } export interface Link { - label: string - url: URL - type: linkType - appContext?: string + label: string; + url: URL; + type: linkType; + appContext?: string; } -type linkType = "absolute" | "smart" -type indicatorType = "info" | "warning" | "critical" +type linkType = 'absolute' | 'smart'; +type indicatorType = 'info' | 'warning' | 'critical'; export default class Card { + summary: string; + detail?: string; + indicator: indicatorType; + source: Source; + suggestions?: Suggestion[]; + selectionBehavior?: string; + links?: Link[]; - summary: string - detail?: string - indicator: indicatorType - source: Source - suggestions?: Suggestion[] - selectionBehavior?: string - links?: Link[] + constructor(summary: string, detail: string, source: Source, indicator: indicatorType = 'info') { + this.summary = summary; + this.detail = detail; + this.source = source; + this.indicator = indicator; + } - constructor(summary: string, detail: string, source: Source, indicator: indicatorType = "info") { - this.summary = summary - this.detail = detail - this.source = source - this.indicator = indicator + get card() { + return this; + } + addSuggestions(suggestions: Suggestion[]) { + if (this.suggestions) { + this.suggestions = this.suggestions.concat(suggestions); + } else { + this.suggestions = suggestions; } - - get card(){ - return this - } - addSuggestions(suggestions: Suggestion[]){ - if(this.suggestions){ - this.suggestions = this.suggestions.concat(suggestions) - } else { - this.suggestions = suggestions - } - } - addSuggestion(suggestion: Suggestion) { - if(this.suggestions) { - this.suggestions?.push(suggestion) - } else { - this.suggestions = [suggestion] - } + } + addSuggestion(suggestion: Suggestion) { + if (this.suggestions) { + this.suggestions?.push(suggestion); + } else { + this.suggestions = [suggestion]; } - addLinks(links: Link[]){ - if(this.links){ - this.links = this.links.concat(links) - } else { - this.links = links - } + } + addLinks(links: Link[]) { + if (this.links) { + this.links = this.links.concat(links); + } else { + this.links = links; } - addLink(link: Link){ - if(this.links) { - this.links?.push(link) - } else { - this.links = [link] - } + } + addLink(link: Link) { + if (this.links) { + this.links?.push(link); + } else { + this.links = [link]; } - generateCard(){ - return this - } -} \ No newline at end of file + } + generateCard() { + return this; + } +} diff --git a/src/config.js b/src/config.ts similarity index 90% rename from src/config.js rename to src/config.ts index f2ca5b13..d086c9ac 100644 --- a/src/config.js +++ b/src/config.ts @@ -1,4 +1,4 @@ -module.exports = { +export default { server: { port: 8090, discoveryEndpoint: '/cds-services' diff --git a/src/hooks/Hook.ts b/src/hooks/Hook.ts index 86b0bcb0..a50007a0 100644 --- a/src/hooks/Hook.ts +++ b/src/hooks/Hook.ts @@ -1,9 +1,9 @@ -import ServicePrefetch from "./Prefetch/ServicePrefetch"; +import ServicePrefetch from './Prefetch/ServicePrefetch'; -export default interface Hook{ - id:string - hook: string - name: string - description: string - prefetch: ServicePrefetch -} \ No newline at end of file +export default interface Hook { + id: string; + hook: string; + name: string; + description: string; + prefetch: ServicePrefetch; +} diff --git a/src/hooks/OrderSign.ts b/src/hooks/OrderSign.ts index d0d21c2f..7ac9bb0c 100644 --- a/src/hooks/OrderSign.ts +++ b/src/hooks/OrderSign.ts @@ -1,17 +1,23 @@ -import Hook from "./Hook"; -import OrderSignPrefetch from "./Prefetch/OrderSignPrefetch"; +import Hook from './Hook'; +import OrderSignPrefetch from './Prefetch/OrderSignPrefetch'; -export default class OrderSign implements Hook{ - id:string - hook: string - name: string - description: string +export default class OrderSign implements Hook { + id: string; + hook: string; + name: string; + description: string; + prefetch: OrderSignPrefetch; + constructor( + id: string, + hook: string, + name: string, + description: string, prefetch: OrderSignPrefetch - constructor(id:string, hook:string, name:string, description: string, prefetch: OrderSignPrefetch){ - this.id = id - this.hook = hook - this.name = name - this.description = description - this.prefetch = prefetch - } -} \ No newline at end of file + ) { + this.id = id; + this.hook = hook; + this.name = name; + this.description = description; + this.prefetch = prefetch; + } +} diff --git a/src/hooks/OrderSignRequest.ts b/src/hooks/OrderSignRequest.ts index abfa1bf0..1a69b90a 100644 --- a/src/hooks/OrderSignRequest.ts +++ b/src/hooks/OrderSignRequest.ts @@ -1,26 +1,26 @@ -import { Bundle } from "fhir/r4"; -import { Url } from "url"; -import OrderSignRequestPrefetch from "./Prefetch/OrderSignRequestPrefetch"; +import { Bundle } from 'fhir/r4'; +import { Url } from 'url'; +import OrderSignRequestPrefetch from './Prefetch/OrderSignRequestPrefetch'; // https://cds-hooks.hl7.org/1.0/#fhir-resource-access interface FhirAuthorization { - token_type: string - expires_in: number - scope: string - subject: string + token_type: string; + expires_in: number; + scope: string; + subject: string; } // https://cds-hooks.org/hooks/order-sign/#context interface OrderSignContext { - userId: string - patientId: string - encounterId?: string - draftOrders: Bundle + userId: string; + patientId: string; + encounterId?: string; + draftOrders: Bundle; } // https://cds-hooks.hl7.org/1.0/#calling-a-cds-service -export default interface OrderSignRequest{ - hook: string - hookInstance: string - fhirServer: Url - fhirAuthorization: FhirAuthorization - context: OrderSignContext - prefetch: OrderSignRequestPrefetch -} \ No newline at end of file +export default interface OrderSignRequest { + hook: string; + hookInstance: string; + fhirServer: Url; + fhirAuthorization: FhirAuthorization; + context: OrderSignContext; + prefetch: OrderSignRequestPrefetch; +} diff --git a/src/hooks/Prefetch/OrderSignPrefetch.ts b/src/hooks/Prefetch/OrderSignPrefetch.ts index dc631ae7..08d7a453 100644 --- a/src/hooks/Prefetch/OrderSignPrefetch.ts +++ b/src/hooks/Prefetch/OrderSignPrefetch.ts @@ -1,5 +1,5 @@ -export default interface OrderSignPrefetch{ - patient?: string - request?: string - practitioner?: string -} \ No newline at end of file +export default interface OrderSignPrefetch { + patient?: string; + request?: string; + practitioner?: string; +} diff --git a/src/hooks/Prefetch/OrderSignRequestPrefetch.ts b/src/hooks/Prefetch/OrderSignRequestPrefetch.ts index 63b9b4dd..39b37738 100644 --- a/src/hooks/Prefetch/OrderSignRequestPrefetch.ts +++ b/src/hooks/Prefetch/OrderSignRequestPrefetch.ts @@ -1,7 +1,7 @@ -import { MedicationRequest, Patient, Practitioner } from "fhir/r4"; -import RequestPrefetch from "./RequestPrefetch"; +import { MedicationRequest, Patient, Practitioner } from 'fhir/r4'; +import RequestPrefetch from './RequestPrefetch'; export default interface OrderSignRequestPrefetch extends RequestPrefetch { - patient: Patient - request: MedicationRequest - practitioner: Practitioner -} \ No newline at end of file + patient: Patient; + request: MedicationRequest; + practitioner: Practitioner; +} diff --git a/src/hooks/Prefetch/RequestPrefetch.ts b/src/hooks/Prefetch/RequestPrefetch.ts index 3f8f9754..efd8714d 100644 --- a/src/hooks/Prefetch/RequestPrefetch.ts +++ b/src/hooks/Prefetch/RequestPrefetch.ts @@ -1,5 +1,5 @@ -import { Resource } from "fhir/r4"; +import { Resource } from 'fhir/r4'; export default interface RequestPrefetch { - [key: string]: Resource -} \ No newline at end of file + [key: string]: Resource; +} diff --git a/src/hooks/Prefetch/ServicePrefetch.ts b/src/hooks/Prefetch/ServicePrefetch.ts index 17248dab..bc780f65 100644 --- a/src/hooks/Prefetch/ServicePrefetch.ts +++ b/src/hooks/Prefetch/ServicePrefetch.ts @@ -1,3 +1,3 @@ -export default interface ServicePrefetch{ - patient?: string -} \ No newline at end of file +export default interface ServicePrefetch { + patient?: string; +} diff --git a/src/hooks/rems.hook.test.js b/src/hooks/rems.hook.test.js deleted file mode 100644 index 6118871c..00000000 --- a/src/hooks/rems.hook.test.js +++ /dev/null @@ -1,23 +0,0 @@ -const getREMSHook = require('./rems.hook'); - -describe('hook: test rems', () => { - test('should have definition and handler', () => { - const expectedDefinition = { - hook: 'order-sign', - name: 'REMS Requirement Lookup', - description: 'REMS Requirement Lookup', - id: 'rems-order-sign', - prefetch: { - patient: 'Patient/{{context.patientId}}', - request: 'MedicationRequest?_id={{context.draftOrders.MedicationRequest.id}}', - practitioner: 'Practitioner/{{context.userId}}' - } - }; - expect(getREMSHook).toHaveProperty('definition'); - expect(getREMSHook).toHaveProperty('handler'); - - expect(getREMSHook.definition).toStrictEqual(expectedDefinition); - expect(getREMSHook.handler).toBeInstanceOf(Function); - }); - test('should test the logic of handler', () => {}); -}); diff --git a/src/hooks/rems.hook.test.ts b/src/hooks/rems.hook.test.ts new file mode 100644 index 00000000..fa75f45b --- /dev/null +++ b/src/hooks/rems.hook.test.ts @@ -0,0 +1,25 @@ +import OrderSign from './OrderSign'; +import getREMSHook from './rems.hook'; + +describe('hook: test rems', () => { + test('should have definition and handler', () => { + const prefetch = { + patient: 'Patient/{{context.patientId}}', + request: 'MedicationRequest?_id={{context.draftOrders.MedicationRequest.id}}', + practitioner: 'Practitioner/{{context.userId}}' + }; + const expectedDefinition = new OrderSign( + 'rems-order-sign', + 'order-sign', + 'REMS Requirement Lookup', + 'REMS Requirement Lookup', + prefetch + ); + + expect(getREMSHook).toHaveProperty('definition'); + expect(getREMSHook).toHaveProperty('handler'); + + expect(getREMSHook.definition).toStrictEqual(expectedDefinition); + expect(getREMSHook.handler).toBeInstanceOf(Function); + }); +}); diff --git a/src/hooks/rems.hook.ts b/src/hooks/rems.hook.ts index e940ba1f..6949e2bb 100644 --- a/src/hooks/rems.hook.ts +++ b/src/hooks/rems.hook.ts @@ -1,96 +1,120 @@ -import Card from '../cards/Card' +import Card from '../cards/Card'; import OrderSign from './OrderSign'; import OrderSignRequest from './OrderSignRequest'; import OrderSignPrefetch from './Prefetch/OrderSignPrefetch'; -import {Coding} from 'fhir/r4' +import { Coding } from 'fhir/r4'; import { Link } from '../cards/Card'; -import config from '../config' +import config from '../config'; -const CARD_DETAILS = "Documentation Required, please complete form via Smart App link." +const CARD_DETAILS = 'Documentation Required, please complete form via Smart App link.'; // TODO: this codemap should be replaced with a system similar to original CRD's questionnaire package operation // the app doesn't necessarily have to use CQL for this. -const codeMap: {[key:string]: Link[]} = { - "2183126": [ - { - "label": "Documentation Requirements", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Turalio_2020_08_04_REMS_Full.pdf") - }, - { - "label": "Medication Guide", - "type": "absolute", - "url": new URL("https://daiichisankyo.us/prescribing-information-portlet/getPIContent?productName=Turalio_Med&inline=true") - }, - { - "label": "Patient Guide", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Turalio_2020_12_16_Patient_Guide.pdf") - }, - { - "label": "Patient Status Update Form", - "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TuralioRemsPatientStatus", - "type": "smart", - "url": new URL(config.smart.endpoint) - }, - { - "label": "Patient Enrollment Form", - "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TuralioRemsPatientEnrollment", - "type": "smart", - "url": new URL(config.smart.endpoint) - } +const codeMap: { [key: string]: Link[] } = { + '2183126': [ + { + label: 'Documentation Requirements', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/Turalio_2020_08_04_REMS_Full.pdf' + ) + }, + { + label: 'Medication Guide', + type: 'absolute', + url: new URL( + 'https://daiichisankyo.us/prescribing-information-portlet/getPIContent?productName=Turalio_Med&inline=true' + ) + }, + { + label: 'Patient Guide', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/Turalio_2020_12_16_Patient_Guide.pdf' + ) + }, + { + label: 'Patient Status Update Form', + appContext: + 'questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TuralioRemsPatientStatus', + type: 'smart', + url: new URL(config.smart.endpoint) + }, + { + label: 'Patient Enrollment Form', + appContext: + 'questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TuralioRemsPatientEnrollment', + type: 'smart', + url: new URL(config.smart.endpoint) + } ], - "6064": [ + '6064': [ { - "label": "Documentation Requirements", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_REMS_Document.pdf") + label: 'Documentation Requirements', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_REMS_Document.pdf' + ) }, { - "label": "Fact Sheet", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Fact_Sheet.pdf") + label: 'Fact Sheet', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Fact_Sheet.pdf' + ) }, { - "label": "Guide For Patients Who Can Get Pregnant", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Guide_for_Patients_Who_Can_Get_pregnant.pdf") + label: 'Guide For Patients Who Can Get Pregnant', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Guide_for_Patients_Who_Can_Get_pregnant.pdf' + ) }, { - "label": "Contraceptive Counseling Guide", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Contraception_Counseling_Guide.pdf") + label: 'Contraceptive Counseling Guide', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/Isotretinoin_2021_10_8_Contraception_Counseling_Guide.pdf' + ) }, { - "label": "Patient Enrollment Form", - "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/IPledgeRemsPatientEnrollment", - "type": "smart", - "url": new URL(config.smart.endpoint) + label: 'Patient Enrollment Form', + appContext: + 'questionnaire=http://localhost:8090/fhir/r4/Questionnaire/IPledgeRemsPatientEnrollment', + type: 'smart', + url: new URL(config.smart.endpoint) } ], - "1237051":[ + '1237051': [ { - "label": "Documentation Requirements", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/TIRF_2022_08_17_REMS_Document.pdf") + label: 'Documentation Requirements', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/TIRF_2022_08_17_REMS_Document.pdf' + ) }, { - "label": "Patient Counseling Guide", - "type": "absolute", - "url": new URL("https://www.accessdata.fda.gov/drugsatfda_docs/rems/TIRF_2022_08_17_Patient_Counseling_Guide.pdf") + label: 'Patient Counseling Guide', + type: 'absolute', + url: new URL( + 'https://www.accessdata.fda.gov/drugsatfda_docs/rems/TIRF_2022_08_17_Patient_Counseling_Guide.pdf' + ) }, { - "label": "Patient FAQ", - "type": "absolute", - "url": new URL("https://tirfstorageproduction.blob.core.windows.net/tirf-public/tirf-patientfaq-frequently-asked-questions.pdf?skoid=417a7522-f809-43c4-b6a8-6b192d44b69e&sktid=59fc620e-de8c-4745-abcc-18182d1bf20e&skt=2022-09-20T19%3A06%3A21Z&ske=2022-09-26T19%3A11%3A21Z&sks=b&skv=2020-04-08&sv=2020-04-08&st=2021-03-21T21%3A27%3A00Z&se=2031-03-21T23%3A59%3A59Z&sr=b&sp=rc&sig=owSGAoUBZuCtsLE41F2XC3o12x%2BG%2Bt5ogykOIt796es%3D") + label: 'Patient FAQ', + type: 'absolute', + url: new URL( + 'https://tirfstorageproduction.blob.core.windows.net/tirf-public/tirf-patientfaq-frequently-asked-questions.pdf?skoid=417a7522-f809-43c4-b6a8-6b192d44b69e&sktid=59fc620e-de8c-4745-abcc-18182d1bf20e&skt=2022-09-20T19%3A06%3A21Z&ske=2022-09-26T19%3A11%3A21Z&sks=b&skv=2020-04-08&sv=2020-04-08&st=2021-03-21T21%3A27%3A00Z&se=2031-03-21T23%3A59%3A59Z&sr=b&sp=rc&sig=owSGAoUBZuCtsLE41F2XC3o12x%2BG%2Bt5ogykOIt796es%3D' + ) }, { - "label": "Patient Enrollment Form", - "appContext": "questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TIRFRemsPatientEnrollment", - "type": "smart", - "url": new URL(config.smart.endpoint) + label: 'Patient Enrollment Form', + appContext: + 'questionnaire=http://localhost:8090/fhir/r4/Questionnaire/TIRFRemsPatientEnrollment', + type: 'smart', + url: new URL(config.smart.endpoint) } ] -} +}; // TODO: No hardcoding of valid codes const validCodes: Coding[] = [ { @@ -104,104 +128,118 @@ const validCodes: Coding[] = [ { code: '6064', // iPledge system: 'http://www.nlm.nih.gov/research/umls/rxnorm' - }, -] + } +]; interface TypedRequestBody extends Express.Request { - body: OrderSignRequest + body: OrderSignRequest; } const prefetch: OrderSignPrefetch = { patient: 'Patient/{{context.patientId}}', - request:'MedicationRequest?_id={{context.draftOrders.MedicationRequest.id}}', + request: 'MedicationRequest?_id={{context.draftOrders.MedicationRequest.id}}', practitioner: 'Practitioner/{{context.userId}}' -} -const definition = new OrderSign('rems-order-sign', 'order-sign', 'REMS Requirement Lookup', 'REMS Requirement Lookup', prefetch) +}; +const definition = new OrderSign( + 'rems-order-sign', + 'order-sign', + 'REMS Requirement Lookup', + 'REMS Requirement Lookup', + prefetch +); const source = { - label: "MCODE REMS Administrator Prototype", - url: new URL("https://github.com/mcode/REMS") -} + label: 'MCODE REMS Administrator Prototype', + url: new URL('https://github.com/mcode/REMS') +}; function buildErrorCard(reason: string) { - - const errorCard = new Card("Bad Request", reason, source, "warning") - let cards = { - cards: [ - errorCard.card - ], + const errorCard = new Card('Bad Request', reason, source, 'warning'); + const cards = { + cards: [errorCard.card] }; return cards; } const handler = (req: TypedRequestBody, res: any) => { - console.log("REMS order-sign hook") + console.log('REMS order-sign hook'); try { const context = req.body.context; const contextRequest = context.draftOrders?.entry?.[0].resource; - console.log(contextRequest) const prefetch = req.body.prefetch; const patient = prefetch?.patient; const prefetchRequest = prefetch?.request; const practitioner = prefetch?.practitioner; - const npi = practitioner?.identifier + const npi = practitioner?.identifier; - console.log(" MedicationRequest: " + prefetchRequest?.id); - console.log(" Practitioner: " + practitioner?.id + " NPI: " + npi); - console.log(" Patient: " + patient?.id); + console.log(' MedicationRequest: ' + prefetchRequest?.id); + console.log(' Practitioner: ' + practitioner?.id + ' NPI: ' + npi); + console.log(' Patient: ' + patient?.id); // verify a MedicationRequest was sent - if (contextRequest && contextRequest.resourceType !== "MedicationRequest") { - res.json(buildErrorCard("DraftOrders does not contain a MedicationRequest")); + if (contextRequest && contextRequest.resourceType !== 'MedicationRequest') { + res.json(buildErrorCard('DraftOrders does not contain a MedicationRequest')); return; } // verify ids - if (patient?.id && patient.id.replace('Patient/','') !== context.patientId.replace('Patient/','')) { - res.json(buildErrorCard("Context patientId does not match prefetch Patient ID")); + if ( + patient?.id && + patient.id.replace('Patient/', '') !== context.patientId.replace('Patient/', '') + ) { + res.json(buildErrorCard('Context patientId does not match prefetch Patient ID')); return; } - if (practitioner?.id && practitioner.id.replace('Practitioner/','') !== context.userId.replace('Practitioner/','')) { - res.json(buildErrorCard("Context userId does not match prefetch Practitioner ID")); + if ( + practitioner?.id && + practitioner.id.replace('Practitioner/', '') !== context.userId.replace('Practitioner/', '') + ) { + res.json(buildErrorCard('Context userId does not match prefetch Practitioner ID')); return; } - if ((prefetchRequest?.id && contextRequest && contextRequest.id) && prefetchRequest.id.replace('MedicationRequest/','') !== contextRequest.id.replace('MedicationRequest/','')) { - res.json(buildErrorCard("Context draftOrder does not match prefetch MedicationRequest ID")); + if ( + prefetchRequest?.id && + contextRequest && + contextRequest.id && + prefetchRequest.id.replace('MedicationRequest/', '') !== + contextRequest.id.replace('MedicationRequest/', '') + ) { + res.json(buildErrorCard('Context draftOrder does not match prefetch MedicationRequest ID')); return; } - const medicationCode = contextRequest?.medicationCodeableConcept?.coding?.[0] - if(medicationCode && medicationCode.code){ - const returnCard = validCodes.some((e)=> { - return e.code === medicationCode.code && e.system === medicationCode.system - }) - if(returnCard){ - const card = new Card(medicationCode.display || "Rems", CARD_DETAILS, source, 'info') - const links = codeMap[medicationCode.code] - links.forEach((e) => { - if(e.type == 'absolute'){ // no construction needed - card.addLink(e) + const medicationCode = contextRequest?.medicationCodeableConcept?.coding?.[0]; + if (medicationCode && medicationCode.code) { + const returnCard = validCodes.some(e => { + return e.code === medicationCode.code && e.system === medicationCode.system; + }); + if (returnCard) { + const card = new Card(medicationCode.display || 'Rems', CARD_DETAILS, source, 'info'); + const links = codeMap[medicationCode.code]; + links.forEach(e => { + if (e.type == 'absolute') { + // no construction needed + card.addLink(e); } else { // link is SMART // TODO: smart links should be built with discovered questionnaires, not hard coded ones - e.appContext = `${e.appContext}&order=${JSON.stringify(contextRequest)}&coverage=${contextRequest.insurance?.[0].reference}` - card.addLink(e) + e.appContext = `${e.appContext}&order=${JSON.stringify(contextRequest)}&coverage=${ + contextRequest.insurance?.[0].reference + }`; + card.addLink(e); } - }) + }); res.json({ - cards:[ - card - ] - }) + cards: [card] + }); } else { - res.json(buildErrorCard("Unsupported code")); + res.json(buildErrorCard('Unsupported code')); } } else { - res.json(buildErrorCard("MedicationRequest does not contain a code")); + res.json(buildErrorCard('MedicationRequest does not contain a code')); } - } catch (error) { console.log(error); res.json(buildErrorCard('Unknown Error')); } }; -export { definition, handler }; +export default { definition, handler }; diff --git a/src/lib/winston.test.js b/src/lib/winston.test.js deleted file mode 100644 index ebd4522f..00000000 --- a/src/lib/winston.test.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @NOTE: Reset modules in between tests so we can test the application of an - * environment based configuration that changes between runs. We need this - * for full coverage - */ -describe('Logger Class', () => { - beforeEach(() => { - jest.resetModules(); - }); - - test('setup without daily log file', () => { - const config = require('../config'); - const container = require('./winston'); - let logger = container.get('application'); - - expect(logger).toBeDefined(); - expect(logger.transports).toHaveLength(1); - expect(logger.transports[0].name).toBe('console'); - expect(logger.transports[0].level).toBe(config.logging.level); - }); - - test('setup with daily log file generation', () => { - // Mock the config to test other branch of if statement - jest.mock('../config', () => ({ - logging: { - level: 'debug', - directory: 'logs' - } - })); - - const config = require('../config'); - const container = require('./winston'); - let logger = container.get('application'); - - expect(logger).toBeDefined(); - expect(logger.transports).toHaveLength(2); - expect(logger.transports[0].name).toBe('console'); - expect(logger.transports[0].level).toBe(config.logging.level); - expect(logger.transports[1].name).toBe('dailyRotateFile'); - expect(logger.transports[1].level).toBe(config.logging.level); - expect(logger.transports[1].dirname).toBe(config.logging.directory); - }); -}); diff --git a/src/lib/winston.test.ts b/src/lib/winston.test.ts new file mode 100644 index 00000000..8c7058e6 --- /dev/null +++ b/src/lib/winston.test.ts @@ -0,0 +1,46 @@ +/* eslint @typescript-eslint/no-var-requires: "off" */ +/** + * @NOTE: Reset modules in between tests so we can test the application of an + * environment based configuration that changes between runs. We need this + * for full coverage + */ +import container from './winston'; +import config from '../config'; +import winston, { Container, transport, transports } from 'winston'; + +describe('Logger Class', () => { + beforeEach(() => { + jest.resetModules(); + }); + + test('setup without daily log file', () => { + const logger = container.get('application'); + + expect(logger).toBeDefined(); + expect(logger.transports).toHaveLength(1); + expect(logger.transports[0].level).toBe(config.logging.level); + }); + + test('setup with daily log file generation', () => { + // Mock the config to test other branch of if statement + jest.mock('../config', () => ({ + logging: { + level: 'debug', + directory: 'logs' + } + })); + const containerPromise = import('./winston'); + const configPromise = import('../config'); + containerPromise.then(container => { + configPromise.then(config => { + const logger = container.default.get('application'); + expect(logger).toBeDefined(); + expect(logger.transports).toHaveLength(2); + expect(logger.transports[0].level).toBe(config.default.logging.level); + expect(logger.transports[1].level).toBe(config.default.logging.level); + }); + }); + }); +}); + +export {}; diff --git a/src/lib/winston.ts b/src/lib/winston.ts index 6d387b3c..e3dc5909 100644 --- a/src/lib/winston.ts +++ b/src/lib/winston.ts @@ -1,33 +1,43 @@ -import { Container, transports, format } from 'winston'; -import { logging } from '../config.js'; - -interface ConfigData{ - directory?: string - level: string +import winston, { Container, transports, format } from 'winston'; +import config from '../config'; +import 'winston-daily-rotate-file'; +import path from 'path'; +const logging = config.logging; +interface ConfigData { + directory?: string; + level: string; } -const logConfig: ConfigData = logging -// Load in the daily file transport -require('winston-daily-rotate-file'); - +const logConfig: ConfigData = logging; // Create our default logging container -let container = new Container(); -let applicationTransports = []; +const container = new Container(); +const applicationTransports = []; // Create a console transport -let transportConsole = new transports.Console({ +const transportConsole = new transports.Console({ level: logConfig.level, + format: winston.format.combine(winston.format.colorize(), winston.format.json()) }); applicationTransports.push(transportConsole); - +if (logConfig.directory) { + const transportDailyFile = new transports.DailyRotateFile({ + filename: path.join(logConfig.directory, 'application-%DATE%.log'), + datePattern: 'YYYY-MM-DD-HH', + level: logging.level, + zippedArchive: true, + maxSize: '20m' + }); + applicationTransports.push(transportDailyFile); +} // Add a default application logger container.add('application', { format: format.combine(format.timestamp(), format.logstash()), - transports: applicationTransports, + transports: applicationTransports }); + /** * @name exports * @static * @summary Logging container for the application */ -export default container +export default container; diff --git a/src/main.ts b/src/main.ts index 26a85e4e..8369d49f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,7 @@ import { initialize } from './server'; -import container from './lib/winston' +import container from './lib/winston'; import config from './config'; - -const remsService = require('./hooks/rems.hook'); +import remsService from './hooks/rems.hook'; /** * @name exports @@ -11,7 +10,7 @@ const remsService = require('./hooks/rems.hook'); * @function main */ export default async function main() { - let logger = container.get('application'); + const logger = container.get('application'); // Build our server logger.info('Initializing REMS Administrator'); @@ -24,4 +23,4 @@ export default async function main() { app.listen(serverConfig, () => { logger.info('Application listening on port: ' + serverConfig.port); }); -}; +} diff --git a/src/scripts/develop.ts b/src/scripts/develop.ts index 85297a1d..02ea5f8e 100644 --- a/src/scripts/develop.ts +++ b/src/scripts/develop.ts @@ -1,7 +1,7 @@ -import container from '../lib/winston' -import nodemon from 'nodemon' +import container from '../lib/winston'; +import nodemon from 'nodemon'; -let logger = container.get('application'); +const logger = container.get('application'); nodemon({ script: 'src/scripts/develop.js', @@ -12,7 +12,7 @@ nodemon({ }); nodemon - .on('restart', (files:File)=> logger.info(`Nodemon restarting because ${files.name} changed.`)) + .on('restart', (files: File) => logger.info(`Nodemon restarting because ${files.name} changed.`)) .on('crash', () => logger.info('Nodemon crashed. Waiting for changes to restart.')); // Make sure the process actually dies when hitting ctrl + c diff --git a/src/scripts/serve.ts b/src/scripts/serve.ts index 4d6a423a..c5de57ec 100644 --- a/src/scripts/serve.ts +++ b/src/scripts/serve.ts @@ -1,3 +1,3 @@ -import main from '../main' +import main from '../main'; main(); diff --git a/src/server.test.js b/src/server.test.ts similarity index 76% rename from src/server.test.js rename to src/server.test.ts index 0636a2a0..5ef94386 100644 --- a/src/server.test.js +++ b/src/server.test.ts @@ -1,9 +1,10 @@ -const bodyParser = require('body-parser'); -const morgan = require('morgan'); -const { initialize, REMSServer } = require('./server'); +import bodyParser from 'body-parser'; +import morgan from 'morgan'; +import { initialize, REMSServer } from './server'; +import config from './config'; describe('REMSServer class', () => { - let server; + let server: REMSServer; beforeEach(() => { jest.mock('morgan', () => jest.fn()); @@ -15,16 +16,15 @@ describe('REMSServer class', () => { })); jest.mock('express', () => { - let mock = jest.fn(() => ({ + const mock = jest.fn(() => ({ use: jest.fn(), set: jest.fn(), get: jest.fn(), listen: jest.fn(), options: jest.fn(), - post: jest.fn() + post: jest.fn(), + static: jest.fn() })); - // Mock the static directory function - mock.static = jest.fn(); return mock; }); @@ -42,8 +42,8 @@ describe('REMSServer class', () => { }); test('method: configureMiddleware', () => { - let set = jest.spyOn(server.app, 'set'); - let use = jest.spyOn(server.app, 'use'); + const set = jest.spyOn(server.app, 'set'); + const use = jest.spyOn(server.app, 'use'); server.configureMiddleware(); @@ -57,7 +57,7 @@ describe('REMSServer class', () => { }); test('method: configureLogstream', () => { - let use = jest.spyOn(server.app, 'use'); + const use = jest.spyOn(server.app, 'use'); server.configureLogstream(); @@ -72,7 +72,7 @@ describe('REMSServer class', () => { description: 'bar', id: 'foobar' }, - handler: (req, res) => { + handler: (req: any, res: { json: (arg0: string) => void }) => { res.json('hello world'); } }; @@ -90,10 +90,10 @@ describe('REMSServer class', () => { }); test('Method: listen', () => { - let listen = jest.spyOn(server.app, 'listen'); - let callback = jest.fn(); + const listen = jest.spyOn(server.app, 'listen'); + const callback = jest.fn(); // Start listening on a port and pass the callback through - let serverListen = server.listen({ port: 3000 }, callback); + const serverListen = server.listen({ port: 3000 }, callback); expect(listen).toHaveBeenCalledTimes(1); expect(listen.mock.calls[0][0]).toBe(3000); expect(listen.mock.calls[0][1]).toBe(callback); @@ -101,7 +101,7 @@ describe('REMSServer class', () => { }); test('should be able to initilize a server', () => { - const newServer = initialize(); + const newServer = initialize(config); expect(newServer).toBeInstanceOf(REMSServer); expect(newServer).toHaveProperty('app'); expect(newServer).toHaveProperty('listen'); diff --git a/src/server.ts b/src/server.ts index 457b812b..cc128900 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,12 @@ -import express from 'express' -import cors from 'cors' -import bodyParser from 'body-parser' -import container from './lib/winston' -import morgan from 'morgan' -import _ from 'lodash' -import Hook from './hooks/Hook' +import express from 'express'; +import cors from 'cors'; +import bodyParser from 'body-parser'; +import container from './lib/winston'; +import morgan from 'morgan'; +import _ from 'lodash'; +import Hook from './hooks/Hook'; -let logger = container.get('application'); +const logger = container.get('application'); const initialize = (config: any) => { const logLevel = _.get(config, 'logging.level'); @@ -20,8 +20,8 @@ const initialize = (config: any) => { * @class Server */ class REMSServer { - app: any - services: Hook[] + app: express.Application; + services: Hook[]; /** * @method constructor * @description Setup defaults for the server instance @@ -53,19 +53,19 @@ class REMSServer { * @method configureLogstream * @description Enable streaming logs via morgan */ - configureLogstream({ log, level = 'info' } : {log?:any, level?:string} = {}) { + configureLogstream({ log, level = 'info' }: { log?: any; level?: string } = {}) { this.app.use( log ? log : morgan('combined', { - stream: { write: (message) => logger.log(level, message)}, + stream: { write: message => logger.log(level, message) } }) ); return this; } - registerService({ definition, handler } : {definition: any, handler: any}) { + registerService({ definition, handler }: { definition: any; handler: any }) { this.services.push(definition); this.app.post(`/cds-services/${definition.id}`, handler); @@ -83,11 +83,13 @@ class REMSServer { * @param {function} [callback] - Optional callback for listen */ listen({ port, discoveryEndpoint = '/cds-services' }: any, callback: any) { - this.app.get(discoveryEndpoint, (req: any, res: { json: (arg0: { services: any }) => any }) => res.json({ services: this.services })); - this.app.get('/', (req: any, res: { send: (arg0: string) => any }) => res.send('Welcome to the REMS Administrator')); - this.app.listen(port, callback); - - return this; + this.app.get(discoveryEndpoint, (req: any, res: { json: (arg0: { services: any }) => any }) => + res.json({ services: this.services }) + ); + this.app.get('/', (req: any, res: { send: (arg0: string) => any }) => + res.send('Welcome to the REMS Administrator') + ); + return this.app.listen(port, callback); } }