import { type FetchResponse, getConfig, openmrsFetch, restBaseUrl, type Session, type StyleguideConfigObject, toOmrsIsoString, } from '@openmrs/esm-framework'; import { type AttributeValue, type CapturePhotoProps, type Encounter, type FormValues, type Patient, type PatientIdentifier, type PatientUuidMapType, type RelationshipValue, } from './patient-registration.types'; import { addPatientIdentifier, deletePatientIdentifier, deletePersonName, deleteRelationship, generateIdentifier, getDatetime, saveEncounter, savePatient, savePatientPhoto, saveRelationship, updatePatientIdentifier, updateRelationship, } from './patient-registration.resource'; import { type RegistrationConfig } from '../config-schema'; function getSettledValuesOrThrow(results: Array>): Array { const rejectedResult = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); if (rejectedResult) { throw rejectedResult.reason; } return results.map((result) => { if (result.status === 'fulfilled') { return result.value; } throw result.reason; }); } export type SavePatientForm = ( isNewPatient: boolean, values: FormValues, patientUuidMap: PatientUuidMapType, capturePhotoProps: CapturePhotoProps, currentLocation: string, initialIdentifierValues: FormValues['identifiers'], currentUser: Session, config: RegistrationConfig, savePatientTransactionManager: SavePatientTransactionManager, ) => Promise; export class FormManager { static savePatientFormOnline: SavePatientForm = async ( isNewPatient, values, patientUuidMap, capturePhotoProps, currentLocation, initialIdentifierValues, currentUser, config, savePatientTransactionManager, ) => { const patientIdentifiers: Array = await FormManager.savePatientIdentifiers( isNewPatient, values.patientUuid, values.identifiers, initialIdentifierValues, currentLocation, savePatientTransactionManager, ); getSettledValuesOrThrow( await Promise.allSettled([ ...FormManager.getDeletedNames(values, patientUuidMap).map((name) => deletePersonName(name.nameUuid, name.personUuid), ), FormManager.deletePatientAttributes(isNewPatient, values, patientUuidMap), ]), ); const createdPatient = FormManager.getPatientToCreate(values, patientUuidMap, patientIdentifiers, config); const savePatientResponse = await savePatient( createdPatient, isNewPatient && !savePatientTransactionManager.patientSaved ? undefined : values.patientUuid, ); if (savePatientResponse.ok) { savePatientTransactionManager.patientSaved = true; await this.saveRelationships(values.relationships, savePatientResponse); await this.saveObservations(values.obs, savePatientResponse, currentLocation, currentUser, config); const { patientPhotoConceptUuid } = await getConfig('@openmrs/esm-styleguide'); if (patientPhotoConceptUuid && capturePhotoProps?.imageData) { await savePatientPhoto( savePatientResponse.data.uuid, capturePhotoProps.imageData, `${restBaseUrl}/obs`, capturePhotoProps.dateTime || new Date().toISOString(), patientPhotoConceptUuid, ); } } return savePatientResponse.data.uuid; }; static async saveRelationships(relationships: Array, savePatientResponse: FetchResponse) { return Promise.all( relationships .filter((m) => m.relationshipType) .filter((relationship) => !!relationship.action) .map(({ relatedPersonUuid, relationshipType, uuid: relationshipUuid, action }) => { const [type, direction] = relationshipType.split('/'); const thisPatientUuid = savePatientResponse.data.uuid; const isAToB = direction === 'aIsToB'; const relationshipToSave = { personA: isAToB ? relatedPersonUuid : thisPatientUuid, personB: isAToB ? thisPatientUuid : relatedPersonUuid, relationshipType: type, }; switch (action) { case 'ADD': return saveRelationship(relationshipToSave); case 'UPDATE': return updateRelationship(relationshipUuid, relationshipToSave); case 'DELETE': return deleteRelationship(relationshipUuid); } }), ); } static async saveObservations( obss: { [conceptUuid: string]: string }, savePatientResponse: FetchResponse, currentLocation: string, currentUser: Session, config: RegistrationConfig, ) { if (obss && Object.keys(obss).length > 0) { if (!config.registrationObs.encounterTypeUuid) { console.error( 'The registration form has been configured to have obs fields, ' + 'but no registration encounter type has been configured. Obs field values ' + 'will not be saved.', ); } else { const encounterToSave: Encounter = { encounterDatetime: new Date(), patient: savePatientResponse.data.uuid, encounterType: config.registrationObs.encounterTypeUuid, location: currentLocation, encounterProviders: [ { provider: currentUser.currentProvider.uuid, encounterRole: config.registrationObs.encounterProviderRoleUuid, }, ], form: config.registrationObs.registrationFormUuid, obs: Object.entries(obss) .filter(([, value]) => value !== '') .map(([conceptUuid, value]) => ({ concept: conceptUuid, value })), }; return saveEncounter(encounterToSave); } } } static async savePatientIdentifiers( isNewPatient: boolean, patientUuid: string, patientIdentifiers: FormValues['identifiers'], // values.identifiers initialIdentifierValues: FormValues['identifiers'], // Initial identifiers assigned to the patient location: string, savePatientTransactionManager: SavePatientTransactionManager, ): Promise> { let identifierTypeRequests = Object.entries(patientIdentifiers) /* Since default identifier-types will be present on the form and are also in the not-required state, therefore we might be running into situations when there's no value and no source associated, hence filtering these fields out. */ .filter( ([, { identifierValue, autoGeneration, selectedSource }]) => identifierValue || (autoGeneration && selectedSource), ) .map(([identifierFieldName, patientIdentifier]) => async () => { const { identifierTypeUuid, identifierValue, identifierUuid, selectedSource, preferred, autoGeneration, initialValue, } = patientIdentifier; const autoGenerationManualEntry = autoGeneration && selectedSource?.autoGenerationOption?.manualEntryEnabled && !!identifierValue; let identifier: string; if (!autoGeneration || autoGenerationManualEntry) { identifier = identifierValue; } else { // Reuse the value generated by an earlier attempt of this submission so a retry // does not consume another identifier from the source. const generated = savePatientTransactionManager.generatedIdentifiers[identifierFieldName]; if (generated?.sourceUuid === selectedSource.uuid) { identifier = generated.identifier; } else { identifier = (await generateIdentifier(selectedSource.uuid)).data.identifier; savePatientTransactionManager.generatedIdentifiers[identifierFieldName] = { sourceUuid: selectedSource.uuid, identifier, }; } } // Marking an identifier as preferred through the identifier sub-resource also clears the flag on the // patient's other identifiers, so only the newly preferred identifier needs updating. const initialPreferred = initialIdentifierValues?.[identifierFieldName]?.preferred; const becamePreferred = !!initialValue && !!preferred && !initialPreferred; const identifierToCreate = { uuid: identifierUuid, identifier, identifierType: identifierTypeUuid, location, preferred, }; if (!isNewPatient) { if (!initialValue) { // Skip additions that already succeeded in an earlier attempt of this submission. if (savePatientTransactionManager.addedIdentifiers[identifierFieldName] !== identifier) { await addPatientIdentifier(patientUuid, identifierToCreate); savePatientTransactionManager.addedIdentifiers[identifierFieldName] = identifier; } } else if (initialValue !== identifier || becamePreferred) { await updatePatientIdentifier( patientUuid, identifierUuid, identifierToCreate.identifier, becamePreferred || undefined, ); } } return identifierToCreate; }); // Save identifiers sequentially because changing the preferred identifier can update other identifiers. // Finish writes before deleting identifiers so replacements exist before their predecessors are removed. const identifiers: Array = []; for (const sendIdentifierRequest of identifierTypeRequests) { identifiers.push(await sendIdentifierRequest()); } /* If there was initially an identifier assigned to the patient, which is now not present in the patientIdentifiers(values.identifiers), this means that the identifier is meant to be deleted, hence we need to delete the respective identifiers. */ const identifierDeletionRequests = patientUuid ? Object.keys(initialIdentifierValues) .filter((identifierFieldName) => !patientIdentifiers[identifierFieldName]) .map((identifierFieldName) => deletePatientIdentifier(patientUuid, initialIdentifierValues[identifierFieldName].identifierUuid), ) : []; getSettledValuesOrThrow(await Promise.allSettled(identifierDeletionRequests)); return identifiers; } static getDeletedNames(values: FormValues, patientUuidMap: PatientUuidMapType) { if (patientUuidMap?.additionalNameUuid && !values.addNameInLocalLanguage) { return [ { nameUuid: patientUuidMap.additionalNameUuid, personUuid: values.patientUuid, }, ]; } return []; } static getPatientToCreate( values: FormValues, patientUuidMap: PatientUuidMapType, identifiers: Array, config?: RegistrationConfig, ): Patient { let birthdate; if (values.birthdate instanceof Date) { birthdate = [values.birthdate.getFullYear(), values.birthdate.getMonth() + 1, values.birthdate.getDate()].join( '-', ); } else { birthdate = values.birthdate; } return { uuid: values.patientUuid, person: { uuid: values.patientUuid, names: FormManager.getNames(values, patientUuidMap), gender: values.gender.charAt(0).toUpperCase(), birthdate, birthdateEstimated: values.birthdateEstimated, attributes: FormManager.getPatientAttributes(values), addresses: [values.address], ...FormManager.getPatientDeathInfo(values, config), }, identifiers, }; } static getNames(values: FormValues, patientUuidMap: PatientUuidMapType) { const names = [ { uuid: patientUuidMap.preferredNameUuid, preferred: true, givenName: values.givenName, middleName: values.middleName, familyName: values.familyName, }, ]; if (values.addNameInLocalLanguage) { names.push({ uuid: patientUuidMap.additionalNameUuid, preferred: false, givenName: values.additionalGivenName, middleName: values.additionalMiddleName, familyName: values.additionalFamilyName, }); } return names; } static getPatientAttributes(values: FormValues) { const attributes: Array = []; if (values.attributes) { Object.entries(values.attributes) .filter(([, value]) => !!value) .forEach(([key, value]) => { // Saved attributes of formats like Location load as `{ uuid, display }` references, but the // REST API only accepts the uuid when saving them. Other values, such as booleans, are sent as loaded. const loadedValue: unknown = value; attributes.push({ attributeType: key, value: typeof loadedValue === 'object' ? (loadedValue as { uuid: string }).uuid : value, }); }); } return attributes; } static async deletePatientAttributes(isNewPatient: boolean, values: FormValues, patientUuidMap: PatientUuidMapType) { if (isNewPatient || !values.patientUuid || !values.attributes) { return; } getSettledValuesOrThrow( await Promise.allSettled( Object.entries(values.attributes) .filter(([, value]) => !value) .map(([key]) => patientUuidMap[`attribute.${key}`]) .filter(Boolean) .map((attributeUuid) => openmrsFetch(`${restBaseUrl}/person/${values.patientUuid}/attribute/${attributeUuid}`, { method: 'DELETE', }), ), ), ); } static getPatientDeathInfo(values: FormValues, config?: RegistrationConfig) { const { isDead, deathDate, deathTime, deathTimeFormat, deathCause, nonCodedCauseOfDeath } = values; if (!isDead) { return { dead: false, deathDate: null, causeOfDeath: null, causeOfDeathNonCoded: null, }; } const dateTimeOfDeath = toOmrsIsoString(getDatetime(deathDate, deathTime, deathTimeFormat)); return { dead: true, deathDate: dateTimeOfDeath, ...(deathCause === config?.freeTextFieldConceptUuid ? { causeOfDeathNonCoded: nonCodedCauseOfDeath, causeOfDeath: null } : { causeOfDeath: deathCause, causeOfDeathNonCoded: null }), }; } } interface GeneratedIdentifier { sourceUuid: string; identifier: string; } /** * Tracks which parts of a patient save already completed so a retry of the same submission only * repeats the work that failed. */ export class SavePatientTransactionManager { patientSaved = false; /** Auto-generated identifier values, keyed by identifier field name. */ generatedIdentifiers: Record = {}; /** Identifier values already added to an existing patient, keyed by identifier field name. */ addedIdentifiers: Record = {}; }