Skip to content

Commit 424c985

Browse files
authored
Merge pull request #54 from mcode/standalone-mcode-client
Standalone mcode client
2 parents 615832c + b621721 commit 424c985

34 files changed

+987
-8
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
node_modules/
22
.vscode/
33
.DS_Store
4+
output/
5+
logs/
6+
config/*.json
7+
!config/mcode-csv-config.example.json

config/mcode-csv-config.example.json

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
{
2+
"patientIdCsvPath": "./data/patient-mrns.csv",
3+
"commonExtractorArgs": {},
4+
"notificationInfo": {
5+
"host": "smtp.example.com",
6+
"port": 587,
7+
"from": "sender@example.com",
8+
"to": [
9+
"demo@example.com",
10+
"test@example.com"
11+
]
12+
},
13+
"extractors": [
14+
{
15+
"label": "condition",
16+
"type": "CSVConditionExtractor",
17+
"constructorArgs": {
18+
"filePath": "./data/condition-information.csv"
19+
}
20+
},
21+
{
22+
"label": "patient",
23+
"type": "CSVPatientExtractor",
24+
"constructorArgs": {
25+
"filePath": "./data/patient-information.csv"
26+
}
27+
},
28+
{
29+
"label": "cancerDiseaseStatus",
30+
"type": "CSVCancerDiseaseStatusExtractor",
31+
"constructorArgs": {
32+
"filePath": "./data/cancer-disease-status-information.csv"
33+
}
34+
},
35+
{
36+
"label": "clinicalTrialInformation",
37+
"type": "CSVClinicalTrialInformationExtractor",
38+
"constructorArgs": {
39+
"filePath": "./data/clinical-trial-information.csv",
40+
"clinicalSiteID": "example-site-id"
41+
}
42+
},
43+
{
44+
"label": "treatmentPlanChange",
45+
"type": "CSVTreatmentPlanChangeExtractor",
46+
"constructorArgs": {
47+
"filePath": "./data/treatment-plan-change-information.csv"
48+
}
49+
},
50+
{
51+
"label": "staging",
52+
"type": "CSVStagingExtractor",
53+
"constructorArgs": {
54+
"filePath": "./data/staging-information.csv"
55+
}
56+
},
57+
{
58+
"label": "cancerRelatedMedication",
59+
"type": "CSVCancerRelatedMedicationExtractor",
60+
"constructorArgs": {
61+
"filePath": "./data/cancer-related-medication-information.csv"
62+
}
63+
},
64+
{
65+
"label": "genericObservations",
66+
"type": "CSVObservationExtractor",
67+
"constructorArgs": {
68+
"filePath": "./data/observation-information.csv"
69+
}
70+
},
71+
{
72+
"label": "genericProcedures",
73+
"type": "CSVProcedureExtractor",
74+
"constructorArgs": {
75+
"filePath": "./data/procedure-information.csv"
76+
}
77+
}
78+
]
79+
}

docs/config.example.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
}
2929
}
3030
],
31-
"auth": {
32-
"tokenEndpoint": "http://example.com/oauth2/token",
31+
"webServiceAuthConfig": {
32+
"url": "http://example.com/oauth2/token",
3333
"clientId": "client_id",
3434
"jwk": {}
3535
},

package-lock.json

Lines changed: 15 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
"license": "Apache-2.0",
2121
"dependencies": {
2222
"axios": "^0.19.0",
23+
"commander": "^6.2.0",
2324
"csv-parse": "^4.8.8",
2425
"fhir-crud-client": "^1.2.1",
2526
"fhirpath": "^2.1.5",
2627
"lodash": "^4.17.19",
2728
"moment": "^2.26.0",
29+
"nodemailer": "^6.4.14",
2830
"sha.js": "^2.4.9",
2931
"winston": "^3.2.1"
3032
},

src/cli/RunInstanceLogger.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const path = require('path');
2+
const moment = require('moment');
3+
const fs = require('fs');
4+
const logger = require('../helpers/logger');
5+
6+
// Sort Log records by `dateRun`, more recent (larger) dates to least recent (smaller)
7+
function logSorter(a, b) {
8+
return moment(b.dateRun) - moment(a.dateRun);
9+
}
10+
11+
// Common wrapper function for checking if a value is a date
12+
function isDate(date) {
13+
return moment(date).isValid();
14+
}
15+
16+
// Create a new instance of a Log record based on a `to` and `from` date
17+
function createLogObject(fromDate, toDate) {
18+
const now = moment();
19+
return {
20+
dateRun: now,
21+
toDate: toDate || now,
22+
fromDate,
23+
};
24+
}
25+
26+
// Provides an interface for loading existing logs of client RunInstances,
27+
// getting the most recent runs, and returning the most recent run
28+
class RunInstanceLogger {
29+
constructor(pathToLogFile) {
30+
this.logPath = pathToLogFile;
31+
try {
32+
this.logs = JSON.parse(fs.readFileSync(path.resolve(this.logPath)));
33+
// Sort logs on load
34+
this.logs.sort(logSorter);
35+
} catch (err) {
36+
logger.error(`FATAL-Could not parse the logPath provided: ${this.logPath}`);
37+
process.exit(1);
38+
}
39+
}
40+
41+
// Get the most recent run performed and logged
42+
getMostRecentToDate() {
43+
// Filter logs by client
44+
const filteredLogs = this.logs.filter((l) => l.client === this.client);
45+
// Sorting of logs is maintained over load and update; this should be safe
46+
return filteredLogs[0] && filteredLogs[0].toDate;
47+
}
48+
49+
// Calling this adds a new Log record to our local object and to the file on disk
50+
addRun(fromDate, toDate) {
51+
logger.info('Logging successful run information to records');
52+
// If fromDate isn't valid, or if toDate is both defined and invalid, we can't properly log
53+
if (!isDate(fromDate) || (toDate && !isDate(toDate))) {
54+
logger.error(`Trying to add a run to RunInstance logger, but toDate and fromDate are not valid dates: to ${toDate} and from ${fromDate}`);
55+
logger.error('Log failed');
56+
return;
57+
}
58+
const log = createLogObject(fromDate, toDate);
59+
this.logs.push(log);
60+
this.logs.sort(logSorter);
61+
fs.writeFileSync(this.logPath, JSON.stringify(this.logs));
62+
}
63+
}
64+
65+
module.exports = {
66+
RunInstanceLogger,
67+
};

src/cli/app.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
const parse = require('csv-parse/lib/sync');
2+
const fs = require('fs');
3+
const path = require('path');
4+
const moment = require('moment');
5+
const logger = require('../helpers/logger');
6+
const { RunInstanceLogger } = require('./RunInstanceLogger');
7+
const { sendEmailNotification, zipErrors } = require('./emailNotifications');
8+
const { extractDataForPatients } = require('./mcodeExtraction');
9+
10+
function getConfig(pathToConfig) {
11+
// Checks pathToConfig points to valid JSON file
12+
const fullPath = path.resolve(pathToConfig);
13+
try {
14+
return JSON.parse(fs.readFileSync(fullPath));
15+
} catch (err) {
16+
throw new Error(`The provided filepath to a configuration file ${pathToConfig}, full path ${fullPath} did not point to a valid JSON file.`);
17+
}
18+
}
19+
20+
function checkInputAndConfig(config, fromDate, toDate) {
21+
// Check input args and needed config variables based on client being used
22+
const { patientIdCsvPath } = config;
23+
24+
// Check if `fromDate` is a valid date
25+
if (fromDate && !moment(fromDate).isValid()) {
26+
throw new Error('-f/--from-date is not a valid date.');
27+
}
28+
29+
// Check if `toDate` is a valid date
30+
if (toDate && !moment(toDate).isValid()) {
31+
throw new Error('-t/--to-date is not a valid date.');
32+
}
33+
34+
// Check if there is a path to the MRN CSV within our config JSON
35+
if (!patientIdCsvPath) {
36+
throw new Error('patientIdCsvPath is required in config file');
37+
}
38+
}
39+
40+
function checkLogFile(pathToLogs) {
41+
// Check that the given log file exists
42+
try {
43+
const logFileContent = JSON.parse(fs.readFileSync(pathToLogs));
44+
if (!Array.isArray(logFileContent)) throw new Error('Log file needs to be an array.');
45+
} catch (err) {
46+
logger.error(`The provided filepath to a LogFile, ${pathToLogs}, did not point to a valid JSON file. Create a json file with an empty array at this location.`);
47+
throw new Error(err.message);
48+
}
49+
}
50+
51+
// Use previous runs to infer a valid fromDate if none was provided
52+
function getEffectiveFromDate(fromDate, runLogger) {
53+
if (fromDate) return fromDate;
54+
55+
// Use the most recent ToDate
56+
logger.info('No fromDate was provided, inferring an effectiveFromDate');
57+
const effectiveFromDate = runLogger.getMostRecentToDate();
58+
logger.info(`effectiveFromDate: ${effectiveFromDate}`);
59+
if (!effectiveFromDate) {
60+
throw new Error('no valid fromDate was supplied, and there are no log records from which we could pull a fromDate');
61+
}
62+
63+
return effectiveFromDate;
64+
}
65+
66+
async function mcodeApp(Client, fromDate, toDate, pathToConfig, pathToRunLogs, debug, allEntries) {
67+
try {
68+
if (debug) logger.level = 'debug';
69+
// Don't require a run-logs file if we are extracting all-entries. Only required when using --entries-filter.
70+
if (!allEntries) checkLogFile(pathToRunLogs);
71+
const config = getConfig(pathToConfig);
72+
checkInputAndConfig(config, fromDate, toDate);
73+
74+
// Create and initialize client
75+
const mcodeClient = new Client(config);
76+
await mcodeClient.init();
77+
78+
// Parse CSV for list of patient mrns
79+
const patientIdsCsvPath = path.resolve(config.patientIdCsvPath);
80+
const patientIds = parse(fs.readFileSync(patientIdsCsvPath, 'utf8'), { columns: true }).map((row) => row.mrn);
81+
82+
// Get RunInstanceLogger for recording new runs and inferring dates from previous runs
83+
const runLogger = allEntries ? null : new RunInstanceLogger(pathToRunLogs);
84+
const effectiveFromDate = allEntries ? null : getEffectiveFromDate(fromDate, runLogger);
85+
const effectiveToDate = allEntries ? null : toDate;
86+
87+
// Extract the data
88+
logger.info(`Extracting data for ${patientIds.length} patients`);
89+
const { extractedData, successfulExtraction, totalExtractionErrors } = await extractDataForPatients(patientIds, mcodeClient, effectiveFromDate, effectiveToDate);
90+
91+
// If we have notification information, send an emailNotification
92+
const { notificationInfo } = config;
93+
if (notificationInfo) {
94+
const notificationErrors = zipErrors(totalExtractionErrors);
95+
await sendEmailNotification(notificationInfo, notificationErrors, debug);
96+
}
97+
// A run is successful and should be logged when both extraction finishes without fatal errors
98+
// and messages are posted without fatal errors
99+
if (!allEntries && effectiveFromDate) {
100+
const successCondition = successfulExtraction;
101+
if (successCondition) {
102+
runLogger.addRun(effectiveFromDate, effectiveToDate);
103+
}
104+
}
105+
106+
// Finally, save the data to disk
107+
const outputPath = './output';
108+
if (!fs.existsSync(outputPath)) {
109+
logger.info(`Creating directory ${outputPath}`);
110+
fs.mkdirSync(outputPath);
111+
}
112+
// For each bundle in our extractedData, write it to our output directory
113+
extractedData.forEach((bundle, i) => {
114+
const outputFile = path.join(outputPath, `mcode-extraction-patient-${i + 1}.json`);
115+
logger.debug(`Logging mCODE output to ${outputFile}`);
116+
fs.writeFileSync(outputFile, JSON.stringify(bundle), 'utf8');
117+
});
118+
logger.info(`Successfully logged ${extractedData.length} mCODE bundle(S) to ${outputPath}`);
119+
} catch (e) {
120+
logger.error(e.message);
121+
logger.debug(e.stack);
122+
process.exit(1);
123+
}
124+
}
125+
126+
module.exports = {
127+
mcodeApp,
128+
};

src/cli/cli.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const path = require('path');
2+
const program = require('commander');
3+
const { MCODEClient } = require('../client/MCODEClient');
4+
const { mcodeApp } = require('./app');
5+
6+
const defaultPathToConfig = path.join('config', 'csv.config.json');
7+
const defaultPathToRunLogs = path.join('logs', 'run-logs.json');
8+
9+
program
10+
.usage('[options]')
11+
.option('-f --from-date <date>', 'The earliest date and time to search')
12+
.option('-t --to-date <date>', 'The latest date and time to search')
13+
.option('-e, --entries-filter', 'Flag to indicate to filter data by date')
14+
.option('-c --config-filepath <path>', 'Specify relative path to config to use:', defaultPathToConfig)
15+
.option('-r --run-log-filepath <path>', 'Specify relative path to log file of previous runs:', defaultPathToRunLogs)
16+
.option('-d, --debug', 'output extra debugging information')
17+
.parse(process.argv);
18+
19+
const {
20+
fromDate, toDate, configFilepath, runLogFilepath, debug, entriesFilter,
21+
} = program;
22+
23+
// Flag to extract allEntries, or just to use to-from dates
24+
const allEntries = !entriesFilter;
25+
26+
mcodeApp(MCODEClient, fromDate, toDate, configFilepath, runLogFilepath, debug, allEntries);

0 commit comments

Comments
 (0)