Skip to content
This repository has been archived by the owner on Jan 9, 2023. It is now read-only.

refactor(patient): decouple patient validator from redux code #2471

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/__tests__/patients/util/validate-patient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { addDays } from 'date-fns'

import validatePatient, { PatientValidationError } from '../../../patients/util/validate-patient'
import Patient from '../../../shared/model/Patient'

const patient: Patient = {
id: 'abc123',
sex: 'Male',
givenName: 'John',
dateOfBirth: '01/11/1988',
isApproximateDateOfBirth: false,
code: 'abc123',
index: '1',
carePlans: [],
careGoals: [],
bloodType: 'A+',
visits: [],
rev: 'asd',
createdAt: '01/01/2020',
updatedAt: '01/01/2020',
phoneNumbers: [],
emails: [],
addresses: [],
}

describe('validate patient', () => {
describe('PatientValidationError class', () => {
it('should count the amount of errors', () => {
const error = new PatientValidationError()

expect(error.count).toEqual(0)

error.fieldErrors.givenName = 'patient.errors.patientGivenNameFeedback'
error.fieldErrors.dateOfBirth = 'patient.errors.patientDateOfBirthFeedback'
error.fieldErrors.suffix = 'patient.errors.patientNumInSuffixFeedback'

expect(error.count).toEqual(3)

error.fieldErrors.prefix = 'patient.errors.patientNumInPrefixFeedback'
error.fieldErrors.familyName = 'patient.errors.patientNumInFamilyNameFeedback'
error.fieldErrors.preferredLanguage = 'patient.errors.patientNumInPreferredLanguageFeedback'
error.fieldErrors.emails = ['patient.errors.invalidEmail']
error.fieldErrors.phoneNumbers = ['patient.errors.invalidPhoneNumber']

expect(error.count).toEqual(8)
})
})

it('returns null when patient is valid', () => {
const error = validatePatient({ ...patient })
expect(error).toEqual(null)
})

it('should validate the patient required fields', () => {
const error = validatePatient({ ...patient, givenName: '' })

expect(error?.fieldErrors.givenName).toEqual('patient.errors.patientGivenNameFeedback')
expect(error?.count).toEqual(1)
})

it('should validate that the patient birthday is not a future date', () => {
const error = validatePatient({
...patient,
dateOfBirth: addDays(new Date(), 4).toISOString(),
})

expect(error?.fieldErrors.dateOfBirth).toEqual('patient.errors.patientDateOfBirthFeedback')
expect(error?.count).toEqual(1)
})

it('should validate that the patient phone number is a valid phone number', () => {
const error = validatePatient({
...patient,
phoneNumbers: [{ id: 'abc', value: 'not a phone number' }],
})

expect(error?.fieldErrors.phoneNumbers).toEqual(['patient.errors.invalidPhoneNumber'])
expect(error?.count).toEqual(1)
})

it('should validate that the patient email is a valid email', () => {
const error = validatePatient({
...patient,
emails: [{ id: 'abc', value: 'not a phone number' }],
})

expect(error?.fieldErrors.emails).toEqual(['patient.errors.invalidEmail'])
expect(error?.count).toEqual(1)
})

it('should validate fields that should only contian alpha characters', () => {
const error = validatePatient({
...patient,
suffix: 'A123',
familyName: 'B456',
prefix: 'C987',
preferredLanguage: 'D321',
})

expect(error?.fieldErrors.suffix).toEqual('patient.errors.patientNumInSuffixFeedback')
expect(error?.fieldErrors.familyName).toEqual('patient.errors.patientNumInFamilyNameFeedback')
expect(error?.fieldErrors.prefix).toEqual('patient.errors.patientNumInPrefixFeedback')
expect(error?.fieldErrors.preferredLanguage).toEqual(
'patient.errors.patientNumInPreferredLanguageFeedback',
)

expect(error?.count).toEqual(4)
})
})
97 changes: 15 additions & 82 deletions src/patients/patient-slice.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { isAfter, parseISO } from 'date-fns'
import { isEmpty } from 'lodash'
import validator from 'validator'

import PatientRepository from '../shared/db/PatientRepository'
import Diagnosis from '../shared/model/Diagnosis'
import Patient from '../shared/model/Patient'
import { AppThunk } from '../shared/store'
import { uuid } from '../shared/util/uuid'
import { cleanupPatient } from './util/set-patient-helper'
import validatePatient from './util/validate-patient'

