diff --git a/spec/clinicaltrialsgov.spec.ts b/spec/clinicaltrialsgov.spec.ts index 0668619..a3a62a4 100644 --- a/spec/clinicaltrialsgov.spec.ts +++ b/spec/clinicaltrialsgov.spec.ts @@ -1,7 +1,5 @@ -import { getContainedResource, ResearchStudy as ResearchStudyObj } from '../src/research-study'; -import { Address, FhirResource, Location, ResearchStudy, PlanDefinition } from 'fhir/r4'; +import { ResearchStudy } from 'fhir/r4'; import * as ctg from '../src/clinicaltrialsgov'; -import { Study } from '../src/clinical-trials-gov'; import fs from 'fs'; import path from 'path'; import nock from 'nock'; @@ -9,20 +7,11 @@ import { Volume } from 'memfs'; // Trial missing summary, inclusion/exclusion criteria, phase and study type import trialMissing from './data/resource.json'; -import trialFilled from './data/complete_study.json'; import { createClinicalStudy } from './support/clinicalstudy-factory'; import { createResearchStudy } from './support/researchstudy-factory'; import { - DateType, - DesignAllocation, - DesignMasking, - DesignTimePerspective, - InterventionalAssignment, - InterventionType, - ObservationalModel, - PrimaryPurpose, - Status, - StudyType + PagedStudies, + Study, } from '../src/ctg-api'; function specFilePath(specFilePath: string): string { @@ -127,11 +116,11 @@ describe('findNCTNumbers', () => { describe('parseStudyJson', () => { it('rejects invalid JSON', () => { - expect(ctg.parseStudyJson('true')).toThrowError('Invalid JSON object for Study: true'); + expect(ctg.parseStudyJson('true')).toBeNull(); }); it('logs failures', () => { const log = jasmine.createSpy('log'); - expect(ctg.parseStudyJson('not valid JSON', log)).toThrowError(/^Unable to parse JSON object/); + expect(ctg.parseStudyJson('not valid JSON', log)).toBeNull(); expect(log).toHaveBeenCalled(); }); }); @@ -297,7 +286,7 @@ describe('ClinicalTrialsGovService', () => { const multipleEntriesDataDir = '/ctg-cache-multi'; // The date used for when the cache was first created, 2021-02-01T12:00:00.000 (picked arbitrarily) const cacheStartTime = new Date(2021, 1, 1, 12, 0, 0, 0); - let study: ResearchStudy, nctID: ctg.NCTNumber; + let study: ResearchStudy; // Create our mock FS const cacheVol = Volume.fromNestedJSON({ '/ctg-cache/data/NCT02513394.json': fs.readFileSync(specFilePath('NCT02513394.json'), { encoding: 'utf8' }), @@ -320,8 +309,6 @@ describe('ClinicalTrialsGovService', () => { if (maybeNctID === null) { // This indicates a failure in test cases throw new Error('ResearchStudy has no NCT number'); - } else { - nctID = maybeNctID; } // Create an empty directory cacheVol.mkdirSync('/ctg-cache-empty'); @@ -734,126 +721,85 @@ describe('ClinicalTrialsGovService', () => { let interceptor: nock.Interceptor; let downloader: ctg.ClinicalTrialsGovService; const nctIDs = ['NCT00000001', 'NCT00000002', 'NCT00000003']; - beforeEach(() => { - scope = nock('https://classic.clinicaltrials.gov'); - interceptor = scope.get('/ct2/download_studies?term=' + nctIDs.join('+OR+')); - return ctg.createClinicalTrialsGovService(dataDirPath, { cleanInterval: 0, fs: cacheFS }).then((service) => { - downloader = service; + beforeEach(async () => { + scope = nock('https://clinicaltrials.gov'); + interceptor = scope.get('/api/v2/studies?filter.ids=' + nctIDs.join(',')); + // Need to intercept the writeFile method + spyOn(cacheFS, 'writeFile').and.callFake((_file, _data, _options, callback) => { + // For these, always pretend it succeeded + callback(null); }); + downloader = await ctg.createClinicalTrialsGovService(dataDirPath, { cleanInterval: 0, fs: cacheFS }); }); - it('handles failures from https.get', () => { + it('handles failures from https.get', async () => { interceptor.replyWithError('Test error'); - return expectAsync(downloader['downloadTrials'](nctIDs)).toBeRejectedWithError('Test error'); + expect(await downloader['downloadTrials'](nctIDs)).toBeFalse(); }); - it('handles failure responses from the server', () => { + it('handles failure responses from the server', async () => { interceptor.reply(404, 'Unknown'); // Pretend the middle entry exists + downloader['cache'].set(nctIDs[1], new ctg.CacheEntry(downloader, nctIDs[1] + '.json', {})); + expect(await downloader['downloadTrials'](nctIDs)).toBeFalse(); + expect(scope.isDone()).toBeTrue(); + // Check to make sure the new cache entries do not still exist - the failure should remove them, but not the + // non-pending one + expect(downloader['cache'].has(nctIDs[0])).toBeFalse(); + expect(downloader['cache'].has(nctIDs[1])).toBeTrue(); + expect(downloader['cache'].has(nctIDs[2])).toBeFalse(); + }); + + it('creates cache entries', async () => { + interceptor.reply( + 200, + JSON.stringify({ + studies: nctIDs.map((id) => ({ + protocolSection: { + identificationModule: { + nctId: id + } + } + })) + } as PagedStudies), + { 'Content-type': 'application/json' } + ); + // For this test, create an existing cache entry for one of the IDs downloader['cache'].set(nctIDs[1], new ctg.CacheEntry(downloader, nctIDs[1] + '.xml', {})); - return expectAsync( - downloader['downloadTrials'](nctIDs).finally(() => { - expect(scope.isDone()).toBeTrue(); - }) - ) - .toBeRejected() - .then(() => { - // Check to make sure the new cache entries do not still exist - the failure should remove them, but not the - // non-pending one - expect(downloader['cache'].has(nctIDs[0])).toBeFalse(); - expect(downloader['cache'].has(nctIDs[2])).toBeFalse(); - }); + expect(await downloader['downloadTrials'](nctIDs)).toBeTrue(); + + // Should have created the two missing items which should be resolved + let entry = downloader['cache'].get(nctIDs[0]); + expect(entry?.pending).toBeFalse(); + entry = downloader['cache'].get(nctIDs[1]); + expect(entry?.pending).toBeFalse(); + entry = downloader['cache'].get(nctIDs[2]); + expect(entry?.pending).toBeFalse(); + }); + + it('invalidates cache entries that were not found in the results', async () => { + interceptor.reply( + 200, + JSON.stringify({ + // Only include NCT ID 1 + studies: [ + { + protocolSection: { + identificationModule: { + nctId: nctIDs[1] + } + } + } + ] + } as PagedStudies), + { 'Content-type': 'application/json' } + ); + expect(await downloader['downloadTrials'](nctIDs)).toBeTrue(); + // The failed NCT IDs should be removed at this point + expect(downloader['cache'].has(nctIDs[0])).toBeFalse(); + expect(downloader['cache'].has(nctIDs[1])).toBeTrue(); + expect(downloader['cache'].has(nctIDs[2])).toBeFalse(); }); - - // it('creates cache entries', () => { - // interceptor.reply(200, 'Unimportant', { 'Content-type': 'application/zip' }); - // // For this test, create an existing cache entry for one of the IDs - // downloader['cache'].set(nctIDs[1], new ctg.CacheEntry(downloader, nctIDs[1] + '.xml', {})); - // // Also mock the extraction process so it thinks everything is fine - // downloader['extractResults'] = () => { - // // Grab cache entries for our NCTs and say they've been resolved - // for (const id of nctIDs) { - // const entry = downloader['cache'].get(id); - // expect(entry).toBeDefined(); - // if (entry) { - // // Indicate that the entry is found - // entry.found(); - // } - // } - // // Only mark entry 1 ready - // const entry = downloader['cache'].get(nctIDs[1]); - // if (entry) { - // entry.ready(); - // } - // return Promise.resolve(); - // }; - // return expectAsync(downloader['downloadTrials'](nctIDs)) - // .toBeResolved() - // .then(() => { - // // Should have created the two missing items which should still be pending as we mocked the extract process - // let entry = downloader['cache'].get(nctIDs[0]); - // expect(entry && entry.pending).toBeTrue(); - // entry = downloader['cache'].get(nctIDs[1]); - // expect(entry && !entry.pending).toBeTrue(); - // entry = downloader['cache'].get(nctIDs[2]); - // expect(entry && entry.pending).toBeTrue(); - // }); - // }); - - // it('extracts a ZIP', () => { - // interceptor.reply(200, 'Unimportant', { - // 'Content-type': 'application/zip' - // }); - // const spy = jasmine.createSpy('extractResults').and.callFake((): Promise => { - // // Grab cache entries for our NCTs and say they've been resolved - // for (const id of nctIDs) { - // const entry = downloader['cache'].get(id); - // expect(entry).toBeDefined(); - // if (entry) { - // entry.found(); - // entry.ready(); - // } - // } - // return Promise.resolve(); - // }); - // // Jam the spy in (method is protected, that's why it can't be created directly) - // downloader['extractResults'] = spy; - // return expectAsync( - // downloader['downloadTrials'](nctIDs).finally(() => { - // // Just check that it was called - // expect(spy).toHaveBeenCalledTimes(1); - // expect(scope.isDone()).toBeTrue(); - // }) - // ).toBeResolved(); - // }); - - // it('invalidates cache entries that were not found in the downloaded ZIP', () => { - // // This is kind of complicated, but basically, we need to have it "create" the entries for the NCT IDs, but then - // // have extractResults "not create" some of the entries. - // interceptor.reply(200, 'Unimportant', { - // 'Content-type': 'application/zip' - // }); - // const spy = jasmine.createSpy('extractResults').and.callFake((): Promise => { - // // For this test, we only mark one OK - // const entry = downloader['cache'].get(nctIDs[1]); - // expect(entry).toBeDefined(); - // if (entry) { - // entry.found(); - // entry.ready(); - // } - // return Promise.resolve(); - // }); - // // Jam the spy in (method is protected, that's why it can't be created directly) - // downloader['extractResults'] = spy; - // return expectAsync(downloader['downloadTrials'](nctIDs)) - // .toBeResolved() - // .then(() => { - // // The failed NCT IDs should be removed at this point - // expect(downloader['cache'].has(nctIDs[0])).toBeFalse(); - // expect(downloader['cache'].has(nctIDs[1])).toBeTrue(); - // expect(downloader['cache'].has(nctIDs[2])).toBeFalse(); - // }); - // }); }); describe('#getCachedClinicalStudy', () => { @@ -888,15 +834,15 @@ describe('ClinicalTrialsGovService', () => { describe('#ensureTrialsAvailable()', () => { // Most of these tests just pass through to downloadTrials let service: ctg.ClinicalTrialsGovService; - let downloadTrials: jasmine.Spy<(ids: string[]) => Promise>; + let downloadTrials: jasmine.Spy<(ids: string[]) => Promise>; beforeEach(() => { service = new ctg.ClinicalTrialsGovService(dataDirPath, { fs: cacheFS }); // Can't directly spy on within TypeScript because downloadTrials is protected - const spy = jasmine.createSpy<(ids: string[]) => Promise>('downloadTrials'); + const spy = jasmine.createSpy<(ids: string[]) => Promise>('downloadTrials'); // Jam it in service['downloadTrials'] = spy; downloadTrials = spy; - downloadTrials.and.callFake(() => Promise.resolve()); + downloadTrials.and.callFake(() => Promise.resolve(true)); }); it('excludes invalid NCT numbers in an array of strings', () => { @@ -1043,702 +989,4 @@ describe('ClinicalTrialsGovService', () => { // There's no really good way to verify this worked. For now, it not blowing up is good enough. }); }); - - describe('filling out a partial trial', () => { - // Use the downloader to load the fixture data - let downloader: ctg.ClinicalTrialsGovService; - let updatedTrial: ResearchStudy; - let clinicalStudy: Study; - beforeAll(async function () { - downloader = new ctg.ClinicalTrialsGovService(dataDirPath, { cleanInterval: 0, fs: cacheFS }); - await downloader.init(); - // Cache should have been restored on init - const maybeStudy = await downloader.getCachedClinicalStudy(nctID); - if (maybeStudy === null) { - throw new Error('Unable to open study'); - } else { - clinicalStudy = maybeStudy; - } - // Note this mutates study, which doesn't actually matter at present. - updatedTrial = ctg.updateResearchStudyWithClinicalStudy(study, clinicalStudy); - }); - - it('fills in inclusion criteria', () => { - expect(updatedTrial.enrollment).toBeDefined(); - if (updatedTrial.enrollment) { - // Prove enrollment exists to TypeScript - expect(updatedTrial.enrollment.length).toBeGreaterThan(0); - expect(updatedTrial.enrollment[0].display).toBeDefined(); - } - }); - - it('fills in phase', () => { - expect(updatedTrial.phase).toBeDefined(); - if (updatedTrial.phase) expect(updatedTrial.phase.text).toBe('Phase 3'); - }); - - it('fills in categories', () => { - expect(updatedTrial.category).toBeDefined(); - if (updatedTrial.category) { - expect(updatedTrial.category.length).toEqual(5); - const categories = updatedTrial.category.map((item) => item.text); - expect(categories).toHaveSize(5); - expect(categories).toEqual( - jasmine.arrayContaining([ - 'Study Type: Interventional', - 'Intervention Model: Parallel Assignment', - 'Primary Purpose: Treatment', - 'Masking: None (Open Label)', - 'Allocation: Randomized' - ]) - ); - } - }); - - it('does not overwrite existing categories', () => { - const researchStudy = new ResearchStudyObj('id'); - researchStudy.category = [{ text: 'Study Type: Example' }]; - - ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - designModule: { - studyType: StudyType.INTERVENTIONAL, - designInfo: { - interventionModel: InterventionalAssignment.PARALLEL, - primaryPurpose: PrimaryPurpose.TREATMENT, - maskingInfo: { - masking: DesignMasking.NONE - }, - allocation: DesignAllocation.RANDOMIZED, - timePerspective: DesignTimePerspective.OTHER, - observationalModel: ObservationalModel.CASE_CONTROL - } - } - } - }); - - expect(researchStudy.category).toBeDefined(); - if (researchStudy.category) { - expect(researchStudy.category).toHaveSize(7); - const categories = researchStudy.category.map((item) => item.text); - expect(categories).toHaveSize(7); - expect(categories).toEqual( - jasmine.arrayContaining([ - 'Study Type: Example', - 'Intervention Model: Parallel Assignment', - 'Primary Purpose: Treatment', - 'Masking: None (Open Label)', - 'Allocation: Randomized', - 'Time Perspective: Example', - 'Observation Model: Something' - ]) - ); - } - }); - - it('will retain old categories if not part of standard study design', () => { - const researchStudy = new ResearchStudyObj('id'); - // Empty category but there is an object there for the sake of this test. - researchStudy.category = [{}]; - - ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - designModule: { - studyType: StudyType.INTERVENTIONAL - } - } - }); - - expect(researchStudy.category).toBeDefined(); - if (researchStudy.category) { - expect(researchStudy.category).toHaveSize(2); - } - }); - - it('fills in arms', () => { - expect(updatedTrial.arm).toBeDefined(); - if (updatedTrial.arm) { - expect(updatedTrial.arm).toHaveSize(2); - expect(updatedTrial.arm).toEqual( - jasmine.arrayContaining([ - jasmine.objectContaining({ - name: 'Arm A', - type: { - coding: jasmine.arrayContaining([{ code: 'Experimental', display: 'Experimental' }]), - text: 'Experimental' - }, - description: - 'Palbociclib at a dose of 125 mg orally once daily, Day 1 to Day 21 followed by 7 days off treatment in a 28-day cycle for a total duration of 2 years, in addition to standard adjuvant endocrine therapy for a duration of at least 5 years.' - }), - jasmine.objectContaining({ - name: 'Arm B', - type: { coding: jasmine.arrayContaining([{ code: 'Other', display: 'Other' }]), text: 'Other' }, - description: 'Standard adjuvant endocrine therapy for a duration of at least 5 years.' - }) - ]) - ); - } - }); - - it('fills in protocol with interventions and arm references', () => { - expect(updatedTrial.protocol).toBeDefined(); - if (updatedTrial.protocol) { - expect(updatedTrial.protocol).toHaveSize(3); - const references: PlanDefinition[] = []; - for (const plan of updatedTrial.protocol) { - if (plan.reference && plan.reference.length > 1) { - const intervention: PlanDefinition = getContainedResource( - updatedTrial, - plan.reference.substring(1) - ) as PlanDefinition; - if (intervention) references.push(intervention); - } else { - fail('PlanDefinition not defined for intervention'); - } - } - - try { - const titles = references.map((item) => item.title); - const types = references.map((item) => (item.type ? item.type.text : null)); - const subjects = references.map((item) => - item.subjectCodeableConcept ? item.subjectCodeableConcept.text : null - ); - - expect(titles).toEqual( - jasmine.arrayContaining([ - 'Palbociclib', - 'Standard Adjuvant Endocrine Therapy', - 'Standard Adjuvant Endocrine Therapy' - ]) - ); - expect(types).toEqual(jasmine.arrayContaining(['Drug', 'Drug', 'Drug'])); - expect(subjects).toEqual(jasmine.arrayContaining(['Arm A', 'Arm A', 'Arm B'])); - } catch (err) { - fail(err); - } - } - }); - - it('fills in interventions even without arms', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - armsInterventionsModule: { - interventions: [ - { - type: InterventionType.BEHAVIORAL, - name: 'Name', - description: 'Description', - otherNames: ['Other name'] - } - ] - } - } - }); - - expect(result.protocol).toBeDefined(); - expect(result.protocol).toHaveSize(1); - - if (result.protocol && result.protocol.length > 0) { - if (result.protocol[0].reference && result.protocol[0].reference.length > 1) { - const intervention: PlanDefinition = getContainedResource( - result, - result.protocol[0].reference.substring(1) - ) as PlanDefinition; - expect(intervention).toEqual( - jasmine.objectContaining({ - resourceType: 'PlanDefinition', - status: 'unknown', - description: 'Description', - title: 'Name', - subtitle: 'Other name', - type: { text: 'Behavioral' } - }) - ); - } - } - }); - - it('fills in interventions with description and subtitle', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - armsInterventionsModule: { - interventions: [ - { - type: InterventionType.BEHAVIORAL, - name: 'Name', - description: 'Description', - otherNames: ['Other name'], - armGroupLabels: ['Arm'] - } - ] - } - } - }); - - expect(result.protocol).toBeDefined(); - expect(result.protocol).toHaveSize(1); - - if (result.protocol && result.protocol.length > 0) { - if (result.protocol[0].reference && result.protocol[0].reference.length > 1) { - const intervention: PlanDefinition = getContainedResource( - result, - result.protocol[0].reference.substring(1) - ) as PlanDefinition; - expect(intervention).toEqual( - jasmine.objectContaining({ - resourceType: 'PlanDefinition', - status: 'unknown', - description: 'Description', - title: 'Name', - subtitle: 'Other name', - type: { text: 'Behavioral' }, - subjectCodeableConcept: { text: 'Arm' } - }) - ); - } - } - }); - - it('fills in period', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - statusModule: { - startDateStruct: { - date: '2023-01', - type: DateType.ACTUAL - }, - completionDateStruct: { - date: '2023-02', - type: DateType.ACTUAL - } - } - } - }); - - expect(result.period).toBeDefined(); - if (result.period) { - expect(result.period.start).toBeDefined(); - expect(result.period.end).toBeDefined(); - - expect(result.period.start).toEqual(new Date('January 2022').toISOString()); - expect(result.period.end).toEqual(new Date('February 2023').toISOString()); - } - }); - - it('fills in start of period even without end', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - statusModule: { - startDateStruct: { - date: '2023-01', - type: DateType.ACTUAL - } - } - } - }); - - expect(result.period).toBeDefined(); - if (result.period) { - expect(result.period.start).toBeDefined(); - expect(result.period.end).not.toBeDefined(); - - expect(result.period.start).toEqual(new Date('January 2022').toISOString()); - } - }); - - it('fills in end of period even without start', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - statusModule: { - completionDateStruct: { - date: '2023-02', - type: DateType.ACTUAL - } - } - } - }); - - expect(result.period).toBeDefined(); - if (result.period) { - expect(result.period.start).not.toBeDefined(); - expect(result.period.end).toBeDefined(); - - expect(result.period.end).toEqual(new Date('February 2023').toISOString()); - } - }); - - it('does not fill in period if not a real date', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - statusModule: { - startDateStruct: { - date: 'Not real', - type: DateType.ACTUAL - }, - completionDateStruct: { - date: 'Not real', - type: DateType.ACTUAL - } - } - } - }); - - expect(result.period).not.toBeDefined(); - }); - - it('fills in description', () => { - expect(updatedTrial.description).toBeDefined(); - }); - - it('fills out the status', () => { - const actual = ctg.updateResearchStudyWithClinicalStudy( - { resourceType: 'ResearchStudy', status: 'active' }, - { - protocolSection: { - statusModule: { - lastKnownStatus: Status.AVAILABLE - } - } - } - ); - expect(actual.status).toEqual('completed'); - }); - - it('leaves status alone if unavailable', () => { - const actual = ctg.updateResearchStudyWithClinicalStudy( - { resourceType: 'ResearchStudy', status: 'active' }, - { - // Lie about types - protocolSection: { - statusModule: { - lastKnownStatus: 'something invalid' as unknown as Status - } - } - } - ); - // It shouldn't have changed it, because it can't - expect(actual.status).toEqual('active'); - }); - - it('fills out conditions', () => { - const actual = ctg.updateResearchStudyWithClinicalStudy( - { resourceType: 'ResearchStudy', status: 'active' }, - { - protocolSection: { - conditionsModule: { - conditions: ['Condition 1', 'Condition 2'] - } - } - } - ); - expect(actual.condition).toBeDefined(); - if (actual.condition) { - expect(actual.condition.length).toEqual(2); - expect(actual.condition[0].text).toEqual('Condition 1'); - expect(actual.condition[1].text).toEqual('Condition 2'); - } - }); - - it('fills in contact', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - contactsLocationsModule: { - centralContacts: [ - { - name: 'First Middle Last, MD', - phone: '1112223333', - email: 'email@example.com' - }, - { - name: 'First2 Middle2 Last2, DO', - phone: '1234567890', - email: 'email2@example.com' - } - ] - } - } - }); - - expect(result.contact).toBeDefined(); - if (result.contact) { - expect(result.contact).toHaveSize(2); - expect(result.contact).toEqual( - jasmine.arrayContaining([ - jasmine.objectContaining({ - name: 'First Middle Last, MD', - telecom: [ - { system: 'email', value: 'email@example.com', use: 'work' }, - { system: 'phone', value: '1112223333', use: 'work' } - ] - }), - jasmine.objectContaining({ - name: 'First2 Middle2 Last2, DO', - telecom: [ - { system: 'email', value: 'email2@example.com', use: 'work' }, - { system: 'phone', value: '1234567890', use: 'work' } - ] - }) - ]) - ); - } - }); - - it('fills in contacts even with missing information', () => { - const researchStudy = new ResearchStudyObj('id'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - contactsLocationsModule: { - centralContacts: [ - { - name: 'First Last', - email: 'email@example.com' - }, - { - name: 'Middle2', - phone: '1234567890' - } - ] - } - } - }); - - expect(result.contact).toBeDefined(); - if (result.contact) { - expect(result.contact).toHaveSize(2); - expect(result.contact).toEqual( - jasmine.arrayContaining([ - jasmine.objectContaining({ - name: 'First Last', - telecom: [{ system: 'email', value: 'email@example.com', use: 'work' }] - }), - jasmine.objectContaining({ - name: 'Middle2', - telecom: [{ system: 'phone', value: '1234567890', use: 'work' }] - }) - ]) - ); - } - }); - - it('does not overwrite site data', () => { - const researchStudy = new ResearchStudyObj('id'); - const location = researchStudy.addSite('Example'); - const result = ctg.updateResearchStudyWithClinicalStudy(researchStudy, { - protocolSection: { - contactsLocationsModule: { - locations: [ - { - facility: 'Facility' - } - ] - } - } - }); - expect(result.site).toBeDefined(); - const sites = result.site; - if (sites) { - expect(sites.length).toEqual(1); - if (sites[0]) { - expect(sites[0].reference).toEqual('#' + location.id); - if (location.id) { - const actualLocation = getContainedResource(result, location.id); - expect(actualLocation).not.toBeNull(); - if (actualLocation) { - expect(actualLocation.resourceType).toEqual('Location'); - expect((actualLocation as Location).name).toEqual('Example'); - } - } else { - fail('location.id not defined'); - } - } else { - fail('sites[0] undefined'); - } - } - }); - - it('does not alter a filled out trial', () => { - // Clone the trial in the dumbest but also most sensible way - const exampleStudy: ResearchStudy = JSON.parse(JSON.stringify(trialFilled)); - ctg.updateResearchStudyWithClinicalStudy(exampleStudy, clinicalStudy); - // Currently active gets overwritten intentioanlly, so set the example - // back to its original value even if it changed - // (Note that the "as" *should* verify that the underlying JSON value is - // in fact valid at compile time. I think.) - exampleStudy.status = trialFilled.status as ResearchStudy['status']; - // Nothing should have changed - expect(exampleStudy).toEqual(trialFilled as ResearchStudy); - }); - - function expectTelecom(location: Location, type: 'phone' | 'email', expectedValue: string | null) { - // Look through the telecoms - // If the expected value is null, telecom must be defined, otherwise it - // may be empty - if (expectedValue !== null) expect(location.telecom).toBeDefined(); - if (location.telecom) { - // If we're expecting a telecom we're expecting it to appear exactly once - let found = 0; - for (const telecom of location.telecom) { - if (telecom.system === type) { - found++; - if (found > 1) { - fail(`Found an extra ${type}`); - } - if (expectedValue === null) { - // If null, it wasn't expected at all - fail(`Expected no ${type}, but one was found`); - } else { - expect(telecom.use).toEqual('work'); - expect(telecom.value).toEqual(expectedValue); - } - } - } - if (expectedValue !== null && found === 0) { - fail(`Expected one ${type}, not found`); - } - } - } - - function expectLocation( - resource: FhirResource, - expectedName?: string, - expectedPhone?: string, - expectedEmail?: string, - expectedAddress?: Address - ) { - if (resource.resourceType === 'Location') { - const location = resource as Location; - if (expectedName) { - expect(location.name).toEqual(expectedName); - } else { - expect(location.name).not.toBeDefined(); - } - expectTelecom(location, 'phone', expectedPhone || null); - expectTelecom(location, 'email', expectedEmail || null); - if (expectedAddress) { - expect(location.address).toBeDefined(); - expect(location.address).toEqual(expectedAddress); - } else { - expect(location.address).not.toBeDefined(); - } - } else { - fail(`Expected Location, got ${resource.resourceType}`); - } - } - - it('fills out sites as expected', () => { - const result = ctg.updateResearchStudyWithClinicalStudy( - { resourceType: 'ResearchStudy', status: 'active' }, - { - protocolSection: { - contactsLocationsModule: { - locations: [ - // Everything in location is optional, so this is valid: - {}, - { - facility: 'No Details' - }, - { - facility: 'Only Email', - contacts: [ - { - email: 'email@example.com' - } - ] - }, - { - facility: 'Only Phone', - contacts: [ - { - phone: '781-555-0100' - } - ] - }, - { - facility: 'Phone and Email', - contacts: [ - { - email: 'hasemail@example.com', - phone: '781-555-0101' - } - ] - }, - { - facility: 'Only Address', - city: 'Bedford', - state: 'MA', - country: 'US', - zip: '01730', - contacts: [ - { - email: 'email@example.com' - } - ] - } - ] - } - } - } - ); - // Sites should be filled out - expect(result.site).toBeDefined(); - if (result.site) { - expect(result.site.length).toEqual(6); - } - // Make sure each individual site was created properly - they will be contained resources and should be in order - expect(result.contained).toBeDefined(); - if (result.contained) { - // Both 0 and 1 should be empty - expectLocation(result.contained[0]); - expectLocation(result.contained[1]); - expectLocation(result.contained[2], 'Only Email', undefined, 'email@example.com'); - expectLocation(result.contained[3], 'Only Phone', '781-555-0100'); - expectLocation(result.contained[4], 'Phone and Email', '781-555-0101', 'hasemail@example.com'); - expectLocation(result.contained[5], 'Only Address', undefined, undefined, { - use: 'work', - city: 'Bedford', - state: 'MA', - postalCode: '01730', - country: 'US' - }); - } - }); - - function expectEmptyResearchStudy(researchStudy: ResearchStudy): void { - // Technically this is just checking fields updateResearchStudyWithClinicalStudy may change - expect(researchStudy.contained).withContext('contained').not.toBeDefined(); - expect(researchStudy.enrollment).withContext('enrollment').not.toBeDefined(); - expect(researchStudy.description).withContext('description').not.toBeDefined(); - expect(researchStudy.phase).withContext('phase').not.toBeDefined(); - expect(researchStudy.category).withContext('category').not.toBeDefined(); - expect(researchStudy.status).toEqual('active'); - expect(researchStudy.condition).withContext('condition').not.toBeDefined(); - expect(researchStudy.site).withContext('site').not.toBeDefined(); - } - - it("handles JSON with missing data (doesn't crash)", () => { - let researchStudy: ResearchStudy; - // According to the schema, literally everything is optional, so an empty object should "work" - researchStudy = ctg.updateResearchStudyWithClinicalStudy(new ResearchStudyObj('id'), {}); - // Expect nothing to have changed - expectEmptyResearchStudy(researchStudy); - // Some partial JSON - researchStudy = ctg.updateResearchStudyWithClinicalStudy(new ResearchStudyObj('id'), { - protocolSection: { - eligibilityModule: { - genderBased: false, - minimumAge: '18 years', - maximumAge: 'N/A' - } - } - }); - expectEmptyResearchStudy(researchStudy); - }); - }); }); diff --git a/spec/study-trial-converter.spec.ts b/spec/study-trial-converter.spec.ts new file mode 100644 index 0000000..9b4e067 --- /dev/null +++ b/spec/study-trial-converter.spec.ts @@ -0,0 +1,706 @@ +import { Address, FhirResource, Location, ResearchStudy, PlanDefinition } from 'fhir/r4'; +import { getContainedResource, ResearchStudy as ResearchStudyObj } from '../src/research-study'; +import { updateResearchStudyWithClinicalStudy } from '../src/study-fhir-converter'; +import { + DateType, + DesignAllocation, + DesignMasking, + DesignTimePerspective, + InterventionalAssignment, + InterventionType, + ObservationalModel, + PrimaryPurpose, + Status, + Study, + StudyType +} from '../src/ctg-api'; + +// Trial missing summary, inclusion/exclusion criteria, phase and study type +import sampleStudy from './data/NCT02513394.json'; +import trialMissing from './data/resource.json'; +import trialFilled from './data/complete_study.json'; + +describe('filling out a partial trial', () => { + // Use the downloader to load the fixture data + const study = trialMissing.entry[0].resource as ResearchStudy; + let updatedTrial: ResearchStudy; + + beforeAll(async function () { + updatedTrial = updateResearchStudyWithClinicalStudy(study, sampleStudy as Study); + }); + + it('fills in inclusion criteria', () => { + expect(updatedTrial.enrollment).toBeDefined(); + if (updatedTrial.enrollment) { + // Prove enrollment exists to TypeScript + expect(updatedTrial.enrollment.length).toBeGreaterThan(0); + expect(updatedTrial.enrollment[0].display).toBeDefined(); + } + }); + + it('fills in phase', () => { + const codings = updatedTrial.phase?.coding; + expect(codings).toBeDefined(); + // For now, just one + expect(codings?.length).toEqual(1); + expect(codings?.[0].code).toEqual('phase-3'); + }); + + it('fills in categories', () => { + expect(updatedTrial.category).toBeDefined(); + if (updatedTrial.category) { + expect(updatedTrial.category.length).toEqual(5); + const categories = updatedTrial.category.map((item) => item.text); + expect(categories).toHaveSize(5); + expect(categories).toEqual( + jasmine.arrayContaining([ + 'Study Type: Interventional', + 'Intervention Model: Parallel', + 'Primary Purpose: Treatment', + 'Masking: None', + 'Allocation: Randomized' + ]) + ); + } + }); + + it('does not overwrite existing categories', () => { + const researchStudy = new ResearchStudyObj('id'); + researchStudy.category = [{ text: 'Study Type: Do Not Replace' }]; + + updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + designModule: { + studyType: StudyType.INTERVENTIONAL, + designInfo: { + interventionModel: InterventionalAssignment.PARALLEL, + primaryPurpose: PrimaryPurpose.TREATMENT, + maskingInfo: { + masking: DesignMasking.NONE + }, + allocation: DesignAllocation.RANDOMIZED, + timePerspective: DesignTimePerspective.OTHER, + observationalModel: ObservationalModel.CASE_CONTROL + } + } + } + }); + + expect(researchStudy.category).toBeDefined(); + if (researchStudy.category) { + expect(researchStudy.category).toHaveSize(7); + const categories = researchStudy.category.map((item) => item.text); + expect(categories).toHaveSize(7); + expect(categories).toEqual( + jasmine.arrayContaining([ + 'Study Type: Do Not Replace', + 'Intervention Model: Parallel', + 'Primary Purpose: Treatment', + 'Masking: None', + 'Allocation: Randomized', + 'Time Perspective: Other', + 'Observation Model: Case Control' + ]) + ); + } + }); + + it('will retain old categories if not part of standard study design', () => { + const researchStudy = new ResearchStudyObj('id'); + // Empty category but there is an object there for the sake of this test. + researchStudy.category = [{}]; + + updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + designModule: { + studyType: StudyType.INTERVENTIONAL + } + } + }); + + expect(researchStudy.category).toBeDefined(); + if (researchStudy.category) { + expect(researchStudy.category).toHaveSize(2); + } + }); + + it('fills in arms', () => { + expect(updatedTrial.arm).toBeDefined(); + if (updatedTrial.arm) { + expect(updatedTrial.arm).toHaveSize(2); + expect(updatedTrial.arm).toEqual( + jasmine.arrayContaining([ + jasmine.objectContaining({ + name: 'Arm A', + type: { + coding: jasmine.arrayContaining([{ code: 'experimental', display: 'Experimental' }]), + text: 'Experimental' + }, + description: + 'Palbociclib at a dose of 125 mg orally once daily, Day 1 to Day 21 followed by 7 days off treatment in a 28-day cycle for a total duration of 2 years, in addition to standard adjuvant endocrine therapy for a duration of at least 5 years.' + }), + jasmine.objectContaining({ + name: 'Arm B', + type: { coding: jasmine.arrayContaining([{ code: 'other', display: 'Other' }]), text: 'Other' }, + description: 'Standard adjuvant endocrine therapy for a duration of at least 5 years.' + }) + ]) + ); + } + }); + + it('fills in protocol with interventions and arm references', () => { + expect(updatedTrial.protocol).toBeDefined(); + if (updatedTrial.protocol) { + expect(updatedTrial.protocol).toHaveSize(3); + const references: PlanDefinition[] = []; + for (const plan of updatedTrial.protocol) { + if (plan.reference && plan.reference.length > 1) { + const intervention: PlanDefinition = getContainedResource( + updatedTrial, + plan.reference.substring(1) + ) as PlanDefinition; + if (intervention) references.push(intervention); + } else { + fail('PlanDefinition not defined for intervention'); + } + } + + try { + const titles = references.map((item) => item.title); + const types = references.map((item) => (item.type ? item.type.text : null)); + const subjects = references.map((item) => + item.subjectCodeableConcept ? item.subjectCodeableConcept.text : null + ); + + expect(titles).toEqual( + jasmine.arrayContaining([ + 'Palbociclib', + 'Standard Adjuvant Endocrine Therapy', + 'Standard Adjuvant Endocrine Therapy' + ]) + ); + expect(types).toEqual(jasmine.arrayContaining(['Drug', 'Drug', 'Drug'])); + expect(subjects).toEqual(jasmine.arrayContaining(['Arm A', 'Arm A', 'Arm B'])); + } catch (err) { + fail(err); + } + } + }); + + it('fills in interventions even without arms', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + armsInterventionsModule: { + interventions: [ + { + type: InterventionType.BEHAVIORAL, + name: 'Name', + description: 'Description', + otherNames: ['Other name'] + } + ] + } + } + }); + + expect(result.protocol).toBeDefined(); + expect(result.protocol).toHaveSize(1); + + if (result.protocol && result.protocol.length > 0) { + if (result.protocol[0].reference && result.protocol[0].reference.length > 1) { + const intervention: PlanDefinition = getContainedResource( + result, + result.protocol[0].reference.substring(1) + ) as PlanDefinition; + expect(intervention).toEqual( + jasmine.objectContaining({ + resourceType: 'PlanDefinition', + status: 'unknown', + description: 'Description', + title: 'Name', + subtitle: 'Other name', + type: { text: 'Behavioral' } + }) + ); + } + } + }); + + it('fills in interventions with description and subtitle', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + armsInterventionsModule: { + interventions: [ + { + type: InterventionType.BEHAVIORAL, + name: 'Name', + description: 'Description', + otherNames: ['Other name'], + armGroupLabels: ['Arm'] + } + ] + } + } + }); + + expect(result.protocol).toBeDefined(); + expect(result.protocol).toHaveSize(1); + + if (result.protocol && result.protocol.length > 0) { + if (result.protocol[0].reference && result.protocol[0].reference.length > 1) { + const intervention: PlanDefinition = getContainedResource( + result, + result.protocol[0].reference.substring(1) + ) as PlanDefinition; + expect(intervention).toEqual( + jasmine.objectContaining({ + resourceType: 'PlanDefinition', + status: 'unknown', + description: 'Description', + title: 'Name', + subtitle: 'Other name', + type: { text: 'Behavioral' }, + subjectCodeableConcept: { text: 'Arm' } + }) + ); + } + } + }); + + it('fills in period', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + statusModule: { + startDateStruct: { + date: '2023-01', + type: DateType.ACTUAL + }, + completionDateStruct: { + date: '2023-02', + type: DateType.ACTUAL + } + } + } + }); + + expect(result.period).toBeDefined(); + if (result.period) { + expect(result.period.start).toBeDefined(); + expect(result.period.end).toBeDefined(); + + expect(result.period.start).toEqual('2023-01'); + expect(result.period.end).toEqual('2023-02'); + } + }); + + it('fills in start of period even without end', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + statusModule: { + startDateStruct: { + date: '2023-01', + type: DateType.ACTUAL + } + } + } + }); + + expect(result.period).toBeDefined(); + if (result.period) { + expect(result.period.start).toBeDefined(); + expect(result.period.end).not.toBeDefined(); + + expect(result.period.start).toEqual('2023-01'); + } + }); + + it('fills in end of period even without start', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + statusModule: { + completionDateStruct: { + date: '2023-02', + type: DateType.ACTUAL + } + } + } + }); + + expect(result.period).toBeDefined(); + if (result.period) { + expect(result.period.start).not.toBeDefined(); + expect(result.period.end).toBeDefined(); + + expect(result.period.end).toEqual('2023-02'); + } + }); + + it('does not fill in period if not a real date', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + statusModule: { + startDateStruct: { + date: 'Not real', + type: DateType.ACTUAL + }, + completionDateStruct: { + date: 'Not real', + type: DateType.ACTUAL + } + } + } + }); + + expect(result.period).not.toBeDefined(); + }); + + it('fills in description', () => { + expect(updatedTrial.description).toBeDefined(); + }); + + it('fills out the status', () => { + const actual = updateResearchStudyWithClinicalStudy( + { resourceType: 'ResearchStudy', status: 'active' }, + { + protocolSection: { + statusModule: { + lastKnownStatus: Status.AVAILABLE + } + } + } + ); + expect(actual.status).toEqual('completed'); + }); + + it('leaves status alone if unavailable', () => { + const actual = updateResearchStudyWithClinicalStudy( + { resourceType: 'ResearchStudy', status: 'active' }, + { + // Lie about types + protocolSection: { + statusModule: { + lastKnownStatus: 'something invalid' as unknown as Status + } + } + } + ); + // It shouldn't have changed it, because it can't + expect(actual.status).toEqual('active'); + }); + + it('fills out conditions', () => { + const actual = updateResearchStudyWithClinicalStudy( + { resourceType: 'ResearchStudy', status: 'active' }, + { + protocolSection: { + conditionsModule: { + conditions: ['Condition 1', 'Condition 2'] + } + } + } + ); + expect(actual.condition).toBeDefined(); + if (actual.condition) { + expect(actual.condition.length).toEqual(2); + expect(actual.condition[0].text).toEqual('Condition 1'); + expect(actual.condition[1].text).toEqual('Condition 2'); + } + }); + + it('fills in contact', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + contactsLocationsModule: { + centralContacts: [ + { + name: 'First Middle Last, MD', + phone: '1112223333', + email: 'email@example.com' + }, + { + name: 'First2 Middle2 Last2, DO', + phone: '1234567890', + email: 'email2@example.com' + } + ] + } + } + }); + + expect(result.contact).toBeDefined(); + if (result.contact) { + expect(result.contact).toHaveSize(2); + expect(result.contact).toEqual( + jasmine.arrayContaining([ + jasmine.objectContaining({ + name: 'First Middle Last, MD', + telecom: [ + { system: 'email', value: 'email@example.com', use: 'work' }, + { system: 'phone', value: '1112223333', use: 'work' } + ] + }), + jasmine.objectContaining({ + name: 'First2 Middle2 Last2, DO', + telecom: [ + { system: 'email', value: 'email2@example.com', use: 'work' }, + { system: 'phone', value: '1234567890', use: 'work' } + ] + }) + ]) + ); + } + }); + + it('fills in contacts even with missing information', () => { + const researchStudy = new ResearchStudyObj('id'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + contactsLocationsModule: { + centralContacts: [ + { + name: 'First Last', + email: 'email@example.com' + }, + { + name: 'Middle2', + phone: '1234567890' + } + ] + } + } + }); + + expect(result.contact).toBeDefined(); + if (result.contact) { + expect(result.contact).toHaveSize(2); + expect(result.contact).toEqual( + jasmine.arrayContaining([ + jasmine.objectContaining({ + name: 'First Last', + telecom: [{ system: 'email', value: 'email@example.com', use: 'work' }] + }), + jasmine.objectContaining({ + name: 'Middle2', + telecom: [{ system: 'phone', value: '1234567890', use: 'work' }] + }) + ]) + ); + } + }); + + it('does not overwrite site data', () => { + const researchStudy = new ResearchStudyObj('id'); + const location = researchStudy.addSite('Example'); + const result = updateResearchStudyWithClinicalStudy(researchStudy, { + protocolSection: { + contactsLocationsModule: { + locations: [ + { + facility: 'Facility' + } + ] + } + } + }); + expect(result.site).toBeDefined(); + const sites = result.site; + if (sites) { + expect(sites.length).toEqual(1); + if (sites[0]) { + expect(sites[0].reference).toEqual('#' + location.id); + if (location.id) { + const actualLocation = getContainedResource(result, location.id); + expect(actualLocation).not.toBeNull(); + if (actualLocation) { + expect(actualLocation.resourceType).toEqual('Location'); + expect((actualLocation as Location).name).toEqual('Example'); + } + } else { + fail('location.id not defined'); + } + } else { + fail('sites[0] undefined'); + } + } + }); + + it('does not alter a filled out trial', () => { + // Clone the trial in the dumbest but also most sensible way + const exampleStudy: ResearchStudy = JSON.parse(JSON.stringify(trialFilled)); + updateResearchStudyWithClinicalStudy(exampleStudy, sampleStudy as Study); + // Currently active gets overwritten intentioanlly, so set the example + // back to its original value even if it changed + // (Note that the "as" *should* verify that the underlying JSON value is + // in fact valid at compile time. I think.) + exampleStudy.status = trialFilled.status as ResearchStudy['status']; + // Nothing should have changed + expect(exampleStudy).toEqual(trialFilled as ResearchStudy); + }); + + function expectTelecom(location: Location, type: 'phone' | 'email', expectedValue: string | null) { + // Look through the telecoms + // If the expected value is null, telecom must be defined, otherwise it + // may be empty + if (expectedValue !== null) expect(location.telecom).toBeDefined(); + if (location.telecom) { + // If we're expecting a telecom we're expecting it to appear exactly once + let found = 0; + for (const telecom of location.telecom) { + if (telecom.system === type) { + found++; + if (found > 1) { + fail(`Found an extra ${type}`); + } + if (expectedValue === null) { + // If null, it wasn't expected at all + fail(`Expected no ${type}, but one was found`); + } else { + expect(telecom.use).toEqual('work'); + expect(telecom.value).toEqual(expectedValue); + } + } + } + if (expectedValue !== null && found === 0) { + fail(`Expected one ${type}, not found`); + } + } + } + + function expectLocation( + resource: FhirResource, + expectedName?: string, + expectedPhone?: string, + expectedEmail?: string, + expectedAddress?: Address + ) { + if (resource.resourceType === 'Location') { + const location = resource as Location; + if (expectedName) { + expect(location.name).toEqual(expectedName); + } else { + expect(location.name).not.toBeDefined(); + } + expectTelecom(location, 'phone', expectedPhone || null); + expectTelecom(location, 'email', expectedEmail || null); + if (expectedAddress) { + expect(location.address).toBeDefined(); + expect(location.address).toEqual(expectedAddress); + } else { + expect(location.address).not.toBeDefined(); + } + } else { + fail(`Expected Location, got ${resource.resourceType}`); + } + } + + it('fills out sites as expected', () => { + const result = updateResearchStudyWithClinicalStudy( + { resourceType: 'ResearchStudy', status: 'active' }, + { + protocolSection: { + contactsLocationsModule: { + locations: [ + // Everything in location is optional, so this is valid: + {}, + { + facility: 'No Details' + }, + { + facility: 'Only Email', + contacts: [ + { + email: 'email@example.com' + } + ] + }, + { + facility: 'Only Phone', + contacts: [ + { + phone: '781-555-0100' + } + ] + }, + { + facility: 'Phone and Email', + contacts: [ + { + email: 'hasemail@example.com', + phone: '781-555-0101' + } + ] + }, + { + facility: 'Only Address', + city: 'Bedford', + state: 'MA', + country: 'US', + zip: '01730' + } + ] + } + } + } + ); + // Sites should be filled out + expect(result.site).toBeDefined(); + if (result.site) { + expect(result.site.length).toEqual(6); + } + // Make sure each individual site was created properly - they will be contained resources and should be in order + expect(result.contained).toBeDefined(); + if (result.contained) { + expectLocation(result.contained[0]); + expectLocation(result.contained[1], 'No Details'); + expectLocation(result.contained[2], 'Only Email', undefined, 'email@example.com'); + expectLocation(result.contained[3], 'Only Phone', '781-555-0100'); + expectLocation(result.contained[4], 'Phone and Email', '781-555-0101', 'hasemail@example.com'); + expectLocation(result.contained[5], 'Only Address', undefined, undefined, { + use: 'work', + city: 'Bedford', + state: 'MA', + postalCode: '01730', + country: 'US' + }); + } + }); + + function expectEmptyResearchStudy(researchStudy: ResearchStudy): void { + // Technically this is just checking fields updateResearchStudyWithClinicalStudy may change + expect(researchStudy.contained).withContext('contained').not.toBeDefined(); + expect(researchStudy.enrollment).withContext('enrollment').not.toBeDefined(); + expect(researchStudy.description).withContext('description').not.toBeDefined(); + expect(researchStudy.phase).withContext('phase').not.toBeDefined(); + expect(researchStudy.category).withContext('category').not.toBeDefined(); + expect(researchStudy.status).toEqual('active'); + expect(researchStudy.condition).withContext('condition').not.toBeDefined(); + expect(researchStudy.site).withContext('site').not.toBeDefined(); + } + + it("handles JSON with missing data (doesn't crash)", () => { + let researchStudy: ResearchStudy; + // According to the schema, literally everything is optional, so an empty object should "work" + researchStudy = updateResearchStudyWithClinicalStudy(new ResearchStudyObj('id'), {}); + // Expect nothing to have changed + expectEmptyResearchStudy(researchStudy); + // Some partial JSON + researchStudy = updateResearchStudyWithClinicalStudy(new ResearchStudyObj('id'), { + protocolSection: { + eligibilityModule: { + genderBased: false, + minimumAge: '18 years', + maximumAge: 'N/A' + } + } + }); + expectEmptyResearchStudy(researchStudy); + }); +}); diff --git a/src/clinical-trials-gov.ts b/src/clinical-trials-gov.ts index 376b34d..5dd5c2d 100644 --- a/src/clinical-trials-gov.ts +++ b/src/clinical-trials-gov.ts @@ -7,6 +7,12 @@ import { debuglog } from 'util'; // Re-export the study type export { Study }; +/** + * The default endpoint if none is specified: the clinicaltrials.gov v2 API + * endpoint. + */ +export const DEFAULT_ENDPOINT = 'https://clinicaltrials.gov/api/v2'; + type Logger = (message: string, ...param: unknown[]) => void; /** @@ -26,7 +32,7 @@ export class ClinicalTrialsGovAPI { debuglog('ctgov-api', (log) => { this._log = log; }); - this._endpoint = options?.endpoint ?? 'https://clinicaltrials.gov/api/v2'; + this._endpoint = options?.endpoint ?? DEFAULT_ENDPOINT; } /** diff --git a/src/clinicaltrialsgov.ts b/src/clinicaltrialsgov.ts index 0081a2a..fb84e84 100644 --- a/src/clinicaltrialsgov.ts +++ b/src/clinicaltrialsgov.ts @@ -16,27 +16,15 @@ * This will fill out whatever can be filled out within the given studies. */ -import fs from 'fs'; +import fs, { WriteFileOptions } from 'fs'; import path from 'path'; import * as https from 'https'; // Needed for types: import * as http from 'http'; import { debuglog } from 'util'; -import { - CodeableConcept, - ContactDetail, - ContactPoint, - Group, - Location, - PlanDefinition, - Reference, - ResearchStudy, - ResearchStudyArm -} from 'fhir/r4'; +import { ResearchStudy } from 'fhir/r4'; import { ClinicalTrialsGovAPI, Study } from './clinical-trials-gov'; -import { Status } from './ctg-api'; -import { addContainedResource, addToContainer } from './research-study'; -import { WriteFileOptions } from 'fs'; +import { updateResearchStudyWithClinicalStudy } from './study-fhir-converter'; /** * Logger type from the NodeJS utilities. (The TypeScript definitions for Node @@ -124,7 +112,8 @@ export function findNCTNumbers(studies: ResearchStudy[]): Map([ - [Status.ACTIVE_NOT_RECRUITING, 'closed-to-accrual'], - [Status.COMPLETED, 'completed'], - // FIXME: This does not appear to have a proper mapping - [Status.ENROLLING_BY_INVITATION, 'active'], - [Status.NOT_YET_RECRUITING, 'approved'], - [Status.RECRUITING, 'active'], - [Status.SUSPENDED, 'temporarily-closed-to-accrual'], - [Status.TERMINATED, 'administratively-completed'], - [Status.WITHDRAWN, 'withdrawn'], - [Status.AVAILABLE, 'completed'], - [Status.NO_LONGER_AVAILABLE, 'closed-to-accrual'], - [Status.TEMPORARILY_NOT_AVAILABLE, 'temporarily-closed-to-accrual'], - [Status.APPROVED_FOR_MARKETING, 'completed'], - // FIXME: This does not appear to have a proper mapping - [Status.WITHHELD, 'in-review'], - // FIXME: This does not appear to have a proper mapping - [Status.UNKNOWN, 'in-review'] -]); - -export function convertClincalStudyStatusToFHIRStatus(status: Status): ResearchStudy['status'] | undefined { - return CLINICAL_STATUS_MAP.get(status); -} - -function convertToTitleCase(str: string): string { - return str.replace(/\b(\w+)\b/g, (s) => s.substring(0, 1) + s.substring(1).toLowerCase()).replace(/_/g, ' '); -} - -function convertArrayToCodeableConcept(trialConditions: string[]): CodeableConcept[] { - const fhirConditions: CodeableConcept[] = []; - for (const condition of trialConditions) { - fhirConditions.push({ text: condition }); - } - return fhirConditions; -} - /** * Subset of the Node.js fs module necessary to handle the cache file system, allowing it to be overridden if * necessary. @@ -196,7 +149,12 @@ export interface FileSystem { mkdir: (path: string, callback: (err: NodeJS.ErrnoException | null) => void) => void; readdir: (path: string, callback: (err: NodeJS.ErrnoException | null, files: string[]) => void) => void; stat: (path: string, callback: (err: NodeJS.ErrnoException | null, stat: fs.Stats) => void) => void; - writeFile: (file: string, data: Buffer | string, options: WriteFileOptions, callback: (err: Error | null) => void) => void; + writeFile: ( + file: string, + data: Buffer | string, + options: WriteFileOptions, + callback: (err: Error | null) => void + ) => void; unlink: (path: string, callback: (err: NodeJS.ErrnoException | null) => void) => void; } @@ -577,7 +535,7 @@ export class ClinicalTrialsGovService { */ constructor(public readonly dataDir: string, options?: ClinicalTrialsGovServiceOptions) { this.cacheDataDir = path.join(dataDir, 'data'); - const log = options ? options.log : null; + const log = options ? options.log : undefined; // If no log was given, create it this.log = log ?? debuglog('ctgovservice'); // Default expiration timeout to an hour @@ -585,7 +543,7 @@ export class ClinicalTrialsGovService { // Default cleanup interval to an hour this.cleanupInterval = options?.cleanInterval ?? 60 * 60 * 1000; this.fs = options?.fs ?? fs; - this.service = new ClinicalTrialsGovAPI(); + this.service = new ClinicalTrialsGovAPI({ logger: this.log }); } /** @@ -799,12 +757,12 @@ export class ClinicalTrialsGovService { } } // Now that we have the IDs, we can split them into download requests - const promises: Promise[] = []; + const promises: Promise[] = []; for (let start = 0; start < ids.length; start += this.maxTrialsPerRequest) { promises.push(this.downloadTrials(ids.slice(start, Math.min(start + this.maxTrialsPerRequest, ids.length)))); } return Promise.all(promises).then(() => { - // This exists solely to turn the result from an array of nothing into a single nothing + // This exists solely to turn the result from an array of success/fails into a single nothing }); } @@ -813,9 +771,11 @@ export class ClinicalTrialsGovService { * always replace trials if they exist. * * @param ids the IDs of the trials to download - * @returns a Promise that resolves to the path where the given IDs were downloaded + * @returns true if the request succeeded, false if it failed for some reason - the exact reason can be logged but + * is otherwise silently eaten */ - protected async downloadTrials(ids: string[]): Promise { + protected async downloadTrials(ids: string[]): Promise { + let success = false; // Now that we're starting to download clinical trials, immediately create pending entries for them. for (const id of ids) { if (!this.cache.has(id)) { @@ -825,15 +785,12 @@ export class ClinicalTrialsGovService { try { const studies = await this.service.fetchStudies(ids); for (const study of studies) { - // Grab the NCT ID of this study - const nctId = study.protocolSection?.identificationModule?.nctId; - // TODO: Handle aliases? - if (nctId) { - await this.addCacheEntry(nctId, JSON.stringify(study)); - } + await this.addCacheEntry(study); } + success = true; } catch (ex) { // If an error occurred while fetching studies, every cache entry we just loaded may be invalid. + this.log('Error while fetching trials: %o', ex); this.log('Invalidating cache entry IDs for: %s', ids); for (const id of ids) { const entry = this.cache.get(id); @@ -851,6 +808,7 @@ export class ClinicalTrialsGovService { this.cache.delete(id); } } + return success; } /** @@ -863,28 +821,6 @@ export class ClinicalTrialsGovService { return https.get(url, callback); } - /** - * Internal method to create a temporary file within the data directory. Temporary files created via this method are - * not automatically deleted and need to be cleaned up by the caller. - */ - private createTemporaryFileName(): string { - // For now, temporary files are always "temp-[DATE]-[PID]-[TEMPID]" where [TEMPID] is an incrementing internal ID. - // This means that temp files should never collide across processes or within a process. However, if a temporary - // file is created and then the server is restarted and it somehow manages to get the same PID, a collision can - // happen in that case. - const now = new Date(); - return [ - 'temp-', - now.getUTCFullYear(), - (now.getUTCMonth() + 1).toString().padStart(2, '0'), - now.getUTCDate().toString().padStart(2, '0'), - '-', - process.pid, - '-', - this.tempId++ - ].join(''); - } - /** * Create a path to the data file that stores data about a cache entry. * @param nctNumber the NCT number @@ -895,19 +831,30 @@ export class ClinicalTrialsGovService { return path.join(this.cacheDataDir, nctNumber + '.json'); } - private addCacheEntry(nctNumber: NCTNumber, contents: string): Promise { - const filename = path.join(this.cacheDataDir, nctNumber + '.json'); + private addCacheEntry(study: Study): Promise { + // See if we can locate an NCT number for this study. + const nctNumber = study.protocolSection?.identificationModule?.nctId; + if (typeof nctNumber !== 'string') { + this.log( + 'Ignoring study object from server: unable to locate an NCT ID for it! (protocolSection.identificationModule.nctId missing or not a string)' + ); + return Promise.resolve(); + } + const filename = this.pathForNctNumber(nctNumber); // The cache entry should already exist const entry = this.cache.get(nctNumber); // Tell the entry that we are writing data if (entry) { entry.found(); + } else { + this.log('No cache entry for %s! NOT writing it to cache! (Got back a different NCT number?)', nctNumber); + return Promise.resolve(); } return new Promise((resolve, reject) => { // This indicates whether no error was raised - close can get called anyway, and it's slightly cleaner to just // mark that an error happened and ignore the close handler if it did. // (This also potentially allows us to do additional cleanup on close if an error happened.) - this.fs.writeFile(filename, contents, 'utf8', (err) => { + this.fs.writeFile(filename, JSON.stringify(study), 'utf8', (err) => { if (err) { this.log('Unable to create file [%s]: %o', filename, err); // If the cache entry exists in pending mode, delete it - we failed to create this entry @@ -985,277 +932,3 @@ export function createClinicalTrialsGovService( ): Promise { return ClinicalTrialsGovService.create(dataDir, options); } - -/** - * Updates a research study with data from a clinical study off the ClinicalTrials.gov website. This will only update - * fields that do not have data, it will not overwrite any existing data. - * - * Mapping as defined by https://www.hl7.org/fhir/researchstudy-mappings.html#clinicaltrials-gov - * - * @param result the research study to update - * @param study the clinical study to use to update - */ -export function updateResearchStudyWithClinicalStudy( - result: ResearchStudy, - study: Study -): ResearchStudy { - const protocolSection = study.protocolSection; - // If there is no protocol section, we can't do anything. - if (!protocolSection) { - return result; - } - if (!result.enrollment) { - const eligibility = protocolSection.eligibilityModule; - if (eligibility) { - const criteria = eligibility.eligibilityCriteria; - if (criteria) { - const group: Group = { resourceType: 'Group', id: 'group' + result.id, type: 'person', actual: false }; - const reference = addContainedResource(result, group); - reference.display = criteria; - result.enrollment = [reference]; - } - } - } - - if (!result.description) { - const briefSummary = protocolSection.descriptionModule?.briefSummary; - if (briefSummary) { - result.description = briefSummary; - } - } - - if (!result.phase) { - const phase = protocolSection.designModule?.phases; - if (phase && phase.length > 0) { - // For now, just grab whatever the first phase is - result.phase = { - coding: [ - { - system: 'http://terminology.hl7.org/CodeSystem/research-study-phase', - code: phase[0], - display: phase[0] - } - ], - text: phase[0] - }; - } - } - - // ------- Category - // Since we may not have all of the Study design in the result, we need to do a merge of data - const studyType = study.protocolSection?.designModule?.studyType; - const categories: CodeableConcept[] = result.category ? result.category : []; - - // We need to determine what categories have already been declared. - const types = categories.map((item) => { - const sep = item.text?.split(':'); - return sep ? sep[0] : ''; - }); - - if (studyType && !types.includes('Study Type')) { - categories.push({ text: 'Study Type: ' + studyType[0] }); - } - - const designInfo = protocolSection.designModule?.designInfo; - if (designInfo) { - if (designInfo.interventionModelDescription && !types.includes('Intervention Model')) { - categories.push({ text: 'Intervention Model: ' + designInfo.interventionModelDescription }); - } - - if (designInfo.primaryPurpose && !types.includes('Primary Purpose')) { - categories.push({ text: 'Primary Purpose: ' + convertToTitleCase(designInfo.primaryPurpose) }); - } - - if (designInfo.maskingInfo?.maskingDescription && !types.includes('Masking')) { - categories.push({ text: 'Masking: ' + designInfo.maskingInfo?.maskingDescription }); - } - - if (designInfo.allocation && !types.includes('Allocation')) { - categories.push({ text: 'Allocation: ' + convertToTitleCase(designInfo.allocation) }); - } - - if (designInfo.timePerspective && !types.includes('Time Perspective')) { - categories.push({ text: 'Time Perspective: ' + convertToTitleCase(designInfo.timePerspective) }); - } - - if (designInfo.observationalModel && !types.includes('Observation Model')) { - categories.push({ text: 'Observation Model: ' + convertToTitleCase(designInfo.observationalModel) }); - } - } - - if (categories.length > 1) result.category = categories; - // ------- Category - - // Right now, the default value for a research study is "active". If CT.G - // knows better, then allow it to override that. - if (!result.status || result.status == 'active') { - const overallStatus = protocolSection.statusModule?.lastKnownStatus; - if (overallStatus) { - const status = convertClincalStudyStatusToFHIRStatus(overallStatus); - if (typeof status !== 'undefined') result.status = status; - } - } - - if (!result.condition) { - if (protocolSection.conditionsModule?.conditions) { - result.condition = convertArrayToCodeableConcept(protocolSection.conditionsModule?.conditions); - } - } - - if (!result.site) { - const locations = protocolSection.contactsLocationsModule?.locations; - if (locations) { - let index = 0; - for (const location of locations) { - const fhirLocation: Location = { resourceType: 'Location', id: 'location-' + index++ }; - if (location) { - if (location.facility) fhirLocation.name = location.facility; - if (location.city && location.country) { - // Also add the address information - fhirLocation.address = { use: 'work', city: location.city, country: location.country }; - if (location.state) { - fhirLocation.address.state = location.state; - } - if (location.zip) { - fhirLocation.address.postalCode = location.zip; - } - } - } - if (location.contacts) { - for (const contact of location.contacts) { - if (contact.email) { - addToContainer(fhirLocation, 'telecom', { - system: 'email', - value: contact.email, - use: 'work' - }); - } - if (contact.phone) { - addToContainer(fhirLocation, 'telecom', { - system: 'phone', - value: contact.phone, - use: 'work' - }); - } - } - } - addToContainer(result, 'site', addContainedResource(result, fhirLocation)); - } - } - } - - if (!result.arm) { - const armGroups = protocolSection.armsInterventionsModule?.armGroups; - if (armGroups) { - for (const studyArm of armGroups) { - const label = studyArm.label; - if (label) { - const arm: ResearchStudyArm = { - name: label, - ...(studyArm.type && { - type: { - coding: [ - { - code: studyArm.type, - display: studyArm.type - } - ], - text: studyArm.type - } - }), - ...(studyArm.description && { description: studyArm.description[0] }) - }; - - addToContainer(result, 'arm', arm); - } - } - } - } - - if (!result.protocol) { - const interventions = protocolSection.armsInterventionsModule?.interventions; - if (interventions) { - let index = 0; - for (const intervention of interventions) { - if (intervention.armGroupLabels) { - for (const armGroupLabel of intervention.armGroupLabels) { - let plan: PlanDefinition = { resourceType: 'PlanDefinition', status: 'unknown', id: 'plan-' + index++ }; - - plan = { - ...plan, - ...(intervention.description && { description: intervention.description }), - ...(intervention.name && { title: intervention.name }), - ...(intervention.otherNames && intervention.otherNames.length > 0 && { subtitle: intervention.otherNames[0] }), - ...(intervention.type && { type: { text: intervention.type } }), - ...{ subjectCodeableConcept: { text: armGroupLabel } } - }; - - addToContainer( - result, - 'protocol', - addContainedResource(result, plan) - ); - } - } else { - let plan: PlanDefinition = { resourceType: 'PlanDefinition', status: 'unknown', id: 'plan-' + index++ }; - - plan = { - ...plan, - ...(intervention.description && { description: intervention.description }), - ...(intervention.name && { title: intervention.name }), - ...(intervention.otherNames && intervention.otherNames.length > 0 && { subtitle: intervention.otherNames[0] }), - ...(intervention.type && { type: { text: intervention.type } }) - }; - - addToContainer(result, 'protocol', addContainedResource(result, plan)); - } - } - } - } - - if (!result.contact) { - const contacts = protocolSection.contactsLocationsModule?.centralContacts; - - if (contacts) { - for (const contact of contacts) { - if (contact != undefined) { - const contactName = contact.name; - if (contactName) { - const fhirContact: ContactDetail = { name: contactName }; - if (contact.email) { - addToContainer(fhirContact, 'telecom', { - system: 'email', - value: contact.email, - use: 'work' - }); - } - if (contact.phone) { - addToContainer(fhirContact, 'telecom', { - system: 'phone', - value: contact.phone, - use: 'work' - }); - } - addToContainer(result, 'contact', fhirContact); - } - } - } - } - } - - if (!result.period) { - const startDate = protocolSection.statusModule?.startDateStruct?.date; - const completionDate = protocolSection.statusModule?.completionDateStruct?.date; - if (startDate || completionDate) { - // Set the period object as appropriate - const period = { - ...(startDate && { start: startDate }), - ...(completionDate && { end: completionDate }) - }; - - if (Object.keys(period).length != 0) result.period = period; - } - } - - return result; -} diff --git a/src/fhir-type-guards.ts b/src/fhir-type-guards.ts index 2db7a9d..3391639 100644 --- a/src/fhir-type-guards.ts +++ b/src/fhir-type-guards.ts @@ -28,3 +28,15 @@ export function isResearchStudy(o: unknown): o is ResearchStudy { } return (o as ResearchStudy).resourceType === 'ResearchStudy'; } + +/** + * Determines if the given string is a valid FHIR date. + * @param str the date string to check + */ +export function isFhirDate(str: string): boolean { + // This RegExp is from the FHIR spec itself: + // http://hl7.org/fhir/R4/datatypes.html#dateTime + return /^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?$/.test( + str + ); +} diff --git a/src/study-fhir-converter.ts b/src/study-fhir-converter.ts new file mode 100644 index 0000000..c02ca53 --- /dev/null +++ b/src/study-fhir-converter.ts @@ -0,0 +1,353 @@ +import { Phase, Status, Study } from './ctg-api'; +import { + CodeableConcept, + ContactDetail, + ContactPoint, + Group, + Location, + PlanDefinition, + Reference, + ResearchStudy, + ResearchStudyArm +} from 'fhir/r4'; +import { isFhirDate } from './fhir-type-guards'; +import { addContainedResource, addToContainer } from './research-study'; + +const PHASE_MAP = new Map([ + [Phase.NA, 'n-a' ], + [Phase.EARLY_PHASE1, 'early-phase-1' ], + [Phase.PHASE1, 'phase-1' ], + [Phase.PHASE2, 'phase-2' ], +[Phase.PHASE3, 'phase-3'], +[Phase.PHASE4, 'phase-4'] +]); + +export function convertToResearchStudyPhase(phase: Phase): string | undefined { + return PHASE_MAP.get(phase); +} + +const CLINICAL_STATUS_MAP = new Map([ + [Status.ACTIVE_NOT_RECRUITING, 'closed-to-accrual'], + [Status.COMPLETED, 'completed'], + // FIXME: This does not appear to have a proper mapping + [Status.ENROLLING_BY_INVITATION, 'active'], + [Status.NOT_YET_RECRUITING, 'approved'], + [Status.RECRUITING, 'active'], + [Status.SUSPENDED, 'temporarily-closed-to-accrual'], + [Status.TERMINATED, 'administratively-completed'], + [Status.WITHDRAWN, 'withdrawn'], + [Status.AVAILABLE, 'completed'], + [Status.NO_LONGER_AVAILABLE, 'closed-to-accrual'], + [Status.TEMPORARILY_NOT_AVAILABLE, 'temporarily-closed-to-accrual'], + [Status.APPROVED_FOR_MARKETING, 'completed'], + // FIXME: This does not appear to have a proper mapping + [Status.WITHHELD, 'in-review'], + // FIXME: This does not appear to have a proper mapping + [Status.UNKNOWN, 'in-review'] +]); + +export function convertClincalStudyStatusToFHIRStatus(status: Status): ResearchStudy['status'] | undefined { + return CLINICAL_STATUS_MAP.get(status); +} + +function convertToTitleCase(str: string): string { + return str + .replace(/([A-Z]+)/g, (s) => (s.length > 1 ? s.substring(0, 1) + s.substring(1).toLowerCase() : s)) + .replace(/_/g, ' '); +} + +function convertArrayToCodeableConcept(trialConditions: string[]): CodeableConcept[] { + const fhirConditions: CodeableConcept[] = []; + for (const condition of trialConditions) { + fhirConditions.push({ text: condition }); + } + return fhirConditions; +} + +/** + * Updates a research study with data from a clinical study off the ClinicalTrials.gov website. This will only update + * fields that do not have data, it will not overwrite any existing data. + * + * Mapping as defined by https://www.hl7.org/fhir/researchstudy-mappings.html#clinicaltrials-gov + * + * @param result the research study to update + * @param study the clinical study to use to update + */ +export function updateResearchStudyWithClinicalStudy(result: ResearchStudy, study: Study): ResearchStudy { + const protocolSection = study.protocolSection; + // If there is no protocol section, we can't do anything. + if (!protocolSection) { + return result; + } + if (!result.enrollment) { + const eligibility = protocolSection.eligibilityModule; + if (eligibility) { + const criteria = eligibility.eligibilityCriteria; + if (criteria) { + const group: Group = { resourceType: 'Group', id: 'group' + result.id, type: 'person', actual: false }; + const reference = addContainedResource(result, group); + reference.display = criteria; + result.enrollment = [reference]; + } + } + } + + if (!result.description) { + const briefSummary = protocolSection.descriptionModule?.briefSummary; + if (briefSummary) { + result.description = briefSummary; + } + } + + if (!result.phase) { + const phase = protocolSection.designModule?.phases; + if (phase && phase.length > 0) { + // For now, just grab whatever the first phase is + // TODO: handle the somewhat weirder "phase-1-phase-2" items + const code = convertToResearchStudyPhase(phase[0]); + if (code) { + const display = code.replace(/-/g, ' '); + result.phase = { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/research-study-phase', + code: code, + display: display + } + ], + text: display + }; + } + } + } + + // ------- Category + // Since we may not have all of the Study design in the result, we need to do a merge of data + const categories: CodeableConcept[] = result.category ? result.category : []; + + // We need to determine what categories have already been declared. + const types = categories.map((item) => { + const sep = item.text?.split(':'); + return sep ? sep[0] : ''; + }); + + const studyType = study.protocolSection?.designModule?.studyType; + if (studyType && !types.includes('Study Type')) { + categories.push({ text: 'Study Type: ' + convertToTitleCase(studyType) }); + } + + const designInfo = protocolSection.designModule?.designInfo; + if (designInfo) { + if (!types.includes('Intervention Model')) { + if (designInfo.interventionModel) { + categories.push({ text: 'Intervention Model: ' + convertToTitleCase(designInfo.interventionModel)}); + } else if (designInfo.interventionModelDescription) { + categories.push({ text: 'Intervention Model: ' + designInfo.interventionModelDescription }); + } + } + + if (designInfo.primaryPurpose && !types.includes('Primary Purpose')) { + categories.push({ text: 'Primary Purpose: ' + convertToTitleCase(designInfo.primaryPurpose) }); + } + + const maskingInfo = designInfo.maskingInfo; + if (maskingInfo && !types.includes('Masking')) { + // It's unclear exactly how to convert this + const masking = maskingInfo.masking ? convertToTitleCase(maskingInfo.masking) : maskingInfo.maskingDescription; + if (masking) { + categories.push({ text: 'Masking: ' + masking }); + } + } + + if (designInfo.allocation && !types.includes('Allocation')) { + categories.push({ text: 'Allocation: ' + convertToTitleCase(designInfo.allocation) }); + } + + if (designInfo.timePerspective && !types.includes('Time Perspective')) { + categories.push({ text: 'Time Perspective: ' + convertToTitleCase(designInfo.timePerspective) }); + } + + if (designInfo.observationalModel && !types.includes('Observation Model')) { + categories.push({ text: 'Observation Model: ' + convertToTitleCase(designInfo.observationalModel) }); + } + } + + if (categories.length > 1) result.category = categories; + // ------- Category + + // Right now, the default value for a research study is "active". If CT.G + // knows better, then allow it to override that. + if (!result.status || result.status == 'active') { + const overallStatus = protocolSection.statusModule?.lastKnownStatus; + if (overallStatus) { + const status = convertClincalStudyStatusToFHIRStatus(overallStatus); + if (typeof status !== 'undefined') result.status = status; + } + } + + if (!result.condition) { + if (protocolSection.conditionsModule?.conditions) { + result.condition = convertArrayToCodeableConcept(protocolSection.conditionsModule?.conditions); + } + } + + if (!result.site) { + const locations = protocolSection.contactsLocationsModule?.locations; + if (locations) { + let index = 0; + for (const location of locations) { + const fhirLocation: Location = { resourceType: 'Location', id: 'location-' + index++ }; + if (location) { + if (location.facility) fhirLocation.name = location.facility; + if (location.city && location.country) { + // Also add the address information + fhirLocation.address = { use: 'work', city: location.city, country: location.country }; + if (location.state) { + fhirLocation.address.state = location.state; + } + if (location.zip) { + fhirLocation.address.postalCode = location.zip; + } + } + } + if (location.contacts) { + for (const contact of location.contacts) { + if (contact.email) { + addToContainer(fhirLocation, 'telecom', { + system: 'email', + value: contact.email, + use: 'work' + }); + } + if (contact.phone) { + addToContainer(fhirLocation, 'telecom', { + system: 'phone', + value: contact.phone, + use: 'work' + }); + } + } + } + addToContainer(result, 'site', addContainedResource(result, fhirLocation)); + } + } + } + + if (!result.arm) { + const armGroups = protocolSection.armsInterventionsModule?.armGroups; + if (armGroups) { + for (const studyArm of armGroups) { + const label = studyArm.label; + if (label) { + const arm: ResearchStudyArm = { + name: label, + ...(studyArm.type && { + type: { + coding: [ + { + // It's unclear if there is any coding system for this, so for now, make it look like FHIR + code: studyArm.type.replace(/_/g, '-').toLowerCase(), + display: convertToTitleCase(studyArm.type) + } + ], + text: convertToTitleCase(studyArm.type) + } + }), + ...(studyArm.description && { description: studyArm.description }) + }; + + addToContainer(result, 'arm', arm); + } + } + } + } + + if (!result.protocol) { + const interventions = protocolSection.armsInterventionsModule?.interventions; + if (interventions) { + let index = 0; + for (const intervention of interventions) { + if (intervention.armGroupLabels) { + for (const armGroupLabel of intervention.armGroupLabels) { + let plan: PlanDefinition = { resourceType: 'PlanDefinition', status: 'unknown', id: 'plan-' + index++ }; + + plan = { + ...plan, + ...(intervention.description && { description: intervention.description }), + ...(intervention.name && { title: intervention.name }), + ...(intervention.otherNames && + intervention.otherNames.length > 0 && { subtitle: intervention.otherNames[0] }), + ...(intervention.type && { type: { text: convertToTitleCase(intervention.type) } }), + ...{ subjectCodeableConcept: { text: armGroupLabel } } + }; + + addToContainer( + result, + 'protocol', + addContainedResource(result, plan) + ); + } + } else { + let plan: PlanDefinition = { resourceType: 'PlanDefinition', status: 'unknown', id: 'plan-' + index++ }; + + plan = { + ...plan, + ...(intervention.description && { description: intervention.description }), + ...(intervention.name && { title: intervention.name }), + ...(intervention.otherNames && + intervention.otherNames.length > 0 && { subtitle: intervention.otherNames[0] }), + ...(intervention.type && { type: { text: convertToTitleCase(intervention.type) } }) + }; + + addToContainer(result, 'protocol', addContainedResource(result, plan)); + } + } + } + } + + if (!result.contact) { + const contacts = protocolSection.contactsLocationsModule?.centralContacts; + + if (contacts) { + for (const contact of contacts) { + if (contact != undefined) { + const contactName = contact.name; + if (contactName) { + const fhirContact: ContactDetail = { name: contactName }; + if (contact.email) { + addToContainer(fhirContact, 'telecom', { + system: 'email', + value: contact.email, + use: 'work' + }); + } + if (contact.phone) { + addToContainer(fhirContact, 'telecom', { + system: 'phone', + value: contact.phone, + use: 'work' + }); + } + addToContainer(result, 'contact', fhirContact); + } + } + } + } + } + + if (!result.period) { + const startDate = protocolSection.statusModule?.startDateStruct?.date; + const completionDate = protocolSection.statusModule?.completionDateStruct?.date; + if (startDate || completionDate) { + // Set the period object as appropriate + const period = { + ...(startDate && isFhirDate(startDate) && { start: startDate }), + ...(completionDate && isFhirDate(completionDate) && { end: completionDate }) + }; + + if (Object.keys(period).length != 0) result.period = period; + } + } + + return result; +}