Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hie otp verification to use values and replaced CR id with SHA number on HealthID #55

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
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,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;
Expand All @@ -17,77 +20,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')}
customValue={
<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}
/>
) : (
<HIEPatientDetailPreview patient={patient} />
)}
</ModalBody>
<ModalFooter>
<Button kind="secondary" onClick={closeModal}>
{t('cancel', 'Cancel')}
</Button>

<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} />

<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
Loading