interface PatientState {
status: 'loading' | 'error' | 'completed'
Expand Down Expand Up @@ -106,80 +105,6 @@ export const {
addDiagnosisError,
} = patientSlice.actions

function validatePatient(patient: Patient) {
const error: Error = {}

const regexContainsNumber = /\d/

if (!patient.givenName) {
error.givenName = 'patient.errors.patientGivenNameFeedback'
}

if (patient.dateOfBirth) {
const today = new Date(Date.now())
const dob = parseISO(patient.dateOfBirth)
if (isAfter(dob, today)) {
error.dateOfBirth = 'patient.errors.patientDateOfBirthFeedback'
}
}

if (patient.suffix) {
if (regexContainsNumber.test(patient.suffix)) {
error.suffix = 'patient.errors.patientNumInSuffixFeedback'
}
}

if (patient.prefix) {
if (regexContainsNumber.test(patient.prefix)) {
error.prefix = 'patient.errors.patientNumInPrefixFeedback'
}
}

if (patient.familyName) {
if (regexContainsNumber.test(patient.familyName)) {
error.familyName = 'patient.errors.patientNumInFamilyNameFeedback'
}
}

if (patient.preferredLanguage) {
if (regexContainsNumber.test(patient.preferredLanguage)) {
error.preferredLanguage = 'patient.errors.patientNumInPreferredLanguageFeedback'
}
}

if (patient.emails) {
const errors: (string | undefined)[] = []
patient.emails.forEach((email) => {
if (!validator.isEmail(email.value)) {
errors.push('patient.errors.invalidEmail')
} else {
errors.push(undefined)
}
})
// Only add to error obj if there's an error
if (errors.some((value) => value !== undefined)) {
error.emails = errors
}
}

if (patient.phoneNumbers) {
const errors: (string | undefined)[] = []
patient.phoneNumbers.forEach((phoneNumber) => {
if (!validator.isMobilePhone(phoneNumber.value)) {
errors.push('patient.errors.invalidPhoneNumber')
} else {
errors.push(undefined)
}
})
// Only add to error obj if there's an error
if (errors.some((value) => value !== undefined)) {
error.phoneNumbers = errors
}
}

return error
}

export const createPatient = (
patient: Patient,
onSuccess?: (patient: Patient) => void,
Expand All @@ -189,16 +114,20 @@ export const createPatient = (
const cleanPatient = cleanupPatient(patient)
const newPatientError = validatePatient(cleanPatient)

if (isEmpty(newPatientError)) {
if (!newPatientError) {
const newPatient = await PatientRepository.save(cleanPatient)
dispatch(createPatientSuccess())

if (onSuccess) {
onSuccess(newPatient)
}
} else {
newPatientError.message = 'patient.errors.createPatientError'
dispatch(createPatientError(newPatientError))
dispatch(
createPatientError({
...newPatientError.fieldErrors,
message: 'patient.errors.createPatientError',
}),
)
}
}

Expand All @@ -211,16 +140,20 @@ export const updatePatient = (
const cleanPatient = cleanupPatient(patient)
const updateError = validatePatient(cleanPatient)

if (isEmpty(updateError)) {
if (!updateError) {
const updatedPatient = await PatientRepository.saveOrUpdate(cleanPatient)
dispatch(updatePatientSuccess(updatedPatient))

if (onSuccess) {
onSuccess(updatedPatient)
}
} else {
updateError.message = 'patient.errors.updatePatientError'
dispatch(updatePatientError(updateError))
dispatch(
updatePatientError({
...updateError.fieldErrors,
message: 'patient.errors.updatePatientError',
}),
)
}
}

Expand Down
98 changes: 98 additions & 0 deletions src/patients/util/validate-patient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { isAfter, parseISO } from 'date-fns'
import validator from 'validator'

import { ContactInfoPiece } from '../../shared/model/ContactInformation'
import Patient from '../../shared/model/Patient'

const validateEmails = (emails: ContactInfoPiece[] | undefined) =>
(emails ?? []).map((email) =>
!validator.isEmail(email.value) ? 'patient.errors.invalidEmail' : undefined,
)

const validatePhoneNumbers = (phoneNumbers: ContactInfoPiece[] | undefined) =>
(phoneNumbers ?? []).map((phone) =>
!validator.isMobilePhone(phone.value) ? 'patient.errors.invalidPhoneNumber' : undefined,
)

const existAndIsAfterToday = (value: string | undefined) => {
if (!value) {
return false
}

const today = new Date(Date.now())
const dateOfBirth = parseISO(value)

return isAfter(dateOfBirth, today)
}

const existAndHasNumbers = (value: string | undefined) => value && /\d/.test(value)

interface IPatientFieldErrors {
givenName?: 'patient.errors.patientGivenNameFeedback'
dateOfBirth?: 'patient.errors.patientDateOfBirthFeedback'
suffix?: 'patient.errors.patientNumInSuffixFeedback'
prefix?: 'patient.errors.patientNumInPrefixFeedback'
familyName?: 'patient.errors.patientNumInFamilyNameFeedback'
preferredLanguage?: 'patient.errors.patientNumInPreferredLanguageFeedback'
emails?: ('patient.errors.invalidEmail' | undefined)[]
phoneNumbers?: ('patient.errors.invalidPhoneNumber' | undefined)[]
}

export class PatientValidationError extends Error {
public fieldErrors: IPatientFieldErrors

constructor() {
super('Patient data is invalid.')
this.name = 'PatientValidationError'
this.fieldErrors = {}
}

get count(): number {
return Object.keys(this.fieldErrors).length
}
}

export default function validatePatient(patient: Patient) {
const error = new PatientValidationError()

if (!patient.givenName) {
error.fieldErrors.givenName = 'patient.errors.patientGivenNameFeedback'
}

if (existAndIsAfterToday(patient.dateOfBirth)) {
error.fieldErrors.dateOfBirth = 'patient.errors.patientDateOfBirthFeedback'
}

if (existAndHasNumbers(patient.suffix)) {
error.fieldErrors.suffix = 'patient.errors.patientNumInSuffixFeedback'
}

if (existAndHasNumbers(patient.prefix)) {
error.fieldErrors.prefix = 'patient.errors.patientNumInPrefixFeedback'
}

if (existAndHasNumbers(patient.familyName)) {
error.fieldErrors.familyName = 'patient.errors.patientNumInFamilyNameFeedback'
}

if (existAndHasNumbers(patient.preferredLanguage)) {
error.fieldErrors.preferredLanguage = 'patient.errors.patientNumInPreferredLanguageFeedback'
}

const emailsErrors = validateEmails(patient.emails)
const phoneNumbersErrors = validatePhoneNumbers(patient.phoneNumbers)

if (emailsErrors.some(Boolean)) {
error.fieldErrors.emails = emailsErrors
}

if (phoneNumbersErrors.some(Boolean)) {
error.fieldErrors.phoneNumbers = phoneNumbersErrors
}

if (error.count === 0) {
return null
}

return error
}