diff --git a/data/core/patient-intake-questionnaire.json b/data/core/patient-intake-questionnaire.json index a53528d..43f3392 100644 --- a/data/core/patient-intake-questionnaire.json +++ b/data/core/patient-intake-questionnaire.json @@ -220,6 +220,52 @@ } ] }, + { + "linkId": "vaccination-history", + "text": "Vaccination History", + "type": "group", + "repeats": true, + "item": [ + { + "linkId": "immunization-vaccine", + "text": "Vaccine", + "type": "choice", + "answerValueSet": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113762.1.4.1010.6" + }, + { + "linkId": "immunization-date", + "text": "Administration Date", + "type": "dateTime" + } + ] + }, + { + "linkId": "preferred-pharmacy", + "text": "Preferred Pharmacy", + "type": "group", + "item": [ + { + "linkId": "preferred-pharmacy-reference", + "text": "Pharmacy", + "type": "reference", + "extension": [ + { + "id": "reference-pharmacy", + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-referenceResource", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/fhir-types", + "display": "Organizations", + "code": "Organization" + } + ] + } + } + ] + } + ] + }, { "linkId": "coverage-information", "text": "Coverage Information", diff --git a/data/example/example-organization-data.json b/data/example/example-organization-data.json index 73dcebf..8fd6d77 100644 --- a/data/example/example-organization-data.json +++ b/data/example/example-organization-data.json @@ -3,7 +3,7 @@ "type": "transaction", "entry": [ { - "fullUrl": "urn:uuid:b884050a-bc99-472c-ad66-4f903fe84aa8", + "fullUrl": "urn:uuid:38bcbdea-1b0c-4e9d-9548-4711a999acf4", "request": { "method": "PUT", "url": "Organization?identifier=blue-cross" @@ -59,6 +59,64 @@ } ] } + }, + { + "fullUrl": "urn:uuid:56867ac2-9c23-415f-a9e8-0bc95a87e986", + "request": { + "method": "PUT", + "url": "Organization?identifier=cvs-pharmacy" + }, + "resource": { + "resourceType": "Organization", + "name": "CVS Pharmacy", + "active": true, + "identifier": [ + { + "system": "http://example.com/pharmacy-orgs", + "value": "cvs-pharmacy" + } + ], + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/organization-type", + "code": "prov", + "display": "Healthcare Provider" + } + ] + } + ] + } + }, + { + "fullUrl": "urn:uuid:d8cc4839-3abf-4a58-8895-190b06551b66", + "request": { + "method": "PUT", + "url": "Organization?identifier=medplum-pharmacy" + }, + "resource": { + "resourceType": "Organization", + "name": "Medplum Pharmacy", + "active": true, + "identifier": [ + { + "system": "http://example.com/pharmacy-orgs", + "value": "medplum-pharmacy" + } + ], + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/organization-type", + "code": "prov", + "display": "Healthcare Provider" + } + ] + } + ] + } } ] } diff --git a/package.json b/package.json index 7be0ea8..3ef48bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "medplum-patient-intake-demo", - "version": "3.2.11", + "version": "3.2.12", "private": true, "type": "module", "scripts": { @@ -28,21 +28,21 @@ "@mantine/core": "7.12.2", "@mantine/hooks": "7.12.2", "@mantine/notifications": "7.12.2", - "@medplum/bot-layer": "3.2.11", - "@medplum/core": "3.2.11", - "@medplum/definitions": "3.2.11", - "@medplum/eslint-config": "3.2.11", - "@medplum/fhirtypes": "3.2.11", - "@medplum/mock": "3.2.11", - "@medplum/react": "3.2.11", - "@tabler/icons-react": "3.16.0", - "@types/node": "22.5.4", - "@types/react": "18.3.5", + "@medplum/bot-layer": "3.2.12", + "@medplum/core": "3.2.12", + "@medplum/definitions": "3.2.12", + "@medplum/eslint-config": "3.2.12", + "@medplum/fhirtypes": "3.2.12", + "@medplum/mock": "3.2.12", + "@medplum/react": "3.2.12", + "@tabler/icons-react": "3.17.0", + "@types/node": "22.5.5", + "@types/react": "18.3.6", "@types/react-dom": "18.3.0", "@vitejs/plugin-react": "4.3.1", "chart.js": "4.4.4", "esbuild": "0.23.1", - "postcss": "8.4.45", + "postcss": "8.4.47", "postcss-preset-mantine": "1.17.0", "react": "18.2.0", "react-chartjs-2": "5.2.0", @@ -50,7 +50,7 @@ "react-router-dom": "6.26.2", "ts-node": "10.9.2", "typescript": "5.6.2", - "vite": "5.4.3", - "vitest": "2.0.5" + "vite": "5.4.5", + "vitest": "2.1.1" } } diff --git a/src/bots/core/intake-form.test.ts b/src/bots/core/intake-form.test.ts index 55e5ddf..2c477ee 100644 --- a/src/bots/core/intake-form.test.ts +++ b/src/bots/core/intake-form.test.ts @@ -5,6 +5,7 @@ import { intakeResponse, payorOrganization1, payorOrganization2, + pharmacyOrganization, } from './test-data/intake-form-test-data'; import { Bundle, @@ -35,7 +36,8 @@ describe('Intake form', async () => { response: QuestionnaireResponse, patient: Patient | undefined, payor1: Organization, - payor2: Organization; + payor2: Organization, + pharmacy: Organization; const bot = { reference: 'Bot/123' }; const contentType = 'application/fhir+json'; const ssn = '518225060'; @@ -53,6 +55,7 @@ describe('Intake form', async () => { medplum = new MockClient(); payor1 = await medplum.createResource(payorOrganization1); payor2 = await medplum.createResource(payorOrganization2); + pharmacy = await medplum.createResource(pharmacyOrganization); await medplum.createResource(intakeQuestionnaire); response = await medplum.createResource(intakeResponse); }); @@ -252,6 +255,32 @@ describe('Intake form', async () => { }); }); + describe('Immunization', async () => { + test('add immunizations', async () => { + await handler(medplum, { bot, input: response, contentType, secrets: {} }); + + patient = (await medplum.searchOne('Patient', `identifier=${ssn}`)) as Patient; + + expect(patient).toBeDefined(); + + const immunizations = await medplum.searchResources('Immunization', { + patient: getReferenceString(patient), + }); + + expect(immunizations.length).toEqual(2); + + expect(immunizations[0].vaccineCode?.coding?.[0].system).toEqual('http://hl7.org/fhir/sid/cvx'); + expect(immunizations[0].vaccineCode?.coding?.[0].code).toEqual('197'); + expect(immunizations[0].status).toEqual('completed'); + expect(immunizations[0].occurrenceDateTime).toEqual('2024-02-01T14:00:00-07:00'); + + expect(immunizations[1].vaccineCode?.coding?.[0].system).toEqual('http://hl7.org/fhir/sid/cvx'); + expect(immunizations[1].vaccineCode?.coding?.[0].code).toEqual('115'); + expect(immunizations[1].status).toEqual('completed'); + expect(immunizations[1].occurrenceDateTime).toEqual('2015-08-01T15:00:00-07:00'); + }); + }); + describe('Language information', async () => { test('add languages', async () => { await handler(medplum, { bot, input: response, contentType, secrets: {} }); @@ -369,6 +398,26 @@ describe('Intake form', async () => { }); }); + describe('CareTeam', async () => { + test('Preferred Pharmacy', async () => { + await handler(medplum, { bot, input: response, contentType, secrets: {} }); + + patient = (await medplum.searchOne('Patient', `identifier=${ssn}`)) as Patient; + + expect(patient).toBeDefined(); + + const careTeam = await medplum.searchResources('CareTeam', { + subject: getReferenceString(patient), + }); + + expect(careTeam.length).toEqual(1); + expect(careTeam[0].status).toEqual('proposed'); + expect(careTeam[0].name).toEqual('Patient Preferred Pharmacy'); + expect(careTeam[0].participant?.length).toEqual(1); + expect(careTeam[0].participant?.[0].member?.reference).toEqual(getReferenceString(pharmacy)); + }); + }); + describe('Coverage', async () => { test('adds coverage resources', async () => { await handler(medplum, { bot, input: response, contentType, secrets: {} }); @@ -382,12 +431,12 @@ describe('Intake form', async () => { expect(coverages[0].beneficiary).toEqual(createReference(patient)); expect(coverages[0].subscriberId).toEqual('first-provider-id'); expect(coverages[0].relationship?.coding?.[0]?.code).toEqual('self'); - expect(coverages[0].payor?.[0].reference).toEqual(createReference(payor1).reference); + expect(coverages[0].payor?.[0].reference).toEqual(getReferenceString(payor1)); expect(coverages[1].beneficiary).toEqual(createReference(patient)); expect(coverages[1].subscriberId).toEqual('second-provider-id'); expect(coverages[1].relationship?.coding?.[0]?.code).toEqual('child'); - expect(coverages[1].payor?.[0].reference).toEqual(createReference(payor2).reference); + expect(coverages[1].payor?.[0].reference).toEqual(getReferenceString(payor2)); }); test('upsert coverage resources to ensure there is only one coverage resource per payor', async () => { diff --git a/src/bots/core/intake-form.ts b/src/bots/core/intake-form.ts index 71f8aa1..9e2f713 100644 --- a/src/bots/core/intake-form.ts +++ b/src/bots/core/intake-form.ts @@ -1,13 +1,15 @@ import { BotEvent, createReference, getQuestionnaireAnswers, MedplumClient } from '@medplum/core'; -import { Patient, Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes'; +import { Organization, Patient, Questionnaire, QuestionnaireResponse, Reference } from '@medplum/fhirtypes'; import { addAllergy, addCondition, addConsent, addCoverage, addFamilyMemberHistory, + addImmunization, addLanguage, addMedication, + addPharmacy, consentCategoryMapping, consentPolicyRuleMapping, consentScopeMapping, @@ -180,6 +182,7 @@ export async function handler(medplum: MedplumClient, event: BotEvent); + } + // Handle consents await addConsent( diff --git a/src/bots/core/intake-utils.ts b/src/bots/core/intake-utils.ts index 3e49bef..5041bce 100644 --- a/src/bots/core/intake-utils.ts +++ b/src/bots/core/intake-utils.ts @@ -6,6 +6,7 @@ import { HTTP_TERMINOLOGY_HL7_ORG, LOINC, MedplumClient, + SNOMED, } from '@medplum/core'; import { Address, @@ -468,6 +469,74 @@ export async function addFamilyMemberHistory( ); } +/** + * + * @param medplum - The Medplum client + * @param patient - The patient beneficiary of the immunization + * @param answers - A list of objects where the keys are the linkIds of the fields used to set up an + * immunization (see getGroupRepeatedAnswers) + */ +export async function addImmunization( + medplum: MedplumClient, + patient: Patient, + answers: Record +): Promise { + const code = answers['immunization-vaccine']?.valueCoding; + const occurrenceDateTime = answers['immunization-date']?.valueDateTime; + + if (!code || !occurrenceDateTime) { + return; + } + + await medplum.upsertResource( + { + resourceType: 'Immunization', + status: 'completed', + vaccineCode: { coding: [code] }, + patient: createReference(patient), + occurrenceDateTime: occurrenceDateTime, + }, + { + status: 'completed', + 'vaccine-code': `${code.system}|${code.code}`, + patient: getReferenceString(patient), + date: occurrenceDateTime, + } + ); +} + +/** + * Adds a CareTeam resource associating the patient with a pharmacy + * + * @param medplum - The Medplum client + * @param patient - The patient beneficiary of the care team + * @param pharmacy - The pharmacy to be added to the care team + */ +export async function addPharmacy( + medplum: MedplumClient, + patient: Patient, + pharmacy: Reference +): Promise { + await medplum.upsertResource( + { + resourceType: 'CareTeam', + status: 'proposed', + name: 'Patient Preferred Pharmacy', + subject: createReference(patient), + participant: [ + { + member: pharmacy, + role: [{ coding: [{ system: SNOMED, code: '76166008', display: 'Practical aid (pharmacy) (occupation)' }] }], + }, + ], + }, + { + name: 'Patient Preferred Pharmacy', + subject: getReferenceString(patient), + } + ); +} + /** * Adds a Coverage resource * diff --git a/src/bots/core/test-data/intake-form-test-data.ts b/src/bots/core/test-data/intake-form-test-data.ts index a56247f..b67b92d 100644 --- a/src/bots/core/test-data/intake-form-test-data.ts +++ b/src/bots/core/test-data/intake-form-test-data.ts @@ -14,6 +14,12 @@ export const payorOrganization2: Organization = { name: 'Second Insurance Provider', }; +export const pharmacyOrganization: Organization = { + resourceType: 'Organization', + id: 'org-id-3', + name: 'Pharmacy', +}; + export const intakeQuestionnaire: Questionnaire = coreBundle.entry[0].resource as Questionnaire; intakeQuestionnaire.id = 'intake-questionnaire-id'; @@ -555,6 +561,68 @@ export const intakeResponse: QuestionnaireResponse = { }, ], }, + { + id: 'id-133', + linkId: 'vaccination-history', + text: 'Vaccination History', + item: [ + { + id: 'id-134', + linkId: 'immunization-vaccine', + text: 'Vaccine', + answer: [ + { + valueCoding: { + system: 'http://hl7.org/fhir/sid/cvx', + code: '197', + display: 'influenza, high-dose seasonal, quadrivalent, 0.7mL dose, preservative free', + }, + }, + ], + }, + { + id: 'id-135', + linkId: 'immunization-date', + text: 'Administration Date', + answer: [ + { + valueDateTime: '2024-02-01T14:00:00-07:00', + }, + ], + }, + ], + }, + { + id: 'id-136', + linkId: 'vaccination-history', + text: 'Vaccination History', + item: [ + { + id: 'id-137', + linkId: 'immunization-vaccine', + text: 'Vaccine', + answer: [ + { + valueCoding: { + system: 'http://hl7.org/fhir/sid/cvx', + code: '115', + display: 'tetanus toxoid, reduced diphtheria toxoid, and acellular pertussis vaccine, adsorbed', + }, + }, + ], + }, + { + id: 'id-138', + linkId: 'immunization-date', + text: 'Administration Date', + answer: [ + { + valueDateTime: '2015-08-01T15:00:00-07:00', + }, + ], + }, + ], + }, { id: 'id-50', linkId: 'coverage-information', @@ -808,6 +876,23 @@ export const intakeResponse: QuestionnaireResponse = { }, ], }, + { + linkId: 'preferred-pharmacy', + text: 'Preferred Pharmacy', + item: [ + { + linkId: 'preferred-pharmacy-reference', + text: 'Pharmacy', + answer: [ + { + valueReference: { + reference: getReferenceString(pharmacyOrganization), + }, + }, + ], + }, + ], + }, { id: 'id-65', linkId: 'consent-for-treatment', diff --git a/src/components/PatientDetails.tsx b/src/components/PatientDetails.tsx index 39cf2bf..eca05ef 100644 --- a/src/components/PatientDetails.tsx +++ b/src/components/PatientDetails.tsx @@ -7,6 +7,7 @@ import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; import { PatientConsents } from './PatientConsents'; import { PatientObservations } from './PatientObservations'; +import { PatientImmunizations } from './PatientImmunizations'; interface PatientDetailsProps { patient: Patient; @@ -24,6 +25,7 @@ export function PatientDetails(props: PatientDetailsProps): JSX.Element { ['history', 'History'], ['observations', 'SDOH'], ['consents', 'Consents'], + ['immunizations', 'Immunizations'], ]; // Get the current tab const tab = window.location.pathname.split('/').pop(); @@ -82,6 +84,9 @@ export function PatientDetails(props: PatientDetailsProps): JSX.Element { + + + ); diff --git a/src/components/PatientImmunizations.tsx b/src/components/PatientImmunizations.tsx new file mode 100644 index 0000000..ffbd216 --- /dev/null +++ b/src/components/PatientImmunizations.tsx @@ -0,0 +1,30 @@ +import { formatSearchQuery, Operator, SearchRequest } from '@medplum/core'; +import { Patient } from '@medplum/fhirtypes'; +import { SearchControl } from '@medplum/react'; +import { useNavigate } from 'react-router-dom'; + +interface PatientImmunizationsProps { + patient: Patient; +} + +export function PatientImmunizations(props: PatientImmunizationsProps): JSX.Element { + const navigate = useNavigate(); + + const search: SearchRequest = { + resourceType: 'Immunization', + filters: [{ code: 'patient', operator: Operator.EQUALS, value: `Patient/${props.patient.id}` }], + fields: ['status', 'vaccineCode', 'occurrenceDateTime'], + }; + + return ( + navigate(`/${e.resource.resourceType}/${e.resource.id}`)} + onChange={(e) => { + navigate(`/${search.resourceType}${formatSearchQuery(e.definition)}`); + }} + /> + ); +}