diff --git a/mediator/src/controllers/cht.ts b/mediator/src/controllers/cht.ts index 7388e173..6707333b 100644 --- a/mediator/src/controllers/cht.ts +++ b/mediator/src/controllers/cht.ts @@ -79,10 +79,8 @@ export async function createEncounter(chtReport: any) { } for (const entry of chtReport.observations) { - if (entry.valueCode || entry.valueString || entry.valueDateTime) { - const observation = buildFhirObservationFromCht(chtReport.patient_uuid, fhirEncounter, entry); - createFhirResource(observation); - } + const observation = buildFhirObservationFromCht(chtReport.patient_uuid, fhirEncounter, entry); + createFhirResource(observation); } return { status: 200, data: {} }; diff --git a/mediator/src/mappers/cht.ts b/mediator/src/mappers/cht.ts index bccfddcd..3805bede 100644 --- a/mediator/src/mappers/cht.ts +++ b/mediator/src/mappers/cht.ts @@ -103,6 +103,39 @@ export function buildFhirEncounterFromCht(chtReport: any): fhir4.Encounter { return encounter } +/* + * Copy the value from the form mapping to observation + * value are expected to already have been validated + * for types which are native to JSON (Integer, String, Boolean) just copy them exactly + * copy Time and DateTime as strings (they have been validated already) + * valueQuantity will be an object, but also just copy it directly + */ +function copyObservationValue(observation: any, entry: any) { + const copyValueTypes = [ + 'valueString', + 'valueDateTime', + 'valueTime', + 'valueBoolean', + 'valueInteger', + 'valueQuantity' + ]; + copyValueTypes.forEach((name) => { + if (entry.hasOwnProperty(name)) { + observation[name] = entry[name]; + } + }); + + // valueCode needs a little bit of conversion; we are not requiring cht forms to + // map to codeableConcept, just to give the string + if ('valueCode' in entry){ + observation.valueCodeableConcept = { + coding: [{ + code: entry['valueCode'] + }] + }; + } +} + export function buildFhirObservationFromCht(patient_id: string, encounter: fhir4.Encounter, entry: any): fhir4.Observation { const patientRef: fhir4.Reference = { reference: `Patient/${patient_id}`, @@ -130,17 +163,7 @@ export function buildFhirObservationFromCht(patient_id: string, encounter: fhir4 issued: now }; - if ('valueCode' in entry){ - observation.valueCodeableConcept = { - coding: [{ - code: entry['valueCode'] - }] - }; - } else if ('valueDateTime' in entry){ - observation.valueDateTime = entry['valueDateTime']; - } else if ('valueString' in entry){ - observation.valueString = entry['valueString']; - } + copyObservationValue(observation, entry); return observation; } diff --git a/mediator/src/middlewares/schemas/cht.ts b/mediator/src/middlewares/schemas/cht.ts new file mode 100644 index 00000000..5bde9cf2 --- /dev/null +++ b/mediator/src/middlewares/schemas/cht.ts @@ -0,0 +1,41 @@ +import joi from 'joi'; + +export const ChtPatientSchema = joi.object({ + doc: joi.object({ + _id: joi.string().uuid().required(), + name: joi.string().required(), + phone: joi.string().required(), + date_of_birth: joi.string().required(), + sex: joi.string().required(), + patient_id: joi.string().required() + }).required() +}); + +export const ChtPatientIdsSchema = joi.object({ + doc: joi.object({ + patient_id: joi.string().required(), + external_id: joi.string().required() + }) +}); + +export const ValueTypeSchema = joi.object({ + code: joi.string().required(), + valueString: joi.string(), + valueCode: joi.string(), + valueBoolean: joi.boolean(), + valueInteger: joi.number().integer(), + valueDateTime: joi.string().isoDate(), + valueTime: joi.string().regex(/^([01]\d|2[0-3]):([0-5]\d)(:[0-5]\d(\.\d{1,3})?)?$/), // matches HH:mm[:ss[.SSS]] + valueQuantity: joi.object({ + value: joi.number().required(), + unit: joi.string().required(), + system: joi.string().uri().optional(), + code: joi.string().optional() + }) +}).or('valueString', 'valueCode', 'valueBoolean', 'valueInteger', 'valueDateTime', 'valueTime', 'valueQuantity'); + +export const ChtEncounterFormSchema = joi.object({ + patient_uuid: joi.string().required(), + reported_date: joi.number().required(), //timestamp + observations: joi.array().items(ValueTypeSchema).optional() +}); diff --git a/mediator/src/middlewares/schemas/tests/cht-request-factories.ts b/mediator/src/middlewares/schemas/tests/cht-request-factories.ts index 337c1504..9efe2b2f 100644 --- a/mediator/src/middlewares/schemas/tests/cht-request-factories.ts +++ b/mediator/src/middlewares/schemas/tests/cht-request-factories.ts @@ -50,10 +50,6 @@ export const ChtPregnancyForm = Factory.define('chtPregnancyDoc') "code": "17a57368-5f59-42c8-aaab-f2774d21501e", "valueCode": "ea6a020e-05cd-4fea-b618-abd7494ac571" }, - { - "code": "17a57368-5f59-42c8-aaab-f2774d21501e", - "valueCode": false - }, { "code": "17a57368-5f59-42c8-aaab-f2774d21501e", "valueCode": "0d9e45d6-9288-494e-841c-80f3f9b8e126" @@ -75,7 +71,19 @@ export const ChtPregnancyForm = Factory.define('chtPregnancyDoc') "valueDateTime": "2024-08-26" }, { - "code": "13179cce-a424-43d7-9ad1-dce7861946e8", - "valueString": "" + "code": "73179cce-a424-43d7-9ad1-dce7861946e8", + "valueString": "String" + }, + { + "code": "53179cce-a424-43d7-9ad1-dce7861946e8", + "valueQuantity": { "value": 160, "unit": "kg" } + }, + { + "code": "37a57368-5f59-42c8-aaab-f2774d21501e", + "valueBoolean": false + }, + { + "code": "47a57368-5f54-42c8-aaab-f2774d21501e", + "valueInteger": 12 } ]); diff --git a/mediator/src/routes/cht.ts b/mediator/src/routes/cht.ts index 1bebab29..7a8e0cbb 100644 --- a/mediator/src/routes/cht.ts +++ b/mediator/src/routes/cht.ts @@ -1,5 +1,7 @@ import { Router } from 'express'; import { requestHandler } from '../utils/request'; +import { validateBodyAgainst } from '../middlewares'; +import { ChtPatientSchema, ChtPatientIdsSchema, ChtEncounterFormSchema } from '../middlewares/schemas/cht'; import { createPatient, updatePatientIds, createEncounter } from '../controllers/cht' const router = Router(); @@ -8,16 +10,19 @@ const resourceType = 'Patient'; router.post( '/patient', + validateBodyAgainst(ChtPatientSchema), requestHandler((req) => createPatient(req.body)) ); router.post( '/patient_ids', + validateBodyAgainst(ChtPatientIdsSchema), requestHandler((req) => updatePatientIds(req.body)) ); router.post( '/encounter', + validateBodyAgainst(ChtEncounterFormSchema), requestHandler((req) => createEncounter(req.body)) ); diff --git a/mediator/src/routes/tests/cht.spec.ts b/mediator/src/routes/tests/cht.spec.ts new file mode 100644 index 00000000..2d733e84 --- /dev/null +++ b/mediator/src/routes/tests/cht.spec.ts @@ -0,0 +1,53 @@ +import request from 'supertest'; +import app from '../../..'; +import { ChtPatientFactory, ChtPatientIdsFactory, ChtPregnancyForm } from '../../middlewares/schemas/tests/cht-request-factories'; + +describe('POST /cht/patient', () => { + it('doesn\'t accept incoming request with invalid patient resource', async () => { + const data = ChtPatientFactory.build(); + delete data.doc._id; + + const res = await request(app).post('/cht/patient').send(data); + + expect(res.status).toBe(400); + expect(res.body.valid).toBe(false); + expect(res.body.message).toMatchInlineSnapshot( + `""doc._id" is required"` + ); + }); +}); + +describe('POST /cht/patient_ids', () => { + it('doesn\'t accept incoming request with invalid patient resource', async () => { + const data = ChtPatientIdsFactory.build(); + delete data.doc.patient_id; + + const res = await request(app).post('/cht/patient_ids').send(data); + + expect(res.status).toBe(400); + expect(res.body.valid).toBe(false); + expect(res.body.message).toMatchInlineSnapshot( + `""doc.patient_id" is required"` + ); + }); +}); + +describe('POST /cht/encounter', () => { + it('doesn\'t accept incoming request with invalid form', async () => { + const data = ChtPregnancyForm.build(); + + // push an invalid observation + data.observations.push({ + "code": "17a57368-5f59-42c8-aaab-f2774d21501e", + "valueDateTime": "This is not a valid date" + }) + + const res = await request(app).post('/cht/encounter').send(data); + + expect(res.status).toBe(400); + expect(res.body.valid).toBe(false); + expect(res.body.message).toMatchInlineSnapshot( + `""observations[13].valueDateTime" must be in iso format"` + ); + }); +});