diff --git a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/hie-resource.ts b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/hie-resource.ts index 2f92c7073..de359feaa 100644 --- a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/hie-resource.ts +++ b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/hie-resource.ts @@ -3,6 +3,8 @@ import { type PatientIdentifierValue, type FormValues } from '../../patient-regi import { type MapperConfig, type HIEPatient, type ErrorResponse } from './hie-types'; import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; import { v4 } from 'uuid'; +import { z } from 'zod'; +import dayjs from 'dayjs'; /** * Represents a client for interacting with a Health Information Exchange (HIE) resource. * @template T - The type of the resource being fetched. @@ -182,3 +184,79 @@ export const getPatientName = (patient: fhir.Patient) => { const middleName = patient.name[0]?.['given']?.[0]?.replace(givenName, '')?.trim() ?? ''; return { familyName, givenName, middleName }; }; + +export const authorizationFormSchema = z.object({ + otp: z.string().min(1, 'Required'), + receiver: z + .string() + .regex(/^(\+?254|0)((7|1)\d{8})$/) + .optional(), +}); + +export function generateOTP(length = 5) { + let otpNumbers = '0123456789'; + let OTP = ''; + const len = otpNumbers.length; + for (let i = 0; i < length; i++) { + OTP += otpNumbers[Math.floor(Math.random() * len)]; + } + return OTP; +} + +export function persistOTP(otp: string, patientUuid: string) { + sessionStorage.setItem( + patientUuid, + JSON.stringify({ + otp, + timestamp: new Date().toISOString(), + }), + ); +} + +export async function sendOtp({ otp, receiver }: z.infer, patientName: string) { + const payload = parseMessage( + { otp, patient_name: patientName, expiry_time: 5 }, + 'Dear {{patient_name}}, your OTP for accessing your Shared Health Records (SHR) is {{otp}}. Please enter this code to proceed. The code is valid for {{expiry_time}} minutes.', + ); + + const url = `${restBaseUrl}/kenyaemr/send-kenyaemr-sms?message=${payload}&phone=${receiver}`; + + const res = await openmrsFetch(url, { + method: 'POST', + redirect: 'follow', + }); + if (res.ok) { + return await res.json(); + } + throw new Error('Error sending otp'); +} + +function parseMessage(object, template) { + const placeholderRegex = /{{(.*?)}}/g; + + const parsedMessage = template.replace(placeholderRegex, (match, fieldName) => { + if (object.hasOwnProperty(fieldName)) { + return object[fieldName]; + } else { + return match; + } + }); + + return parsedMessage; +} +export function verifyOtp(otp: string, patientUuid: string) { + const data = sessionStorage.getItem(patientUuid); + if (!data) { + throw new Error('Invalid OTP'); + } + const { otp: storedOtp, timestamp } = JSON.parse(data); + const isExpired = dayjs(timestamp).add(5, 'minutes').isBefore(dayjs()); + if (storedOtp !== otp) { + throw new Error('Invalid OTP'); + } + if (isExpired) { + throw new Error('OTP Expired'); + } + sessionStorage.removeItem(patientUuid); + return 'Verification success'; +} diff --git a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.modal.tsx b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.modal.tsx index 21667beb4..333d58f1b 100644 --- a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.modal.tsx +++ b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.modal.tsx @@ -1,13 +1,16 @@ -import React from 'react'; +import { Button, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, ModalBody, ModalHeader, ModalFooter, Accordion, AccordionItem, CodeSnippet } from '@carbon/react'; -import { age, ExtensionSlot, formatDate } from '@openmrs/esm-framework'; import { type HIEPatient } from '../hie-types'; -import capitalize from 'lodash-es/capitalize'; import styles from './confirm-hie.scss'; -import PatientInfo from '../patient-info/patient-info.component'; -import DependentInfo from '../dependants/dependants.component'; -import { getPatientName, maskData } from '../hie-resource'; +import { authorizationFormSchema, generateOTP, getPatientName, persistOTP, sendOtp, verifyOtp } from '../hie-resource'; +import HIEPatientDetailPreview from './hie-patient-detail-preview.component'; +import HIEOTPVerficationForm from './hie-otp-verification-form.component'; +import { Form } from '@carbon/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { type z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { showSnackbar } from '@openmrs/esm-framework'; interface HIEConfirmationModalProps { closeModal: () => void; @@ -17,77 +20,78 @@ interface HIEConfirmationModalProps { const HIEConfirmationModal: React.FC = ({ closeModal, patient, onUseValues }) => { const { t } = useTranslation(); - const { familyName, givenName, middleName } = getPatientName(patient); + const [mode, setMode] = useState<'authorization' | 'preview'>('preview'); + const [status, setStatus] = useState<'loadingOtp' | 'otpSendSuccessfull' | 'otpFetchError'>(); + const phoneNumber = patient?.telecom?.find((num) => num.value)?.value; + const getidentifier = (code: string) => + patient?.identifier?.find((identifier) => identifier?.type?.coding?.some((coding) => coding?.code === code)); + const patientId = patient?.id ?? getidentifier('SHA-number')?.value; + const form = useForm>({ + defaultValues: { + receiver: phoneNumber, + }, + resolver: zodResolver(authorizationFormSchema), + }); + const patientName = getPatientName(patient); - const handleUseValues = () => { - onUseValues(); - closeModal(); + const onSubmit = async (values: z.infer) => { + try { + verifyOtp(values.otp, patientId); + showSnackbar({ title: 'Success', kind: 'success', subtitle: 'Access granted successfully' }); + onUseValues(); + closeModal(); + } catch (error) { + showSnackbar({ title: 'Faulure', kind: 'error', subtitle: `${error}` }); + } }; return ( -
- - {t('hieModal', 'HIE Patient Record Found')} - - -
- -
- - -

{maskData(givenName)}

- -

{maskData(middleName)}

- -

{maskData(familyName)}

- - } + +
+ + + {mode === 'authorization' + ? t('hiePatientVerification', 'HIE Patient Verification') + : t('hieModal', 'HIE Patient Record Found')} + + + + {mode === 'authorization' ? ( + + ) : ( + + )} + + + - - - - m.code).join('')} - /> - - {(!patient?.contact || patient?.contact.length === 0) && ( - - )} -
-
- - - -
- - - - {JSON.stringify(patient, null, 2)} - - - -
-
- - - - - -
+ {mode === 'preview' && ( + + )} + {mode === 'authorization' && ( + + )} + + + ); }; export default HIEConfirmationModal; +function onVerificationSuccesfull() { + throw new Error('Function not implemented.'); +} diff --git a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.scss b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.scss index dbbda7cf1..2edad7cac 100644 --- a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.scss +++ b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/confirm-hie.scss @@ -48,3 +48,15 @@ color: colors.$gray-40; } } + +.grid { + margin: 0 layout.$spacing-05; + padding: layout.$spacing-05 0rem 0rem orem; +} + +.otpInputRow { + display: flex; + flex-direction: row; + gap: layout.$spacing-03; + align-items: flex-end; +} diff --git a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/hie-otp-verification-form.component.tsx b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/hie-otp-verification-form.component.tsx new file mode 100644 index 000000000..3e16dbcaf --- /dev/null +++ b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/hie-otp-verification-form.component.tsx @@ -0,0 +1,88 @@ +import { Button, Column, Row, Stack, Tag, TextInput, InlineLoading } from '@carbon/react'; +import { showSnackbar } from '@openmrs/esm-framework'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { type authorizationFormSchema, generateOTP, persistOTP, sendOtp } from '../hie-resource'; +import styles from './confirm-hie.scss'; +import { type z } from 'zod'; + +type HIEOTPVerficationFormProps = { + name: string; + patientId: string; + status?: 'loadingOtp' | 'otpSendSuccessfull' | 'otpFetchError'; + setStatus: React.Dispatch>; +}; + +const HIEOTPVerficationForm: React.FC = ({ name, patientId, setStatus, status }) => { + const form = useFormContext>(); + const { t } = useTranslation(); + + const handleGetOTP = async () => { + try { + setStatus('loadingOtp'); + const otp = generateOTP(5); + await sendOtp({ otp, receiver: form.watch('receiver') }, name); + setStatus('otpSendSuccessfull'); + persistOTP(otp, patientId); + } catch (error) { + setStatus('otpFetchError'); + showSnackbar({ title: t('error', 'Error'), kind: 'error', subtitle: error?.message }); + } + }; + + return ( + + + ( + + )} + /> + + + + ( + + + + + )} + /> + + + ); +}; + +export default HIEOTPVerficationForm; diff --git a/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/hie-patient-detail-preview.component.tsx b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/hie-patient-detail-preview.component.tsx new file mode 100644 index 000000000..320506fa1 --- /dev/null +++ b/packages/esm-patient-registration-app/src/client-registry/hie-client-registry/modal/hie-patient-detail-preview.component.tsx @@ -0,0 +1,73 @@ +import { Accordion, AccordionItem, CodeSnippet } from '@carbon/react'; +import { age, ExtensionSlot, formatDate } from '@openmrs/esm-framework'; +import capitalize from 'lodash-es/capitalize'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import DependentInfo from '../dependants/dependants.component'; +import { getPatientName, maskData } from '../hie-resource'; +import PatientInfo from '../patient-info/patient-info.component'; +import styles from './confirm-hie.scss'; + +type HIEPatientDetailPreviewProps = { + patient: fhir.Patient; +}; + +const HIEPatientDetailPreview: React.FC = ({ patient }) => { + const { familyName, givenName, middleName } = getPatientName(patient); + const { t } = useTranslation(); + const getidentifier = (code: string) => + patient?.identifier?.find((identifier) => identifier?.type?.coding?.some((coding) => coding?.code === code)); + + return ( + <> +
+ +
+ + +

{maskData(givenName)}

+ +

{maskData(middleName)}

+ +

{maskData(familyName)}

+ + } + /> + + + + + m.code).join('')} + /> + + {(!patient?.contact || patient?.contact.length === 0) && ( + + )} +
+
+ + + +
+ + + + {JSON.stringify(patient, null, 2)} + + + +
+ + ); +}; + +export default HIEPatientDetailPreview; diff --git a/packages/esm-patient-registration-app/translations/en.json b/packages/esm-patient-registration-app/translations/en.json index 27329b70f..da51c50a8 100644 --- a/packages/esm-patient-registration-app/translations/en.json +++ b/packages/esm-patient-registration-app/translations/en.json @@ -74,6 +74,7 @@ "givenNameRequired": "Given name is required", "healthID": "HealthID", "hieModal": "HIE Patient Record Found", + "hiePatientVerification": "HIE Patient Verification", "identifierSearch": "Identifier search", "identifierType": "Identifier type", "identifierValueRequired": "Identifier value is required", @@ -101,12 +102,15 @@ "optional": "optional", "optionalIdentifierLabel": "{{identifierName}} (optional)", "other": "Other", + "otpCode": "OTP Authorization code", "patientDetailsFound": "Patient information found in the registry, do you want to use the information to continue with registration?", "patientName": "Patient name", "patientNameKnown": "Patient's Name is Known?", "patientNotFound": "The patient records could not be found in Client registry, do you want to continue to create and post patient to registry", + "patientPhoneNUmber": "Patient Phone number", "patientRegistrationBreadcrumb": "Patient Registration", "patientVerificationFromHIE": "Patient verification from HIE", + "phoneNumberHelper": "Patient will receive OTP on this number", "postToRegistry": "Post to registry", "refreshOrContactAdmin": "Try refreshing the page or contact your system administrator", "registerPatient": "Register patient", @@ -124,6 +128,7 @@ "removeIdentifierButton": "Remove identifier", "resetIdentifierTooltip": "Reset", "restoreRelationshipActionButton": "Undo", + "retry": "Retry", "searchAddress": "Search address", "searchClientRegistry": "Search client registry", "searchIdentifierPlaceholder": "Search identifier", @@ -149,6 +154,8 @@ "updatePatientSuccessSnackbarTitle": "Patient Details Updated", "useValues": "Use values", "validate": "Validate", + "verifyAndUseValues": "Verify & Use values", + "verifyOTP": "Verify with OTP", "viewFullResponse": "View full response", "yearsEstimateRequired": "Estimated years required", "yes": "Yes"