Skip to content

Commit

Permalink
Add hie otp verification to use values and replaced CR id with SHA nu…
Browse files Browse the repository at this point in the history
…mber on HealthID (#55)

* Added hie Patient OTP verification to use patient data and replace CR id with SHA number

* Updated transalations
  • Loading branch information
Omoshlawi authored and donaldkibet committed Jan 5, 2025
1 parent fe870ad commit 6037eca
Show file tree
Hide file tree
Showing 6 changed files with 333 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<typeof authorizationFormSchema>, 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';
}
Original file line number Diff line number Diff line change
@@ -1,12 +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 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';

const PatientInfo: React.FC<{ label: string; value: string | (() => React.ReactNode) }> = ({ label, value }) => {
return (
Expand All @@ -25,76 +29,78 @@ interface HIEConfirmationModalProps {

const HIEConfirmationModal: React.FC<HIEConfirmationModalProps> = ({ 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<z.infer<typeof authorizationFormSchema>>({
defaultValues: {
receiver: phoneNumber,
},
resolver: zodResolver(authorizationFormSchema),
});
const patientName = getPatientName(patient);

const handleUseValues = () => {
onUseValues();
closeModal();
const onSubmit = async (values: z.infer<typeof authorizationFormSchema>) => {
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 (
<div>
<ModalHeader closeModal={closeModal}>
<span className={styles.header}>{t('hieModal', 'HIE Patient Record Found')}</span>
</ModalHeader>
<ModalBody>
<div className={styles.patientDetails}>
<ExtensionSlot
className={styles.patientPhotoContainer}
name="patient-photo-slot"
state={{ patientName: `${maskData(givenName)} . ${maskData(middleName)} . ${maskData(familyName)}` }}
/>
<div className={styles.patientInfoContainer}>
<PatientInfo label={t('healthID', 'HealthID')} value={patient?.id} />
<PatientInfo
label={t('patientName', 'Patient name')}
value={() => (
<span className={styles.patientNameValue}>
<p>{maskData(givenName)}</p>
<span>&bull;</span>
<p>{maskData(middleName)}</p>
<span>&bull;</span>
<p>{maskData(familyName)}</p>
</span>
)}
<FormProvider {...form}>
<Form onSubmit={form.handleSubmit(onSubmit)}>
<ModalHeader closeModal={closeModal}>
<span className={styles.header}>
{mode === 'authorization'
? t('hiePatientVerification', 'HIE Patient Verification')
: t('hieModal', 'HIE Patient Record Found')}
</span>
</ModalHeader>
<ModalBody>
{mode === 'authorization' ? (
<HIEOTPVerficationForm
name={`${patientName.givenName} ${patientName.middleName}`}
patientId={patientId}
status={status}
setStatus={setStatus}
/>
<PatientInfo label={t('age', 'Age')} value={age(patient?.birthDate)} />
<PatientInfo label={t('dateOfBirth', 'Date of birth')} value={formatDate(new Date(patient?.birthDate))} />
<PatientInfo label={t('gender', 'Gender')} value={capitalize(patient?.gender)} />
<PatientInfo
label={t('maritalStatus', 'Marital status')}
value={patient?.maritalStatus?.coding?.map((m) => m.code).join('')}
/>

{(!patient?.contact || patient?.contact.length === 0) && (
<PatientInfo label={t('dependents', 'Dependents')} value="--" />
)}
</div>
</div>

<DependentInfo dependents={patient?.contact} />
) : (
<HIEPatientDetailPreview patient={patient} />
)}
</ModalBody>
<ModalFooter>
<Button kind="secondary" onClick={closeModal}>
{t('cancel', 'Cancel')}
</Button>

<div>
<Accordion>
<AccordionItem title={t('viewFullResponse', 'View full response')}>
<CodeSnippet type="multi" feedback="Copied to clipboard">
{JSON.stringify(patient, null, 2)}
</CodeSnippet>
</AccordionItem>
</Accordion>
</div>
</ModalBody>
<ModalFooter>
<Button kind="secondary" onClick={closeModal}>
{t('cancel', 'Cancel')}
</Button>

<Button onClick={handleUseValues} kind="primary">
{t('useValues', 'Use values')}
</Button>
</ModalFooter>
</div>
{mode === 'preview' && (
<Button onClick={() => setMode('authorization')} kind="primary">
{t('useValues', 'Use values')}
</Button>
)}
{mode === 'authorization' && (
<Button
kind="primary"
type="submit"
disabled={form.formState.isSubmitting || status !== 'otpSendSuccessfull'}>
{t('verifyAndUseValues', 'Verify & Use values')}
</Button>
)}
</ModalFooter>
</Form>
</FormProvider>
);
};

export default HIEConfirmationModal;
function onVerificationSuccesfull() {
throw new Error('Function not implemented.');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<'loadingOtp' | 'otpSendSuccessfull' | 'otpFetchError'>>;
};

const HIEOTPVerficationForm: React.FC<HIEOTPVerficationFormProps> = ({ name, patientId, setStatus, status }) => {
const form = useFormContext<z.infer<typeof authorizationFormSchema>>();
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 (
<Stack gap={4} className={styles.grid}>
<Column>
<Controller
control={form.control}
name="receiver"
render={({ field }) => (
<TextInput
invalid={form.formState.errors[field.name]?.message}
invalidText={form.formState.errors[field.name]?.message}
{...field}
placeholder={t('patientPhoneNUmber', 'Patient Phone number')}
labelText={t('patientPhoneNUmber', 'Patient Phone number')}
helperText={t('phoneNumberHelper', 'Patient will receive OTP on this number')}
/>
)}
/>
</Column>

<Column>
<Controller
control={form.control}
name="otp"
render={({ field }) => (
<Row className={styles.otpInputRow}>
<TextInput
invalid={form.formState.errors[field.name]?.message}
invalidText={form.formState.errors[field.name]?.message}
{...field}
placeholder={t('otpCode', 'OTP Authorization code')}
labelText={t('otpCode', 'OTP Authorization code')}
/>
<Button
onClick={handleGetOTP}
role="button"
type="blue"
kind="tertiary"
disabled={['loadingOtp', 'otpSendSuccessfull'].includes(status)}>
{status === 'loadingOtp' ? (
<InlineLoading status="active" iconDescription="Loading" description="Loading data..." />
) : status === 'otpFetchError' ? (
t('retry', 'Retry')
) : (
t('verifyOTP', 'Verify with OTP')
)}
</Button>
</Row>
)}
/>
</Column>
</Stack>
);
};

export default HIEOTPVerficationForm;
Loading

0 comments on commit 6037eca

Please sign in to comment.