Skip to content

Commit

Permalink
Merge branch 'main' into itsDependants
Browse files Browse the repository at this point in the history
  • Loading branch information
its-kios09 authored Nov 25, 2024
2 parents a3bb186 + 8697927 commit d937b4f
Show file tree
Hide file tree
Showing 6 changed files with 335 additions and 74 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, type HIEPatientResponse } 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 @@ -187,3 +189,79 @@ export const getPatientName = (patient: HIEPatientResponse) => {

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,94 +1,97 @@
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 HIEPatientResponse, type HIEPatient } from '../hie-types';
import capitalize from 'lodash-es/capitalize';
import { type HIEPatient } from '../hie-types';
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;
patient: HIEPatientResponse;
patient: HIEPatient;
onUseValues: () => void;
}

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?.entry[0]?.resource?.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?.entry[0]?.resource?.birthDate)} />
<PatientInfo
label={t('dateOfBirth', 'Date of birth')}
value={formatDate(new Date(patient?.entry[0]?.resource?.birthDate))}
/>
<PatientInfo label={t('gender', 'Gender')} value={capitalize(patient?.entry[0]?.resource?.gender)} />
<PatientInfo
label={t('maritalStatus', 'Marital status')}
value={patient?.entry[0]?.resource.maritalStatus?.coding?.map((m) => m.code).join('')}
/>

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

<DependentInfo dependents={patient?.entry[0]?.resource.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

0 comments on commit d937b4f

Please sign in to comment.