From d4629ac1c9ac84d03c1efa7ae1a8407677ec9c74 Mon Sep 17 00:00:00 2001 From: Nick Hellemans Date: Thu, 13 Feb 2025 08:09:06 +0100 Subject: [PATCH] Elation: non-visit note refactor (#587) * elation_non_visit_note_refactor * fix(): import * chore(): error handling * chore(): test * chore(): test * fix(): dont validate twice --- .../actions/__tests__/createNonVisitNote.ts | 73 --------- .../elation/actions/createNonVisitNote.ts | 155 ------------------ .../__testdata__/CreateNonVisitNote.mock.ts | 27 +++ .../createNonVisitNote/config/dataPoints.ts | 12 ++ .../createNonVisitNote/config/fields.ts | 61 +++++++ .../createNonVisitNote/config/index.ts | 2 + .../createNonVisitNote.test.ts | 135 +++++++++++++++ .../createNonVisitNote/createNonVisitNote.ts | 61 +++++++ .../actions/createNonVisitNote/index.ts | 1 + extensions/elation/actions/index.ts | 2 +- extensions/elation/client.ts | 9 +- 11 files changed, 305 insertions(+), 233 deletions(-) delete mode 100644 extensions/elation/actions/__tests__/createNonVisitNote.ts delete mode 100644 extensions/elation/actions/createNonVisitNote.ts create mode 100644 extensions/elation/actions/createNonVisitNote/__testdata__/CreateNonVisitNote.mock.ts create mode 100644 extensions/elation/actions/createNonVisitNote/config/dataPoints.ts create mode 100644 extensions/elation/actions/createNonVisitNote/config/fields.ts create mode 100644 extensions/elation/actions/createNonVisitNote/config/index.ts create mode 100644 extensions/elation/actions/createNonVisitNote/createNonVisitNote.test.ts create mode 100644 extensions/elation/actions/createNonVisitNote/createNonVisitNote.ts create mode 100644 extensions/elation/actions/createNonVisitNote/index.ts diff --git a/extensions/elation/actions/__tests__/createNonVisitNote.ts b/extensions/elation/actions/__tests__/createNonVisitNote.ts deleted file mode 100644 index 8fa3ba6ec..000000000 --- a/extensions/elation/actions/__tests__/createNonVisitNote.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createNonVisitNote } from '../createNonVisitNote' -import { nonVisitNoteResponseExample } from '../../__mocks__/constants' -import { makeAPIClientMockFunc } from '../../__mocks__/client' -import { makeAPIClient } from '../../client' -import { nonVisitNoteSchema } from '../../validation/nonVisitNote.zod' - -jest.mock('../../client') - -describe('Create non-visit note action', () => { - const onComplete = jest.fn() - const onError = jest.fn() - const settings = { - client_id: 'clientId', - client_secret: 'clientSecret', - username: 'username', - password: 'password', - auth_url: 'authUrl', - base_url: 'baseUrl', - } - - beforeAll(() => { - const mockAPIClient = makeAPIClient as jest.Mock - mockAPIClient.mockImplementation(makeAPIClientMockFunc) - }) - - beforeEach(() => { - jest.clearAllMocks() - }) - - test('Should return with correct data_points', async () => { - await createNonVisitNote.onActivityCreated!( - { - fields: { - patientId: nonVisitNoteResponseExample.patient, - authorId: nonVisitNoteResponseExample.bullets[0].author, - category: undefined, - tags: undefined, - text: nonVisitNoteResponseExample.bullets[0].text, - }, - settings, - } as any, - onComplete, - onError, - ) - expect(onComplete).toHaveBeenCalledWith({ - data_points: { - nonVisitNoteId: String(nonVisitNoteResponseExample.id), - nonVisitNoteBulletId: String(nonVisitNoteResponseExample.bullets[0].id), - }, - }) - }) - - describe('nonVisitNoteSchema', () => { - test('Should work undefined category', async () => { - const test = nonVisitNoteSchema.parse({ - patient: 1, - bullets: [{ text: 'Text', author: 2, category: undefined }], - document_date: new Date().toISOString(), - chart_date: new Date().toISOString(), - tags: undefined, - }) - - expect(test).toEqual({ - type: 'nonvisit', - patient: 1, - bullets: [{ text: 'Text', author: 2, category: undefined }], - document_date: expect.any(String), - chart_date: expect.any(String), - tags: undefined, - }) - }) - }) -}) diff --git a/extensions/elation/actions/createNonVisitNote.ts b/extensions/elation/actions/createNonVisitNote.ts deleted file mode 100644 index ce57f0037..000000000 --- a/extensions/elation/actions/createNonVisitNote.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { ZodError } from 'zod' -import { - FieldType, - type Action, - type DataPointDefinition, - type Field, - Category, -} from '@awell-health/extensions-core' -import { type settings } from '../settings' -import { makeAPIClient } from '../client' -import { fromZodError } from 'zod-validation-error' -import { AxiosError } from 'axios' -import { nonVisitNoteSchema } from '../validation/nonVisitNote.zod' - -const fields = { - patientId: { - id: 'patientId', - label: 'Patient ID', - description: '', - type: FieldType.NUMERIC, - required: true, - }, - // Practice ID is not required so leaving it out for simplicity - // practiceId: { - // id: 'practiceId', - // label: 'Practice', - // description: 'ID of a Practice', - // type: FieldType.NUMERIC, - // required: false, - // }, - authorId: { - id: 'authorId', - label: 'Author', - description: 'The author of a note. Should be the ID of a User in Elation.', - type: FieldType.NUMERIC, - required: true, - }, - category: { - id: 'category', - label: 'Category', - description: - 'The Category of a note, defaults to "Problem". Read the extension documentation for the list of possible values.', - type: FieldType.STRING, - required: false, - }, - tags: { - id: 'tags', - label: 'Tags', - description: 'Comma-separated list of tags IDs', - type: FieldType.STRING, - required: false, - }, - text: { - id: 'text', - label: 'Text', - description: 'Text of a note', - type: FieldType.TEXT, - required: true, - }, -} satisfies Record - -const dataPoints = { - nonVisitNoteId: { - key: 'nonVisitNoteId', - valueType: 'number', - }, - nonVisitNoteBulletId: { - key: 'nonVisitNoteBulletId', - valueType: 'number', - }, -} satisfies Record - -export const createNonVisitNote: Action< - typeof fields, - typeof settings, - keyof typeof dataPoints -> = { - key: 'createNonVisitNote', - category: Category.EHR_INTEGRATIONS, - title: 'Create Non-Visit Note', - description: "Create a Non-Visit Note using Elation's patient API.", - fields, - previewable: true, - dataPoints, - onActivityCreated: async (payload, onComplete, onError): Promise => { - try { - const { patientId, authorId, text, category, ...fields } = payload.fields - - const note = nonVisitNoteSchema.parse({ - ...fields, - patient: patientId, - bullets: [{ text, author: authorId, category }], - document_date: new Date().toISOString(), - chart_date: new Date().toISOString(), - }) - - const api = makeAPIClient(payload.settings) - const { id, bullets } = await api.createNonVisitNote(note) - await onComplete({ - data_points: { - nonVisitNoteId: String(id), - nonVisitNoteBulletId: String(bullets[0].id), - }, - }) - } catch (err) { - if (err instanceof ZodError) { - const error = fromZodError(err) - await onError({ - events: [ - { - date: new Date().toISOString(), - text: { en: error.message }, - error: { - category: 'WRONG_INPUT', - message: error.message, - }, - }, - ], - }) - } else if (err instanceof AxiosError) { - await onError({ - events: [ - { - date: new Date().toISOString(), - text: { - en: `${err.status ?? '(no status code)'} Error: ${err.message}`, - }, - error: { - category: 'SERVER_ERROR', - message: `${err.status ?? '(no status code)'} Error: ${ - err.message - }`, - }, - }, - ], - }) - } else { - const message = (err as Error).message - await onError({ - events: [ - { - date: new Date().toISOString(), - text: { en: message }, - error: { - category: 'SERVER_ERROR', - message, - }, - }, - ], - }) - } - } - }, -} diff --git a/extensions/elation/actions/createNonVisitNote/__testdata__/CreateNonVisitNote.mock.ts b/extensions/elation/actions/createNonVisitNote/__testdata__/CreateNonVisitNote.mock.ts new file mode 100644 index 000000000..7e4f366ae --- /dev/null +++ b/extensions/elation/actions/createNonVisitNote/__testdata__/CreateNonVisitNote.mock.ts @@ -0,0 +1,27 @@ +import { type AxiosResponse } from 'axios' +import { type NonVisitNoteResponse } from 'extensions/elation/types' +import { type DeepPartial } from '../../../../../src/lib/types' + +export const CreateNonVisitNoteMock = { + status: 200, + statusText: 'OK', + data: { + id: 1, + type: 'nonvisit', + bullets: [ + { + id: 1, + author: 1, + category: 'Problem', + text: 'Test', + updated_date: '2023-01-01T00:00:00Z', + version: 2, + }, + ], + patient: 1, + practice: 1, + chart_date: '2023-01-01T00:00:00Z', + document_date: '2023-01-01T00:00:00Z', + tags: [], + }, +} satisfies DeepPartial> diff --git a/extensions/elation/actions/createNonVisitNote/config/dataPoints.ts b/extensions/elation/actions/createNonVisitNote/config/dataPoints.ts new file mode 100644 index 000000000..3e4b95fc4 --- /dev/null +++ b/extensions/elation/actions/createNonVisitNote/config/dataPoints.ts @@ -0,0 +1,12 @@ +import { type DataPointDefinition } from '@awell-health/extensions-core' + +export const dataPoints = { + nonVisitNoteId: { + key: 'nonVisitNoteId', + valueType: 'number', + }, + nonVisitNoteBulletId: { + key: 'nonVisitNoteBulletId', + valueType: 'number', + }, +} satisfies Record diff --git a/extensions/elation/actions/createNonVisitNote/config/fields.ts b/extensions/elation/actions/createNonVisitNote/config/fields.ts new file mode 100644 index 000000000..4634b5dd1 --- /dev/null +++ b/extensions/elation/actions/createNonVisitNote/config/fields.ts @@ -0,0 +1,61 @@ +import { + type Field, + FieldType, + NumericIdSchema, +} from '@awell-health/extensions-core' +import z, { type ZodTypeAny } from 'zod' + +export const fields = { + patientId: { + id: 'patientId', + label: 'Patient ID', + description: '', + type: FieldType.NUMERIC, + required: true, + }, + // Practice ID is not required so leaving it out for simplicity + // practiceId: { + // id: 'practiceId', + // label: 'Practice', + // description: 'ID of a Practice', + // type: FieldType.NUMERIC, + // required: false, + // }, + authorId: { + id: 'authorId', + label: 'Author', + description: 'The author of a note. Should be the ID of a User in Elation.', + type: FieldType.NUMERIC, + required: true, + }, + category: { + id: 'category', + label: 'Category', + description: + 'The Category of a note, defaults to "Problem". Read the extension documentation for the list of possible values.', + type: FieldType.STRING, + required: false, + }, + tags: { + id: 'tags', + label: 'Tags', + description: 'Comma-separated list of tags IDs', + type: FieldType.STRING, + required: false, + }, + text: { + id: 'text', + label: 'Text', + description: 'Text of a note', + type: FieldType.TEXT, + required: true, + }, +} satisfies Record + +export const FieldsValidationSchema = z.object({ + patientId: NumericIdSchema, + authorId: NumericIdSchema, + tags: z.string().optional(), + category: z.string().optional(), + text: z.string(), +} satisfies Record) diff --git a/extensions/elation/actions/createNonVisitNote/config/index.ts b/extensions/elation/actions/createNonVisitNote/config/index.ts new file mode 100644 index 000000000..cd36e4bd6 --- /dev/null +++ b/extensions/elation/actions/createNonVisitNote/config/index.ts @@ -0,0 +1,2 @@ +export { fields, FieldsValidationSchema } from './fields' +export { dataPoints } from './dataPoints' diff --git a/extensions/elation/actions/createNonVisitNote/createNonVisitNote.test.ts b/extensions/elation/actions/createNonVisitNote/createNonVisitNote.test.ts new file mode 100644 index 000000000..13af39d21 --- /dev/null +++ b/extensions/elation/actions/createNonVisitNote/createNonVisitNote.test.ts @@ -0,0 +1,135 @@ +import { createNonVisitNote as action } from '.' +import { makeAPIClient } from '../../client' +import { nonVisitNoteSchema } from '../../validation/nonVisitNote.zod' +import { TestHelpers } from '@awell-health/extensions-core' +import { CreateNonVisitNoteMock } from './__testdata__/CreateNonVisitNote.mock' +import { createAxiosError } from '../../../../tests' + +jest.mock('../../client', () => ({ + makeAPIClient: jest.fn(), +})) + +describe('Elation - Create non-visit note', () => { + const { extensionAction, onComplete, onError, helpers, clearMocks } = + TestHelpers.fromAction(action) + + const mockCreateNonVisitNote = jest.fn() + + const settings = { + client_id: 'clientId', + client_secret: 'clientSecret', + username: 'username', + password: 'password', + auth_url: 'authUrl', + base_url: 'baseUrl', + } + + beforeAll(() => { + const mockAPIClient = makeAPIClient as jest.Mock + mockAPIClient.mockImplementation(() => ({ + createNonVisitNote: mockCreateNonVisitNote, + })) + }) + + beforeEach(() => { + clearMocks() + }) + + describe('Validation - nonVisitNoteSchema', () => { + test('Should work undefined category', async () => { + const test = nonVisitNoteSchema.parse({ + patient: 1, + bullets: [{ text: 'Text', author: 2, category: undefined }], + document_date: new Date().toISOString(), + chart_date: new Date().toISOString(), + tags: undefined, + }) + + expect(test).toEqual({ + type: 'nonvisit', + patient: 1, + bullets: [{ text: 'Text', author: 2, category: undefined }], + document_date: expect.any(String), + chart_date: expect.any(String), + tags: undefined, + }) + }) + }) + + describe('When the non-visit note is created', () => { + beforeEach(() => { + mockCreateNonVisitNote.mockResolvedValue(CreateNonVisitNoteMock) + }) + + test('Should return with correct data_points', async () => { + await extensionAction.onEvent!({ + payload: { + fields: { + patientId: CreateNonVisitNoteMock.data.patient, + authorId: CreateNonVisitNoteMock.data.bullets[0].author, + category: undefined, + tags: undefined, + text: CreateNonVisitNoteMock.data.bullets[0].text, + }, + settings, + } as any, + onComplete, + onError, + helpers, + }) + + expect(mockCreateNonVisitNote).toHaveBeenCalled() + expect(onComplete).toHaveBeenCalledWith({ + data_points: { + nonVisitNoteId: String(CreateNonVisitNoteMock.data.id), + nonVisitNoteBulletId: String( + CreateNonVisitNoteMock.data.bullets[0].id, + ), + }, + }) + }) + }) + + describe('When the non-visit note creation fails', () => { + beforeEach(() => { + mockCreateNonVisitNote.mockRejectedValue( + createAxiosError( + 400, + 'Bad Request', + JSON.stringify({ + patient: ['Invalid pk "1" - object does not exist.'], + }), + ), + ) + }) + + test('Should return with correct data_points', async () => { + await extensionAction.onEvent!({ + payload: { + fields: { + patientId: CreateNonVisitNoteMock.data.patient, + authorId: CreateNonVisitNoteMock.data.bullets[0].author, + category: undefined, + tags: undefined, + text: CreateNonVisitNoteMock.data.bullets[0].text, + }, + settings, + } as any, + onComplete, + onError, + helpers, + }) + + expect(onError).toHaveBeenCalledWith({ + events: [ + { + date: expect.any(String), + text: { + en: '400: Bad Request\n{\n "patient": [\n "Invalid pk \\"1\\" - object does not exist."\n ]\n}', + }, + }, + ], + }) + }) + }) +}) diff --git a/extensions/elation/actions/createNonVisitNote/createNonVisitNote.ts b/extensions/elation/actions/createNonVisitNote/createNonVisitNote.ts new file mode 100644 index 000000000..5691a679c --- /dev/null +++ b/extensions/elation/actions/createNonVisitNote/createNonVisitNote.ts @@ -0,0 +1,61 @@ +import { type Action, Category } from '@awell-health/extensions-core' +import { type settings } from '../../settings' +import { makeAPIClient } from '../../client' +import { AxiosError } from 'axios' +import { nonVisitNoteSchema } from '../../validation/nonVisitNote.zod' +import { fields, FieldsValidationSchema, dataPoints } from './config' +import { addActivityEventLog } from '../../../../src/lib/awell/addEventLog' + +export const createNonVisitNote: Action< + typeof fields, + typeof settings, + keyof typeof dataPoints +> = { + key: 'createNonVisitNote', + category: Category.EHR_INTEGRATIONS, + title: 'Create Non-Visit Note', + description: "Create a Non-Visit Note using Elation's patient API.", + fields, + previewable: true, + dataPoints, + onEvent: async ({ payload, onComplete, onError }): Promise => { + const { patientId, authorId, tags, text, category } = + FieldsValidationSchema.parse(payload.fields) + + const note = nonVisitNoteSchema.parse({ + tags, + patient: patientId, + bullets: [{ text, author: authorId, category }], + document_date: new Date().toISOString(), + chart_date: new Date().toISOString(), + }) + + const api = makeAPIClient(payload.settings) + + try { + const { + data: { id, bullets }, + } = await api.createNonVisitNote(note) + + await onComplete({ + data_points: { + nonVisitNoteId: String(id), + nonVisitNoteBulletId: String(bullets[0].id), + }, + }) + } catch (err) { + if (err instanceof AxiosError) { + await onError({ + events: [ + addActivityEventLog({ + message: `${String(err.response?.status)}: ${String(err.response?.statusText)}\n${JSON.stringify(err.response?.data, null, 2)}`, + }), + ], + }) + return + } + + throw err + } + }, +} diff --git a/extensions/elation/actions/createNonVisitNote/index.ts b/extensions/elation/actions/createNonVisitNote/index.ts new file mode 100644 index 000000000..57550a663 --- /dev/null +++ b/extensions/elation/actions/createNonVisitNote/index.ts @@ -0,0 +1 @@ +export * from './createNonVisitNote' \ No newline at end of file diff --git a/extensions/elation/actions/index.ts b/extensions/elation/actions/index.ts index 1654761ef..c8feee0d4 100644 --- a/extensions/elation/actions/index.ts +++ b/extensions/elation/actions/index.ts @@ -25,7 +25,7 @@ import { createCareGap } from './createCareGap' import { closeCareGap } from './closeCareGap' import { updatePatientTags } from './updatePatientTags' import { getReferralOrder } from './getReferralOrder' -import { findFutureAppointment} from './findFutureAppointment' +import { findFutureAppointment } from './findFutureAppointment' import { findAppointmentsWithAI } from './findAppointmentsWithAI' import { signNonVisitNote } from './signNonVisitNote/signNonVisitNote' import { updateReferralOrderResolution } from './updateReferralOrderResolution' diff --git a/extensions/elation/client.ts b/extensions/elation/client.ts index bde03a469..66920bafb 100644 --- a/extensions/elation/client.ts +++ b/extensions/elation/client.ts @@ -53,6 +53,7 @@ import { import { elationCacheService } from './cache' import { isEmpty } from 'lodash' import { type DeepPartial } from '../../src/lib/types' +import { type AxiosResponse } from 'axios' export class ElationDataWrapper extends DataWrapper { public async findAppointments( @@ -205,8 +206,8 @@ export class ElationDataWrapper extends DataWrapper { public async createNonVisitNote( obj: NonVisitNoteInput, - ): Promise { - return await this.Request({ + ): Promise> { + return await this.RequestRaw({ method: 'POST', url: '/non_visit_notes/', data: obj, @@ -292,7 +293,7 @@ export class ElationDataWrapper extends DataWrapper { return res } - // A bit confusing, but in Elation, 'Message Thread' is the 'main object' + // A bit confusing, but in Elation, 'Message Thread' is the 'main object' // and you add thread messages to main Message Thread. // see https://docs.elationhealth.com/reference/create-thread-message public async addMessageToThread( @@ -531,7 +532,7 @@ export class ElationAPIClient extends APIClient { public async createNonVisitNote( obj: NonVisitNoteInput, - ): Promise { + ): Promise> { return await this.FetchData(async (dw) => await dw.createNonVisitNote(obj)) }