diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd6e66f --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +KEYCLOAK_ID= +KEYCLOAK_SECRET= +KEYCLOAK_ISSUER= +KEYCLOAK_AUTHORIZE_TOKEN_URL= +KEYCLOAK_ACCESS_TOKEN_URL= +KEYCLOAK_USERNAME= +KEYCLOAK_PASSWORD= +FHIR_BASE_URL= +COOLIFY_TOKEN= + +RESOURCES_CACHE_DIR= +RESOURCES_CACHE_TAG= +RESOURCES_CACHE_URL= +RESOURCES_SSH_PASSPHRASE= +RESOURCES_SSH_LOCATION= \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 03da255..ff206e9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -44,6 +44,6 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - name: Deploy to Coolify - run: | - curl --request GET '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}' \ No newline at end of file +# - name: Deploy to Coolify +# run: | +# curl --request GET '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 206908e..cda98ef 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,9 @@ bin/ ### Mac OS ### .DS_Store -*.env \ No newline at end of file +*.env + +.kotlin/ + +.temp/ +fhir_data/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f6976ed --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +run: + docker-compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .dev.env up -d + +down: + docker-compose -f docker-compose.yml -f docker-compose.dev.yml down \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..983d0ff --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,13 @@ +tasks.register("downloadDependencies") { + doLast { + allprojects { + configurations.all { + try { + resolve() + } catch (e: Exception) { + // Ignore resolution errors + } + } + } + } +} \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 2f53703..3305618 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -42,6 +42,9 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) + + // https://mvnrepository.com/artifact/org.json/json + implementation("org.json:json:20240303") } diff --git a/core/src/main/kotlin/com/google/android/fhir/LocalChange.kt b/core/src/main/kotlin/com/google/android/fhir/LocalChange.kt index 88c93c6..e8f4e09 100644 --- a/core/src/main/kotlin/com/google/android/fhir/LocalChange.kt +++ b/core/src/main/kotlin/com/google/android/fhir/LocalChange.kt @@ -1,5 +1,11 @@ package com.google.android.fhir +import ca.uhn.fhir.parser.IParser +import org.dtree.fhir.core.uploader.ContentTypes +import org.hl7.fhir.r4.model.Binary +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent +import org.hl7.fhir.r4.model.Resource import java.time.Instant data class LocalChange( @@ -20,6 +26,7 @@ data class LocalChange( * [LocalChange] class instance is created. */ var token: LocalChangeToken, + val isPatch: Boolean = false, ) { enum class Type(val value: Int) { INSERT(1), // create a new resource. payload is the entire resource json. @@ -31,6 +38,45 @@ data class LocalChange( fun from(input: Int): Type = values().first { it.value == input } } } + + fun createPatchRequest( + iParser: IParser, + resource: Resource? = null + ): BundleEntryComponent { + return if (type == LocalChange.Type.UPDATE) { + if (isPatch) { + createRequest(createPathRequest()) + } else { + createRequest(iParser.parseResource(payload) as Resource) + } + } else if (resource != null && type == LocalChange.Type.INSERT) { + createRequest(resource) + } else { + val resourceToUpload = iParser.parseResource(payload) as Resource + createRequest(resourceToUpload) + } + } + + private fun createPathRequest(): Binary { + return Binary().apply { + contentType = ContentTypes.APPLICATION_JSON_PATCH + data = payload.toByteArray() + } + } + + private fun createRequest(resourceToUpload: Resource): BundleEntryComponent { + return BundleEntryComponent().apply { + resource = resourceToUpload + request = Bundle.BundleEntryRequestComponent().apply { + url = "${resourceType}/${resourceId}" + method = when (type) { + LocalChange.Type.INSERT -> Bundle.HTTPVerb.PUT + LocalChange.Type.UPDATE -> Bundle.HTTPVerb.PATCH + LocalChange.Type.DELETE -> Bundle.HTTPVerb.DELETE + } + } + } + } } data class LocalChangeToken(val ids: List) \ No newline at end of file diff --git a/core/src/main/kotlin/com/google/android/fhir/MoreTypes.kt b/core/src/main/kotlin/com/google/android/fhir/MoreTypes.kt new file mode 100644 index 0000000..9f56957 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/MoreTypes.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2023-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir + +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.IdType +import org.hl7.fhir.r4.model.PrimitiveType +import org.hl7.fhir.r4.model.Quantity +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.Type +import org.hl7.fhir.r4.model.UriType +import java.util.* + + +/** + * Returns the string representation for [PrimitiveType] or [Quantity], otherwise defaults to null + */ +private fun getValueString(type: Type): String? = + when (type) { + is Quantity -> type.value?.toString() + else -> (type as? PrimitiveType<*>)?.asStringValue() + } + +/** Converts StringType to toUriType. */ +internal fun StringType.toUriType(): UriType { + return UriType(value) +} + +/** Converts StringType to CodeType. */ +internal fun StringType.toCodeType(): CodeType { + return CodeType(value) +} + +/** Converts StringType to IdType. */ +internal fun StringType.toIdType(): IdType { + return IdType(value) +} + +/** Converts Coding to CodeType. */ +internal fun Coding.toCodeType(): CodeType { + return CodeType(code) +} + +/** + * Converts Quantity to Coding type. The resulting Coding properties are equivalent of Coding.system + * = Quantity.system Coding.code = Quantity.code Coding.display = Quantity.unit + */ +internal fun Quantity.toCoding(): Coding { + return Coding(this.system, this.code, this.unit) +} + +/** + * Returns whether two instances of the [Type] class are equal. + * + * Note this is not an operator because it is not possible to overload the equality operator as an + * extension. + */ +fun equals(a: Type, b: Type): Boolean { + if (a::class != b::class) return false + + if (a === b) return true + + if (a.isPrimitive) return a.primitiveValue() == b.primitiveValue() + + // Codes with the same system and code values are considered equal even if they have different + // display values. + if (a is Coding && b is Coding) return a.system == b.system && a.code == b.code + + throw NotImplementedError("Comparison for type ${a::class.java} not supported.") +} + +internal fun Type.hasValue(): Boolean = !getValueString(this).isNullOrBlank() + +internal val Type.cqfCalculatedValueExpression + get() = this.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL)?.value as? Expression + +internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = + "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" + +operator fun Type.compareTo(value: Type): Int { + if (!this.fhirType().equals(value.fhirType())) { + throw IllegalArgumentException( + "Cannot compare different data types: ${this.fhirType()} and ${value.fhirType()}", + ) + } + when { + this.fhirType().equals("integer") -> { + return this.primitiveValue().toInt().compareTo(value.primitiveValue().toInt()) + } + this.fhirType().equals("decimal") -> { + return this.primitiveValue().toBigDecimal().compareTo(value.primitiveValue().toBigDecimal()) + } + this.fhirType().equals("date") -> { + return clearTimeFromDateValue(this.dateTimeValue().value) + .compareTo(clearTimeFromDateValue(value.dateTimeValue().value)) + } + this.fhirType().equals("dateTime") -> { + return this.dateTimeValue().value.compareTo(value.dateTimeValue().value) + } + this.fhirType().equals("Quantity") -> { + val quantity = + UnitConverter.getCanonicalFormOrOriginal(UcumValue((this as Quantity).code, this.value)) + val anotherQuantity = + UnitConverter.getCanonicalFormOrOriginal(UcumValue((value as Quantity).code, value.value)) + if (quantity.code != anotherQuantity.code) { + throw IllegalArgumentException( + "Cannot compare different quantity codes: ${quantity.code} and ${anotherQuantity.code}", + ) + } + return quantity.value.compareTo(anotherQuantity.value) + } + else -> { + throw NotImplementedError() + } + } +} + +private fun clearTimeFromDateValue(dateValue: Date): Date { + val calendarValue = Calendar.getInstance() + calendarValue.time = dateValue + calendarValue.set(Calendar.HOUR_OF_DAY, 0) + calendarValue.set(Calendar.MINUTE, 0) + calendarValue.set(Calendar.SECOND, 0) + calendarValue.set(Calendar.MILLISECOND, 0) + return calendarValue.time +} + +fun StringType.getLocalizedText(lang: String = Locale.getDefault().toLanguageTag()): String? { + return getTranslation(lang) ?: getTranslation(lang.split("-").firstOrNull()) ?: value +} diff --git a/core/src/main/kotlin/com/google/android/fhir/UnitConverter.kt b/core/src/main/kotlin/com/google/android/fhir/UnitConverter.kt new file mode 100644 index 0000000..8dcad74 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/UnitConverter.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir + +import java.lang.NullPointerException +import java.math.BigDecimal +import java.math.MathContext +import org.fhir.ucum.Decimal +import org.fhir.ucum.Pair +import org.fhir.ucum.UcumEssenceService +import org.fhir.ucum.UcumException + +/** + * Canonicalizes unit values to UCUM base units. + * + * For details of UCUM, see http://unitsofmeasure.org/ + * + * For using UCUM with FHIR, see https://www.hl7.org/fhir/ucum.html + * + * For the implementation of UCUM with FHIR, see https://github.com/FHIR/Ucum-java + */ +object UnitConverter { + private val ucumService by lazy { + UcumEssenceService(this::class.java.getResourceAsStream("/ucum-essence.xml")) + } + + /** + * Returns the canonical form of a UCUM Value. + * + * The canonical form is generated by normalizing [value] to UCUM base units, used to generate + * canonical matches on Quantity Search + * + * @throws ConverterException if fails to generate canonical matches + * + * For example a value of 1000 mm will return 1 m. + */ + fun getCanonicalForm(value: UcumValue): UcumValue { + try { + val pair = + ucumService.getCanonicalForm(Pair(Decimal(value.value.toPlainString()), value.code)) + return UcumValue( + pair.code, + pair.value.asDecimal().toBigDecimal(MathContext(value.value.precision())), + ) + } catch (e: UcumException) { + throw ConverterException("UCUM conversion failed", e) + } catch (e: NullPointerException) { + // See https://github.com/google/android-fhir/issues/869 for why NPE needs to be caught + throw ConverterException("Missing numerical value in the canonical UCUM value", e) + } + } + + /** + * Returns the canonical form of a UCUM Value if it is supported in Ucum library. + * + * The canonical form is generated by normalizing [value] to UCUM base units, used to generate + * canonical matches on Quantity Search, if fails to generate then returns original value + * + * For example a value of 1000 mm will return 1 m. + */ + fun getCanonicalFormOrOriginal(value: UcumValue): UcumValue { + return try { + getCanonicalForm(value) + } catch (e: ConverterException) { + val pair = Pair(Decimal(value.value.toPlainString()), value.code) + UcumValue( + pair.code, + pair.value.asDecimal().toBigDecimal(MathContext(value.value.precision())), + ) + } + } +} + +class ConverterException(message: String, cause: Throwable) : Exception(message, cause) + +data class UcumValue(val code: String, val value: BigDecimal) diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/DataCaptureConfig.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/DataCaptureConfig.kt new file mode 100644 index 0000000..7ca0c7a --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/DataCaptureConfig.kt @@ -0,0 +1,14 @@ +package com.google.android.fhir.datacapture + +import org.hl7.fhir.r4.model.Resource + +/** + * Resolves resources based on the provided xFhir query. This allows the library to resolve + * x-fhir-query answer expressions. + * + * NOTE: The result of the resolution may be cached to improve performance. In other words, the + * resolver may be called only once after which the Resources may be used multiple times in the UI. + */ +fun interface XFhirQueryResolver { + suspend fun resolve(xFhirQuery: String): List +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/enablement/EnablementEvaluator.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/enablement/EnablementEvaluator.kt new file mode 100644 index 0000000..851e6ec --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/enablement/EnablementEvaluator.kt @@ -0,0 +1,281 @@ +/* + * Copyright 2022-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.enablement + +import com.google.android.fhir.compareTo +import com.google.android.fhir.datacapture.XFhirQueryResolver +import com.google.android.fhir.datacapture.extensions.allItems +import com.google.android.fhir.datacapture.extensions.enableWhenExpression +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator +import com.google.android.fhir.datacapture.fhirpath.convertToBoolean +import com.google.android.fhir.equals +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Resource + +/** + * Evaluator for the enablement status of a [Questionnaire.QuestionnaireItemComponent]. + * + * This is done by locating the relevant [QuestionnaireResponse.QuestionnaireResponseItemComponent]s + * specified by the linkIds in the `enableWhen` constraints, and checking if the answers (or lack + * thereof) satisfy the criteria in the `enableWhen` constraints. The `enableBehavior` value is then + * used to combine the evaluation results of different `enableWhen` constraints. + * + * For example, the following `enableWhen` constraint in a + * [Questionnaire.QuestionnaireItemComponent] + * + * ``` + * "enableWhen": [ + * { + * "question": "vitaminKgiven", + * "operator": "exists", + * "answerBoolean": true + * } + * ], + * ``` + * + * specifies that the [Questionnaire.QuestionnaireItemComponent] should be enabled only if the + * question with linkId `vitaminKgiven` has been answered. + * + * The enablement status typically determines whether the [Questionnaire.QuestionnaireItemComponent] + * is shown or hidden. However, it is also possible that only user interaction is enabled or + * disabled (e.g. grayed out) with the [Questionnaire.QuestionnaireItemComponent] always shown. + * + * The evaluator works in the context of a Questionnaire and the corresponding + * QuestionnaireResponse. It is the caller's responsibility to make sure to call the evaluator with + * QuestionnaireItems and QuestionnaireResponseItems that belong to the Questionnaire and the + * QuestionnaireResponse. + * + * For more information see + * [Questionnaire.item.enableWhen](https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.enableWhen) + * and + * [Questionnaire.item.enableBehavior](https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.enableBehavior) + * . + * + * @param questionnaire the [Questionnaire] where the expression belong to + * @param questionnaireResponse the [QuestionnaireResponse] related to the [Questionnaire] + * @param questionnaireItemParentMap the [Map] of items parent + * @param questionnaireLaunchContextMap the [Map] of launchContext names to their resource values + */ +class EnablementEvaluator( + private val questionnaire: Questionnaire, + private val questionnaireResponse: QuestionnaireResponse, + private val questionnaireItemParentMap: + Map = + emptyMap(), + private val questionnaireLaunchContextMap: Map? = emptyMap(), + private val xFhirQueryResolver: XFhirQueryResolver? = null, +) { + + private val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + + /** + * The pre-order traversal trace of the items in the [QuestionnaireResponse]. This essentially + * represents the order in which all items are displayed in the UI. + */ + private val questionnaireResponseItemPreOrderList = questionnaireResponse.allItems + + /** The map from each item in the [QuestionnaireResponse] to its parent. */ + private val questionnaireResponseItemParentMap = + mutableMapOf< + QuestionnaireResponse.QuestionnaireResponseItemComponent, + QuestionnaireResponse.QuestionnaireResponseItemComponent, + >() + + init { + /** Adds each child-parent pair in the [QuestionnaireResponse] to the parent map. */ + fun buildParentList(item: QuestionnaireResponse.QuestionnaireResponseItemComponent) { + for (child in item.item) { + questionnaireResponseItemParentMap[child] = item + buildParentList(child) + } + for (answer in item.answer) { + for (nestedItem in answer.item) { + buildParentList(nestedItem) + } + } + } + + for (item in questionnaireResponse.item) { + buildParentList(item) + } + } + + /** + * Returns whether [questionnaireItem] should be enabled. + * + * @param questionnaireItem the corresponding questionnaire item. + * @param questionnaireResponseItem the corresponding questionnaire response item. + */ + suspend fun evaluate( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + ): Boolean { + val enableWhenList = questionnaireItem.enableWhen + val enableWhenExpression = questionnaireItem.enableWhenExpression + + // The questionnaire item is enabled by default if there is no `enableWhen` constraint and no + // `enableWhenExpression`. + if (enableWhenList.isEmpty() && enableWhenExpression == null) return true + + // Evaluate `enableWhenExpression`. + if (enableWhenExpression != null && enableWhenExpression.hasExpression()) { + return convertToBoolean( + expressionEvaluator.evaluateExpression( + questionnaireItem, + questionnaireResponseItem, + questionnaireItem.enableWhenExpression!!, + ), + ) + } + + // Evaluate single `enableWhen` constraint. + if (enableWhenList.size == 1) { + return evaluateEnableWhen( + enableWhenList.single(), + questionnaireItem, + questionnaireResponseItem, + ) + } + + // Evaluate multiple `enableWhen` constraints and aggregate the results according to + // `enableBehavior` which specifies one of the two behaviors: 1) the questionnaire item is + // enabled if ALL `enableWhen` constraints are satisfied, or 2) the questionnaire item is + // enabled if ANY `enableWhen` constraint is satisfied. + return when (val value = questionnaireItem.enableBehavior) { + Questionnaire.EnableWhenBehavior.ALL -> + enableWhenList.all { evaluateEnableWhen(it, questionnaireItem, questionnaireResponseItem) } + Questionnaire.EnableWhenBehavior.ANY -> + enableWhenList.any { evaluateEnableWhen(it, questionnaireItem, questionnaireResponseItem) } + else -> throw IllegalStateException("Unrecognized enable when behavior $value") + } + } + + /** + * Returns whether the `enableWhen` constraint is satisfied for the `questionnaireResponseItem`. + */ + private fun evaluateEnableWhen( + enableWhen: Questionnaire.QuestionnaireItemEnableWhenComponent, + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + ): Boolean { + val targetQuestionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent? = + if ( + questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY && + questionnaireResponseItem.linkId == enableWhen.question + ) { + questionnaireResponseItem + } else { + findEnableWhenQuestionnaireResponseItem(questionnaireResponseItem, enableWhen.question) + } + return if (Questionnaire.QuestionnaireItemOperator.EXISTS == enableWhen.operator) { + // True iff the answer value of the enable when is equal to whether an answer exists in the + // target questionnaire response item + enableWhen.answerBooleanType.booleanValue() == + !(targetQuestionnaireResponseItem == null || + targetQuestionnaireResponseItem.answer.isEmpty()) + } else { + // The `enableWhen` constraint evaluates to true if at least one answer has a value that + // satisfies the `enableWhen` operator and answer, with the exception of the `Exists` + // operator. + // See https://www.hl7.org/fhir/valueset-questionnaire-enable-operator.html. + targetQuestionnaireResponseItem?.answer?.any { enableWhen.predicate(it) } ?: false + } + } + + /** + * Find a questionnaire response item in [QuestionnaireResponse] with the given `linkId` starting + * from the `origin`. + * + * This is used by the enableWhen logic to evaluate if a question should be enabled/displayed. + * + * If multiple questionnaire response items are present for the same question (same linkId), + * either as a result of repeated group or nested question under repeated answers, this returns + * the nearest question occurrence reachable by tracing first the "ancestor" axis and then the + * "preceding" axis and then the "following" axis. + * + * See + * https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.enableWhen.question. + */ + private fun findEnableWhenQuestionnaireResponseItem( + origin: QuestionnaireResponse.QuestionnaireResponseItemComponent, + linkId: String, + ): QuestionnaireResponse.QuestionnaireResponseItemComponent? { + // Find the nearest ancestor with the linkId + var parent = questionnaireResponseItemParentMap[origin] + while (parent != null) { + if (parent.linkId == linkId) { + return parent + } + parent = questionnaireResponseItemParentMap[parent] + } + + // Find the nearest item preceding the origin + val itemIndex = questionnaireResponseItemPreOrderList.indexOf(origin) + for (index in itemIndex - 1 downTo 0) { + if (questionnaireResponseItemPreOrderList[index].linkId == linkId) { + return questionnaireResponseItemPreOrderList[index] + } + } + + // Find the nearest item succeeding the origin + for (index in itemIndex + 1 until questionnaireResponseItemPreOrderList.size) { + if (questionnaireResponseItemPreOrderList[index].linkId == linkId) { + return questionnaireResponseItemPreOrderList[index] + } + } + + return null + } +} + +/** + * The predicate to evaluate the status of the enableWhen on the `EnableWhen` `operator` and + * `Answer` value. + */ +private val Questionnaire.QuestionnaireItemEnableWhenComponent.predicate: + (QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) -> Boolean + get() = { + when (operator) { + Questionnaire.QuestionnaireItemOperator.EQUAL -> { + equals(it.value, answer) + } + Questionnaire.QuestionnaireItemOperator.NOT_EQUAL -> { + !equals(it.value, answer) + } + Questionnaire.QuestionnaireItemOperator.GREATER_THAN -> { + it.value > answer + } + Questionnaire.QuestionnaireItemOperator.GREATER_OR_EQUAL -> { + it.value >= answer + } + Questionnaire.QuestionnaireItemOperator.LESS_THAN -> { + it.value < answer + } + Questionnaire.QuestionnaireItemOperator.LESS_OR_EQUAL -> { + it.value <= answer + } + else -> throw NotImplementedError("Enable when operator $operator is not implemented.") + } + } diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreEnumerations.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreEnumerations.kt new file mode 100644 index 0000000..b24e7c0 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreEnumerations.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2022-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.extensions + +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Enumeration + +/** + * All the enums defined in [org.hl7.fhir.r4.model.Enumerations] have these common methods + * [fromCode, valueOf, values, getDefinition, getDisplay, getSystem, toCode]. This function converts + * the high level [org.hl7.fhir.r4.model.Enumerations] of something like + * [org.hl7.fhir.r4.model.Enumerations.AdministrativeGender] into a corresponding [Coding]. The + * reason we use reflection here to get the actual value is that [Enumeration] provides a default + * implementation for some of the apis like [Enumeration.getDisplay] and always return null. So as + * client, we have to call the desired api on the GenericType passed to the [Enumeration] and get + * the desired value by calling the api's as described above. + */ +internal fun Enumeration<*>.toCoding(): Coding { + val enumeration = this + return Coding().apply { + display = + if (enumeration.hasDisplay()) { + enumeration.display + } else { + enumeration.value.invokeFunction("getDisplay") as String? + } + code = + if (enumeration.hasCode()) { + enumeration.code + } else { + enumeration.value.invokeFunction("toCode") as String? + } + system = + if (enumeration.hasSystem()) { + enumeration.system + } else { + enumeration.value.invokeFunction("getSystem") as String? + } + } +} + +/** + * Invokes function specified by [functionName] on the calling object with the provided arguments + * [args] + */ +internal fun Any.invokeFunction( + functionName: String, + parameterTypes: List> = listOf(), + vararg args: Any?, +): Any? = + this::class + .java + .getDeclaredMethod(functionName, *parameterTypes.toTypedArray()) + .apply { isAccessible = true } + .invoke(this, *args) diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt new file mode 100644 index 0000000..5c77c0d --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.extensions + +import org.hl7.fhir.r4.model.Expression + +internal val Expression.isXFhirQuery: Boolean + get() = this.language == Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() + +internal val Expression.isFhirPath: Boolean + get() = this.language == Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemAnswerOptionComponents.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemAnswerOptionComponents.kt new file mode 100644 index 0000000..0bb2913 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemAnswerOptionComponents.kt @@ -0,0 +1,11 @@ +package com.google.android.fhir.datacapture.extensions + +import org.hl7.fhir.r4.model.Attachment +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemAnswerOptionComponent +import org.hl7.fhir.r4.model.Type + +/** Get the answer options values with `initialSelected` set to true */ +internal val List.initialSelected: List + get() = this.filter { it.initialSelected }.map { it.value } \ No newline at end of file diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt new file mode 100644 index 0000000..766f42f --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt @@ -0,0 +1,305 @@ +/* + * Copyright 2023-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.extensions + +import com.google.android.fhir.getLocalizedText +import org.hl7.fhir.r4.model.* + +// Please note these URLs do not point to any FHIR Resource and are broken links. They are being +// used until we can engage the FHIR community to add these extensions officially. + +internal const val EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR = + "https://github.com/google/android-fhir/StructureDefinition/questionnaire-itemControl" + +internal const val ITEM_INITIAL_EXPRESSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression" + +internal const val EXTENSION_VARIABLE_URL = "http://hl7.org/fhir/StructureDefinition/variable" + +internal const val EXTENSION_ITEM_CONTROL_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl" + +internal const val EXTENSION_ITEM_CONTROL_SYSTEM = "http://hl7.org/fhir/questionnaire-item-control" + +internal const val EXTENSION_ENABLE_WHEN_EXPRESSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression" + +internal const val EXTENSION_CALCULATED_EXPRESSION_URL = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression" + +/** + * Creates a list of [QuestionnaireResponse.QuestionnaireResponseItemComponent]s corresponding to + * the nested items under the questionnaire item. + * + * The list can be added as nested items under answers in a corresponding questionnaire response + * item. This may be because + * 1. the questionnaire item is a question with nested questions, in which case each answer in the + * questionnaire response item needs to have the same nested questions, or + * 2. the questionnaire item is a repeated group, in which case each answer in the questionnaire + * response item represents an instance of the repeated group, and needs to have the same nested + * questions. + * + * The hierarchy and order of child items will be retained as specified in the standard. See + * https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. + */ +internal fun Questionnaire.QuestionnaireItemComponent.createNestedQuestionnaireResponseItems() = + item.map { it.createQuestionnaireResponseItem() } + + + +// ********************************************************************************************** // +// // +// Utilities: zip with questionnaire response item list, nested items, create response items, // +// flattening, etc. // +// // +// ********************************************************************************************** // + +/** + * Returns a list of values built from the elements of `this` and the + * `questionnaireResponseItemList` with the same linkId using the provided `transform` function + * applied to each pair of questionnaire item and questionnaire response item. + * + * In case of repeated group item, `questionnaireResponseItemList` will contain + * QuestionnaireResponseItemComponent with same linkId. So these items are grouped with linkId and + * associated with its questionnaire item linkId. + */ +internal inline fun List.zipByLinkId( + questionnaireResponseItemList: List, + transform: + ( + Questionnaire.QuestionnaireItemComponent, + QuestionnaireResponse.QuestionnaireResponseItemComponent, + ) -> T, +): List { + val linkIdToQuestionnaireResponseItemListMap = questionnaireResponseItemList.groupBy { it.linkId } + return flatMap { questionnaireItem -> + linkIdToQuestionnaireResponseItemListMap[questionnaireItem.linkId]?.mapNotNull { + questionnaireResponseItem -> + transform(questionnaireItem, questionnaireResponseItem) + } + ?: emptyList() + } +} + +internal val Questionnaire.QuestionnaireItemComponent.isRepeatedGroup: Boolean + get() = type == Questionnaire.QuestionnaireItemType.GROUP && repeats +// TODO: Move this elsewhere. +val Resource.logicalId: String + get() { + return this.idElement?.idPart.orEmpty() + } + +/** + * The initial-expression extension on [QuestionnaireItemComponent] to allow dynamic selection of + * default or initially selected answers + */ +val Questionnaire.QuestionnaireItemComponent.initialExpression: Expression? + get() { + return this.extension + .firstOrNull { it.url == ITEM_INITIAL_EXPRESSION_URL } + ?.let { it.value as Expression } + } + +/** + * Creates a corresponding [QuestionnaireResponse.QuestionnaireResponseItemComponent] for the + * questionnaire item with the following properties: + * - same `linkId` as the questionnaire item, + * - any initial answer(s) specified either in the `initial` element or as `initialSelected` + * `answerOption`(s), + * - any nested questions under the initial answers (there will be no user input yet since this is + * just being created) if this is a question with nested questions, and + * - any nested questions if this is a non-repeated group. + * + * Note that although initial answers to a repeated group may be interpreted as initial instances of + * the repeated group in the in-memory representation of questionnaire response, they are not + * defined as such in the standard. As a result, we are not treating them as such in this function + * to be conformant. + * + * The hierarchy and order of child items will be retained as specified in the standard. See + * https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. + */ +internal fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItem(): + QuestionnaireResponse.QuestionnaireResponseItemComponent { + return QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = this@createQuestionnaireResponseItem.linkId + answer = createQuestionnaireResponseItemAnswers() + if ( + type != Questionnaire.QuestionnaireItemType.GROUP && + this@createQuestionnaireResponseItem.item.isNotEmpty() && + answer.isNotEmpty() + ) { + this.copyNestedItemsToChildlessAnswers(this@createQuestionnaireResponseItem) + } else if ( + this@createQuestionnaireResponseItem.type == Questionnaire.QuestionnaireItemType.GROUP && + !repeats + ) { + this@createQuestionnaireResponseItem.item.forEach { + if (!it.isRepeatedGroup) { + this.addItem(it.createQuestionnaireResponseItem()) + } + } + } + } +} + +/** + * Returns a list of answers from the initial values of the questionnaire item. `null` if no initial + * value. + */ +private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers(): + MutableList? { + // TODO https://github.com/google/android-fhir/issues/2161 + // The rule can be by-passed if initial value was set by an initial-expression. + // The [ResourceMapper] at L260 wrongfully sets the initial property of questionnaire after + // evaluation of initial-expression. + require(answerOption.isEmpty() || initial.isEmpty() || initialExpression != null) { + "Questionnaire item $linkId has both initial value(s) and has answerOption. See rule que-11 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." + } + + // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial + // quantity given as initial without value is for unit reference purpose only. Answer conversion + // not needed + if ( + answerOption.initialSelected.isEmpty() && + (initial.isEmpty() || + (initialFirstRep.hasValueQuantity() && initialFirstRep.valueQuantity.value == null)) + ) { + return null + } + + if ( + type == Questionnaire.QuestionnaireItemType.GROUP || + type == Questionnaire.QuestionnaireItemType.DISPLAY + ) { + throw IllegalArgumentException( + "Questionnaire item $linkId has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial.", + ) + } + + if ((answerOption.initialSelected.size > 1 || initial.size > 1) && !repeats) { + throw IllegalArgumentException( + "Questionnaire item $linkId can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial.", + ) + } + + return initial + .map { it.value } + .plus(answerOption.initialSelected) + .map { QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = it } } + .toMutableList() +} + +// ********************************************************************************************** // +// // +// Additional display utilities: display item control, localized text spanned, // +// localized prefix spanned, localized instruction spanned, etc. // +// // +// ********************************************************************************************** // + +/** UI controls relevant to rendering questionnaire items. */ +internal enum class DisplayItemControlType(val extensionCode: String) { + FLYOVER("flyover"), + PAGE("page"), + HELP("help"), +} + +/** Item control to show instruction text */ +internal val Questionnaire.QuestionnaireItemComponent.displayItemControl: DisplayItemControlType? + get() { + val codeableConcept = + this.extension.firstOrNull { it.url == EXTENSION_ITEM_CONTROL_URL }?.value as CodeableConcept? + val code = + codeableConcept?.coding?.firstOrNull { it.system == EXTENSION_ITEM_CONTROL_SYSTEM }?.code + return DisplayItemControlType.values().firstOrNull { it.extensionCode == code } + } + +// Return expression if QuestionnaireItemComponent has ENABLE WHEN EXPRESSION URL +val Questionnaire.QuestionnaireItemComponent.enableWhenExpression: Expression? + get() { + return this.extension + .firstOrNull { it.url == EXTENSION_ENABLE_WHEN_EXPRESSION_URL } + ?.let { it.value as Expression } + } + +/** Returns Calculated expression, or null */ +internal val Questionnaire.QuestionnaireItemComponent.calculatedExpression: Expression? + get() = + this.getExtensionByUrl(EXTENSION_CALCULATED_EXPRESSION_URL)?.let { + it.castToExpression(it.value) + } + +/** + * Flatten a nested list of [Questionnaire.QuestionnaireItemComponent] recursively and returns a + * flat list of all items into list embedded at any level + */ +fun List.flattened(): + List = + mutableListOf().also { flattenInto(it) } + +private fun List.flattenInto( + output: MutableList, +) { + forEach { + output.add(it) + it.item.flattenInto(output) + } +} + +/** + * Whether [item] has any expression directly referencing the current questionnaire item by link ID + * (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where + * `this-question` is the link ID of the current questionnaire item). + */ +internal fun Questionnaire.QuestionnaireItemComponent.isReferencedBy( + item: Questionnaire.QuestionnaireItemComponent, +) = + item.expressionBasedExtensions.any { + it + .castToExpression(it.value) + .expression + .replace(" ", "") + .contains(Regex(".*linkId='${this.linkId}'.*")) + } + +/** Returns list of extensions whose value is of type [Expression] */ +internal val Questionnaire.QuestionnaireItemComponent.expressionBasedExtensions + get() = this.extension.filter { it.value is Expression } + +internal val Questionnaire.QuestionnaireItemComponent.variableExpressions: List + get() = + this.extension.filter { it.url == EXTENSION_VARIABLE_URL }.map { it.castToExpression(it.value) } + + +/** + * Finds the specific variable name [String] at the questionnaire item + * [Questionnaire.QuestionnaireItemComponent] + * + * @param variableName the [String] to match the variable + * @return an [Expression] + */ +internal fun Questionnaire.QuestionnaireItemComponent.findVariableExpression( + variableName: String, +): Expression? { + return variableExpressions.find { it.name == variableName } +} + +/** + * Localized and spanned value of [Questionnaire.QuestionnaireItemComponent.text] if translation is + * present. Default value otherwise. + */ +val Questionnaire.QuestionnaireItemComponent.localizedTextSpanned: String? + get() = textElement?.getLocalizedText() \ No newline at end of file diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt new file mode 100644 index 0000000..85b48aa --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.extensions + +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse + +/** + * Pre-order list of descendants of the questionnaire response item (inclusive of the current item). + */ +val QuestionnaireResponse.QuestionnaireResponseItemComponent.descendant: + List + get() = + mutableListOf().also { + appendDescendantTo(it) + } + +private fun QuestionnaireResponse.QuestionnaireResponseItemComponent.appendDescendantTo( + output: MutableList, +) { + output.add(this) + item.forEach { it.appendDescendantTo(output) } + answer.forEach { answer -> answer.item.forEach { it.appendDescendantTo(output) } } +} + +/** + * Copies nested items under `questionnaireItem` to each answer without children. The hierarchy and + * order of nested items will be retained as specified in the standard. + * + * Existing answers with nested items will not be modified because the nested items may contain + * answers already. + * + * This should be used when + * - a new answer is added to a question with nested questions, or + * - a new answer is added to a repeated group (in which case this indicates a new instance of the + * repeated group will be added to the final questionnaire response). + * + * See https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. + */ +internal fun QuestionnaireResponse.QuestionnaireResponseItemComponent + .copyNestedItemsToChildlessAnswers( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, +) { + answer + .filter { it.item.isEmpty() } + .forEach { it.item = questionnaireItem.createNestedQuestionnaireResponseItems() } +} diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt new file mode 100644 index 0000000..c9aa893 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt @@ -0,0 +1,45 @@ +package com.google.android.fhir.datacapture.extensions + +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse + +/** Pre-order list of all questionnaire response items in the questionnaire. */ +val QuestionnaireResponse.allItems: List + get() = item.flatMap { it.descendant } + + fun QuestionnaireResponse.unpackRepeatedGroups(questionnaire: Questionnaire) { + item = unpackRepeatedGroups(questionnaire.item, item) +} + +private fun unpackRepeatedGroups( + questionnaireItems: List, + questionnaireResponseItems: List, +): List { + return questionnaireItems + .zipByLinkId(questionnaireResponseItems) { questionnaireItem, questionnaireResponseItem -> + unpackRepeatedGroups(questionnaireItem, questionnaireResponseItem) + } + .flatten() +} + +private fun unpackRepeatedGroups( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, +): List { + questionnaireResponseItem.item = + unpackRepeatedGroups(questionnaireItem.item, questionnaireResponseItem.item) + questionnaireResponseItem.answer.forEach { + it.item = unpackRepeatedGroups(questionnaireItem.item, it.item) + } + return if (questionnaireItem.isRepeatedGroup) { + questionnaireResponseItem.answer.map { + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = questionnaireItem.linkId + text = questionnaireItem.localizedTextSpanned + item = it.item + } + } + } else { + listOf(questionnaireResponseItem) + } +} diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt new file mode 100644 index 0000000..5f96c65 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2023-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.extensions + +import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.CanonicalType +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType + +/** + * The StructureMap url in the + * [target structure-map extension](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html) + * s. + */ +val Questionnaire.targetStructureMap: String? + get() { + val extensionValue = + this.extension.singleOrNull { it.url == TARGET_STRUCTURE_MAP }?.value ?: return null + return if (extensionValue is CanonicalType) extensionValue.valueAsString else null + } + +internal val Questionnaire.variableExpressions: List + get() = + this.extension.filter { it.url == EXTENSION_VARIABLE_URL }.map { it.castToExpression(it.value) } + +/** + * A list of extensions that define the resources that provide context for form processing logic: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + */ +internal val Questionnaire.questionnaireLaunchContexts: List? + get() = + this.extension + .filter { it.url == EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT } + .takeIf { it.isNotEmpty() } + +/** + * Finds the specific variable name [String] at questionnaire [Questionnaire] level + * + * @param variableName the [String] to match the variable at questionnaire [Questionnaire] level + * @return [Expression] the matching expression + */ +internal fun Questionnaire.findVariableExpression(variableName: String): Expression? = + variableExpressions.find { it.name == variableName } + +/** + * Validates each questionnaire launch context extension matches: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + */ +internal fun validateLaunchContextExtensions(launchContextExtensions: List) = + launchContextExtensions.forEach { launchExtension -> + validateLaunchContextExtension(launchExtension) + } + +/** + * Verifies the existence of extension:name and extension:type with valid name system and type + * values. + */ +private fun validateLaunchContextExtension(launchExtension: Extension) { + val nameCoding = + launchExtension.getExtensionByUrl("name")?.value as? Coding + ?: error( + "The extension:name is missing or is not of type Coding in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT", + ) + + val typeCodeType = + launchExtension.getExtensionByUrl("type")?.value as? CodeType + ?: error( + "The extension:type is missing or is not of type CodeType in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT", + ) + + val isValidResourceType = + try { + ResourceType.fromCode(typeCodeType.value) != null + } catch (exception: FHIRException) { + false + } + + if (nameCoding.system != CODE_SYSTEM_LAUNCH_CONTEXT || !isValidResourceType) { + error( + "The extension:name and/or extension:type do not follow the format specified in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT", + ) + } +} + +/** + * Filters the provided launch contexts by matching the keys with the `code` values found in the + * "name" extensions. + */ +internal fun filterByCodeInNameExtension( + launchContexts: Map, + launchContextExtensions: List, +): Map { + val nameCodes = + launchContextExtensions + .mapNotNull { extension -> (extension.getExtensionByUrl("name").value as? Coding)?.code } + .toSet() + + return launchContexts.filterKeys { nameCodes.contains(it) } +} + +/** + * See + * [Extension: target structure map](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html) + * . + */ +private const val TARGET_STRUCTURE_MAP: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap" + +val Questionnaire.isPaginated: Boolean + get() = item.any { item -> item.displayItemControl == DisplayItemControlType.PAGE } + +/** + * See + * [Extension: Entry mode](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-entryMode.html) + * . + */ +internal const val EXTENSION_ENTRY_MODE_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-entryMode" + +internal const val EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext" + +internal const val CODE_SYSTEM_LAUNCH_CONTEXT = + "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext" + +val Questionnaire.entryMode: EntryMode? + get() { + val entryMode = + this.extension + .firstOrNull { it.url == EXTENSION_ENTRY_MODE_URL } + ?.value + ?.toString() + ?.lowercase() + return EntryMode.from(entryMode) + } + +enum class EntryMode(val value: String) { + PRIOR_EDIT("prior-edit"), + RANDOM("random"), + SEQUENTIAL("sequential"), + ; + + companion object { + fun from(type: String?): EntryMode? = entries.find { it.value == type } + } +} + +/** + * Applies `forEach` on each questionnaire item and questionnaire response item pair in the + * questionnaire and the given `questionnaireResponse`. + * + * Questionnaire items and questionnaire response items are visited in pre-order. + * + * Items nested under repeated groups and repeated questions will be repeated for each repeated + * group instance or answer provided by the user. + * + * Note: use this function only with a questionnaire response that has been packed using + * [QuestionnaireResponse.packRepeatedGroups]. + */ +internal suspend fun Questionnaire.forEachItemPair( + questionnaireResponse: QuestionnaireResponse, + forEach: + suspend ( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + ) -> Unit, +) { + forEachItemPair(item, questionnaireResponse.item, forEach) +} + +private suspend fun forEachItemPair( + questionnaireItems: List, + questionnaireResponseItems: List, + forEach: + suspend ( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + ) -> Unit, +) { + require(questionnaireItems.size == questionnaireResponseItems.size) + questionnaireItems.zip(questionnaireResponseItems).forEach { + (questionnaireItem, questionnaireResponseItem) -> + require(questionnaireItem.linkId == questionnaireResponseItem.linkId) + + // Apply forEach on the current questionnaire item and questionnaire response item + forEach(questionnaireItem, questionnaireResponseItem) + + // For non-repeated groups, simply match the child questionnaire items with child questionnaire + // response items. + if ( + questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP && + !questionnaireItem.repeats && + questionnaireItem.item.isNotEmpty() + ) { + forEachItemPair(questionnaireItem.item, questionnaireResponseItem.item, forEach) + } + + // The following block handles two separate cases: + // 1. questionnaire items nested under repeated group are repeated for each instance of the + // repeated group, each represented as an answer components in the questionnaire response item. + // 2. questionnaire items nested directly under question are repeated for each answer. + if (questionnaireItem.repeats && questionnaireItem.item.isNotEmpty()) { + questionnaireResponseItem.answer.forEach { + forEachItemPair(questionnaireItem.item, it.item, forEach) + } + } + } +} diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt new file mode 100644 index 0000000..9132041 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -0,0 +1,526 @@ +/* + * Copyright 2023-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.fhirpath + +import com.google.android.fhir.datacapture.XFhirQueryResolver +import com.google.android.fhir.datacapture.extensions.calculatedExpression +import com.google.android.fhir.datacapture.extensions.findVariableExpression +import com.google.android.fhir.datacapture.extensions.flattened +import com.google.android.fhir.datacapture.extensions.isFhirPath +import com.google.android.fhir.datacapture.extensions.isReferencedBy +import com.google.android.fhir.datacapture.extensions.isXFhirQuery +import com.google.android.fhir.datacapture.extensions.variableExpressions +import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.ExpressionNode +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Type + +/** + * Evaluates an expression and returns its result. + * + * The evaluator works in the context of a [Questionnaire] and the corresponding + * [QuestionnaireResponse]. It is the caller's responsibility to make sure to call the evaluator + * with [QuestionnaireItemComponent] and [QuestionnaireResponseItemComponent] that belong to the + * [Questionnaire] and the [QuestionnaireResponse]. + * + * Expressions can be defined at questionnaire level and questionnaire item level. This + * [ExpressionEvaluator] supports evaluation of + * [variable expression](http://hl7.org/fhir/R4/extension-variable.html) defined at either + * questionnaire level or questionnaire item level. + * + * @param questionnaire the [Questionnaire] where the expression belong to + * @param questionnaireResponse the [QuestionnaireResponse] related to the [Questionnaire] + * @param questionnaireItemParentMap the [Map] of items parent + * @param questionnaireLaunchContextMap the [Map] of launchContext names to their resource values + */ +internal class ExpressionEvaluator( + private val questionnaire: Questionnaire, + private val questionnaireResponse: QuestionnaireResponse, + private val questionnaireItemParentMap: + Map = + emptyMap(), + private val questionnaireLaunchContextMap: Map? = emptyMap(), + private val xFhirQueryResolver: XFhirQueryResolver? = null, +) { + + private val reservedItemVariables = + listOf( + "sct", + "loinc", + "ucum", + "resource", + "rootResource", + "context", + "map-codes", + "questionnaire", + "qItem", + ) + + private val reservedRootVariables = + listOf( + "sct", + "loinc", + "ucum", + "resource", + "rootResource", + "context", + "map-codes", + "questionnaire", + ) + + /** + * Finds all the matching occurrences of variables. For example, when we apply regex to the + * expression "%X + %Y", if we simply groupValues, it returns [%X, X], [%Y, Y] The group with + * index 0 is always the entire matched string (%X and %Y). The indices greater than 0 represent + * groups in the regular expression (X and Y) so we groupValues by first index to get only the + * variables name without % as prefix i.e, ([X, Y]) + * + * If we apply regex to the expression "X + Y", it returns nothing as there are no matching groups + * in this expression + */ + private val variableRegex = Regex("[%]([A-Za-z0-9\\-]{1,64})") + + /** + * Finds all the matching occurrences of FHIRPaths in x-fhir-query. See: + * https://build.fhir.org/ig/HL7/sdc/expressions.html#x-fhir-query-enhancements + */ + private val xFhirQueryEnhancementRegex = Regex("\\{\\{(.*?)\\}\\}") + + /** + * Variable %questionnaire corresponds to the Questionnaire resource into + * QuestionnaireResponse.questionnaire element. + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + private val questionnaireFhirPathSupplement = "questionnaire" + + /** + * Variable %qitem refer to Questionnaire.item that corresponds to context + * QuestionnaireResponse.item. It is only valid for FHIRPath expressions defined within a + * Questionnaire item. https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + private val questionnaireItemFhirPathSupplement = "qItem" + + /** Detects if any item into list is referencing a dependent item in its calculated expression */ + internal fun detectExpressionCyclicDependency(items: List) { + items + .flattened() + .filter { it.calculatedExpression != null } + .run { + forEach { current -> + // no calculable item depending on current item should be used as dependency into current + // item + this.forEach { dependent -> + check(!(current.isReferencedBy(dependent) && dependent.isReferencedBy(current))) { + "${current.linkId} and ${dependent.linkId} have cyclic dependency in expression based extension" + } + } + } + } + } + + /** + * Returns the evaluation result of the expression. + * + * FHIRPath supplements are handled according to + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. + * + * %resource = [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] + */ + suspend fun evaluateExpression( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent?, + expression: Expression, + ): List { + val appContext = extractItemDependentVariables(expression, questionnaireItem) + return evaluateToBase( + questionnaireResponse, + questionnaireResponseItem, + expression.expression, + appContext, + ) + } + + /** + * Returns single [Type] evaluation value result of an expression, including cqf-expression and + * cqf-calculatedValue expressions + */ + suspend fun evaluateExpressionValue( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent?, + expression: Expression, + ): Type? { + if (!expression.isFhirPath) { + throw UnsupportedOperationException("${expression.language} not supported yet") + } + return try { + evaluateExpression(questionnaireItem, questionnaireResponseItem, expression).singleOrNull() + as? Type + } catch (e: Exception) { + println("Could not evaluate expression ${expression.expression} with FHIRPathEngine" + e) + null + } + } + + /** + * Returns a list of pair of item and the calculated and evaluated value for all items with + * calculated expression extension, which is dependent on value of updated response + */ + suspend fun evaluateCalculatedExpressions( + questionnaireItem: QuestionnaireItemComponent, + updatedQuestionnaireResponseItemComponent: QuestionnaireResponseItemComponent?, + ): List { + return questionnaire.item + .flattened() + .filter { item -> + // Condition 1. item is calculable + // Condition 2. item answer depends on the updated item answer OR has a variable dependency + item.calculatedExpression != null && + (questionnaireItem.isReferencedBy(item) || + findDependentVariables(item.calculatedExpression!!).isNotEmpty()) + } + .map { item -> + val updatedAnswer = + evaluateExpression( + item, + updatedQuestionnaireResponseItemComponent, + item.calculatedExpression!!, + ) + .map { it.castToType(it) } + item to updatedAnswer + } + } + + /** + * Evaluates variable expression defined at questionnaire item level and returns the evaluated + * result. + * + * Parses the expression using regex [Regex] for variable (For example: A variable name could be + * %weight) and build a list of variables that the expression contains and for every variable, we + * first find it at questionnaire item, then up in the ancestors and then at questionnaire level, + * if found we get their expressions and pass them into the same function to evaluate its value + * recursively, we put the variable name and its evaluated value into the map [Map] to use this + * map to pass into fhirPathEngine's evaluate method to apply the evaluated values to the + * expression being evaluated. + * + * @param expression the [Expression] Variable expression + * Questionnaire.QuestionnaireItemComponent>] of child to parent + * @param questionnaireItem the [Questionnaire.QuestionnaireItemComponent] where this expression + * is defined, + * @param variablesMap the [Map] of variables, the default value is empty map + * @return [Base] the result of expression + */ + internal suspend fun evaluateQuestionnaireItemVariableExpression( + expression: Expression, + questionnaireItem: QuestionnaireItemComponent, + variablesMap: MutableMap = mutableMapOf(), + ): Base? { + require( + questionnaireItem.variableExpressions.any { + it.name == expression.name && it.expression == expression.expression + }, + ) { + "The expression should come from the same questionnaire item" + } + extractItemDependentVariables( + expression, + questionnaireItem, + variablesMap, + ) + + return evaluateVariable( + expression, + variablesMap, + ) + } + + /** + * Parses the expression using regex [Regex] for variable and build a map of variables and its + * values respecting the scope and hierarchy level + * + * @param expression the [Expression] expression to find variables applicable + * @param questionnaireItem the [Questionnaire.QuestionnaireItemComponent] where this expression + * @param variablesMap the [Map] of variables, the default value is empty map is + * defined + */ + internal suspend fun extractItemDependentVariables( + expression: Expression, + questionnaireItem: QuestionnaireItemComponent, + variablesMap: MutableMap = mutableMapOf(), + ): MutableMap { + questionnaireLaunchContextMap?.let { variablesMap.putAll(it) } + findDependentVariables(expression) + .filterNot { variable -> reservedItemVariables.contains(variable) } + .forEach { variableName -> + if (variablesMap[variableName] == null) { + findAndEvaluateVariable( + variableName, + questionnaireItem, + variablesMap, + ) + } + } + return variablesMap.apply { + put(questionnaireFhirPathSupplement, questionnaire) + put(questionnaireItemFhirPathSupplement, questionnaireItem) + } + } + + /** + * Evaluates variable expression defined at questionnaire level and returns the evaluated result. + * + * Parses the expression using [Regex] for variable (For example: A variable name could be + * %weight) and build a list of variables that the expression contains and for every variable, we + * first find it at questionnaire level, if found we get their expressions and pass them into the + * same function to evaluate its value recursively, we put the variable name and its evaluated + * value into the map [Map] to use this map to pass into fhirPathEngine's evaluate method to apply + * the evaluated values to the expression being evaluated. + * + * @param expression the [Expression] Variable expression + * @param variablesMap the [Map] of variables, the default value is empty map + * @return [Base] the result of expression + */ + internal suspend fun evaluateQuestionnaireVariableExpression( + expression: Expression, + variablesMap: MutableMap = mutableMapOf(), + ): Base? { + findDependentVariables(expression) + .filterNot { variable -> reservedRootVariables.contains(variable) } + .forEach { variableName -> + questionnaire.findVariableExpression(variableName)?.let { expression -> + if (variablesMap[expression.name] == null) { + variablesMap[expression.name] = + evaluateQuestionnaireVariableExpression( + expression, + variablesMap, + ) + } + } + } + + return evaluateVariable( + expression, + variablesMap, + ) + } + + /** + * Creates an x-fhir-query string for evaluation. For this, it evaluates both variables and + * fhir-paths in the expression. + */ + internal fun createXFhirQueryFromExpression( + expression: Expression, + variablesMap: Map = emptyMap(), + ): String { + // get all dependent variables and their evaluated values + val variablesEvaluatedPairs = + variablesMap + .filterKeys { expression.expression.contains("{{%$it}}") } + .map { Pair("{{%${it.key}}}", it.value?.primitiveValue() ?: "") } + + val fhirPathsEvaluatedPairs = + questionnaireLaunchContextMap + ?.toMutableMap() + .takeIf { !it.isNullOrEmpty() } + ?.also { it.put(questionnaireFhirPathSupplement, questionnaire) } + ?.let { evaluateXFhirEnhancement(expression, it) } + ?: emptySequence() + + return (variablesEvaluatedPairs + fhirPathsEvaluatedPairs).fold(expression.expression) { + acc: String, + pair: Pair, + -> + acc.replace(pair.first, pair.second) + } + } + + /** + * Evaluates an x-fhir-query that contains fhir-paths, returning a sequence of pairs. The first + * element in the pair is the FhirPath expression surrounded by curly brackets {{ fhir.path }}, + * and the second element is the evaluated string result from evaluating the resource passed in. + * + * @param expression x-fhir-query expression containing a FHIRpath, e.g. + * Practitioner?active=true&{{Practitioner.name.family}} + * @param launchContextMap the launch context to evaluate the expression against + */ + private fun evaluateXFhirEnhancement( + expression: Expression, + launchContextMap: Map, + ): Sequence> = + xFhirQueryEnhancementRegex + .findAll(expression.expression) + .map { it.groupValues } + .map { (fhirPathWithParentheses, fhirPath) -> + val expressionNode = extractExpressionNode(fhirPath) + val evaluatedResult = + evaluateToString( + expression = expressionNode, + data = launchContextMap[extractResourceType(expressionNode)], + contextMap = launchContextMap, + ) + + // If the result of evaluating the FHIRPath expressions is an invalid query, it returns + // null. As per the spec: + // Systems SHOULD log it and continue with extraction as if the query had returned no + // data. + // See : http://build.fhir.org/ig/HL7/sdc/extraction.html#structuremap-based-extraction + if (evaluatedResult.isEmpty()) { + println( + "$fhirPath evaluated to null. The expression is either invalid, or the " + + "expression returned no, or more than one resource. The expression will be " + + "replaced with a blank string.", + ) + } + fhirPathWithParentheses to evaluatedResult + } + + private fun findDependentVariables(expression: Expression) = + variableRegex.findAll(expression.expression).map { it.groupValues[1] }.toList() + + /** + * Finds the dependent variables at questionnaire item level first, then in ancestors and then at + * questionnaire level + * + * @param variableName the [String] to match the variable in the ancestors + * @param questionnaireItem the [Questionnaire.QuestionnaireItemComponent] from where we have to + * track hierarchy up in the ancestors + * @param variablesMap the [Map] of variables + */ + private suspend fun findAndEvaluateVariable( + variableName: String, + questionnaireItem: QuestionnaireItemComponent, + variablesMap: MutableMap = mutableMapOf(), + ) { + // First, check the questionnaire item itself + val evaluatedValue = + questionnaireItem.findVariableExpression(variableName)?.let { expression -> + evaluateQuestionnaireItemVariableExpression( + expression, + questionnaireItem, + variablesMap, + ) + } // Secondly, check the ancestors of the questionnaire item + ?: findVariableInAncestors(variableName, questionnaireItem)?.let { + (questionnaireItem, expression) -> + evaluateQuestionnaireItemVariableExpression( + expression, + questionnaireItem, + variablesMap, + ) + } // Finally, check the variables defined on the questionnaire itself + ?: questionnaire.findVariableExpression(variableName)?.let { expression -> + evaluateQuestionnaireVariableExpression( + expression, + variablesMap, + ) + } + + evaluatedValue?.also { variablesMap[variableName] = it } + } + + /** + * Finds the questionnaire item having specific variable name [String] in the ancestors of + * questionnaire item [Questionnaire.QuestionnaireItemComponent] + * + * @param variableName the [String] to match the variable in the ancestors + * @param questionnaireItem the [Questionnaire.QuestionnaireItemComponent] whose ancestors we + * visit + * @return [Pair] containing [Questionnaire.QuestionnaireItemComponent] and an [Expression] + */ + private fun findVariableInAncestors( + variableName: String, + questionnaireItem: QuestionnaireItemComponent, + ): Pair? { + var parent = questionnaireItemParentMap[questionnaireItem] + while (parent != null) { + val expression = parent.findVariableExpression(variableName) + if (expression != null) return Pair(parent, expression) + + parent = questionnaireItemParentMap[parent] + } + return null + } + + /** + * Evaluates the value of variable expression and returns its evaluated value + * + * @param expression the [Expression] the expression to evaluate + * @param dependentVariables the [Map] of variable names to their values + * @return [Base] the result of an expression + */ + private suspend fun evaluateVariable( + expression: Expression, + dependentVariables: Map = emptyMap(), + ) = + try { + require(expression.name?.isNotBlank() == true) { + "Expression name should be a valid expression name" + } + + if (expression.isXFhirQuery) { + checkNotNull(xFhirQueryResolver) { + "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig." + } + + val xFhirExpressionString = createXFhirQueryFromExpression(expression, dependentVariables) + + if (dependentVariables.contains(expression.name)) dependentVariables[expression.name]!! + + Bundle().apply { + entry = + xFhirQueryResolver.resolve(xFhirExpressionString).map { + BundleEntryComponent().apply { resource = it } + } + } + } else if (expression.isFhirPath) { + evaluateToBase( + questionnaireResponse = questionnaireResponse, + questionnaireResponseItem = null, + expression = expression.expression, + contextMap = dependentVariables, + ) + .firstOrNull() + } else { + throw UnsupportedOperationException( + "${expression.language} not supported for variable-expression yet", + ) + } + } catch (exception: FHIRException) { + println("Could not evaluate expression with FHIRPathEngine $exception") + null + } +} + +/** + * Extract [ResourceType] string representation from constant or name property of given + * [ExpressionNode]. + */ +private fun extractResourceType(expressionNode: ExpressionNode): String? { + // TODO(omarismail94): See if FHIRPathEngine.check() can be used to distinguish invalid + // expression vs an expression that is valid, but does not return one resource only. + return expressionNode.constant?.primitiveValue()?.substring(1) ?: expressionNode.name?.lowercase() +} + +/** Pair of a [Questionnaire.QuestionnaireItemComponent] with its evaluated answers */ +internal typealias ItemToAnswersPair = Pair> diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/FHIRPathEngineHostServices.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/FHIRPathEngineHostServices.kt new file mode 100644 index 0000000..ada5524 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/FHIRPathEngineHostServices.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.fhirpath + +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.TypeDetails +import org.hl7.fhir.r4.model.ValueSet +import org.hl7.fhir.r4.utils.FHIRPathEngine + +/** + * Resolves constants defined in the fhir path expressions beyond those defined in the specification + */ +internal object FHIRPathEngineHostServices : FHIRPathEngine.IEvaluationContext { + override fun resolveConstant( + appContext: Any?, + name: String?, + beforeContext: Boolean, + ): List? = + ((appContext as? Map<*, *>)?.get(name) as? Base)?.let { listOf(it) } ?: emptyList() + + override fun resolveConstantType(appContext: Any?, name: String?): TypeDetails { + throw UnsupportedOperationException() + } + + override fun log(argument: String?, focus: MutableList?): Boolean { + throw UnsupportedOperationException() + } + + override fun resolveFunction( + functionName: String?, + ): FHIRPathEngine.IEvaluationContext.FunctionDetails { + throw UnsupportedOperationException() + } + + override fun checkFunction( + appContext: Any?, + functionName: String?, + parameters: MutableList?, + ): TypeDetails { + throw UnsupportedOperationException() + } + + override fun executeFunction( + appContext: Any?, + focus: MutableList?, + functionName: String?, + parameters: MutableList>?, + ): MutableList { + throw UnsupportedOperationException() + } + + override fun resolveReference(appContext: Any?, url: String?, refContext: Base?): Base? { + throw UnsupportedOperationException() + } + + override fun conformsToProfile(appContext: Any?, item: Base?, url: String?): Boolean { + throw UnsupportedOperationException() + } + + override fun resolveValueSet(appContext: Any?, url: String?): ValueSet? { + throw UnsupportedOperationException() + } +} diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt new file mode 100644 index 0000000..be80f65 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2022-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.fhirpath + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.ExpressionNode +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.utils.FHIRPathEngine + +private val fhirPathEngine: FHIRPathEngine = + with(FhirContext.forCached(FhirVersionEnum.R4)) { + FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)).apply { + hostServices = FHIRPathEngineHostServices + } + } + +/** + * Evaluates the expressions over list of resources [Resource] and joins to space separated string + */ +internal fun evaluateToDisplay(expressions: List, data: Resource) = + expressions.joinToString(" ") { fhirPathEngine.evaluateToString(data, it) } + +/** Evaluates the expression over resource [Resource] and returns string value */ +internal fun evaluateToString( + expression: ExpressionNode, + data: Resource?, + contextMap: Map, +) = + fhirPathEngine.evaluateToString( + /* appInfo = */ contextMap, + /* focusResource = */ null, + /* rootResource = */ null, + /* base = */ data, + /* node = */ expression, + ) + +/** + * Evaluates the expression and returns the boolean result. The resources [QuestionnaireResponse] + * and [QuestionnaireResponseItemComponent] are passed as fhirPath supplements as defined in fhir + * specs https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + * + * %resource = [QuestionnaireResponse], %context = [QuestionnaireResponseItemComponent] + */ +internal fun evaluateToBoolean( + questionnaireResponse: QuestionnaireResponse, + questionnaireResponseItemComponent: QuestionnaireResponseItemComponent, + expression: String, + contextMap: Map = mapOf(), +): Boolean { + val expressionNode = fhirPathEngine.parse(expression) + return fhirPathEngine.evaluateToBoolean( + contextMap, + questionnaireResponse, + null, + questionnaireResponseItemComponent, + expressionNode, + ) +} + +/** + * Evaluates the expression and returns the list of [Base]. The resources [QuestionnaireResponse] + * and [QuestionnaireResponseItemComponent] are passed as fhirPath supplements as defined in fhir + * specs https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. All other + * constants are passed as contextMap + * + * %resource = [QuestionnaireResponse], %context = [QuestionnaireResponseItemComponent] + */ +internal fun evaluateToBase( + questionnaireResponse: QuestionnaireResponse?, + questionnaireResponseItem: QuestionnaireResponseItemComponent?, + expression: String, + contextMap: Map = mapOf(), +): List { + return fhirPathEngine.evaluate( + /* appContext = */ contextMap, + /* focusResource = */ questionnaireResponse, + /* rootResource = */ null, + /* base = */ questionnaireResponseItem, + /* path = */ expression, + ) +} + +/** Evaluates the given expression and returns list of [Base] */ +internal fun evaluateToBase(base: Base, expression: String): List { + return fhirPathEngine.evaluate( + /* base = */ base, + /* path = */ expression, + ) +} + +/** Evaluates the given list of [Base] elements and returns boolean result */ +internal fun convertToBoolean(items: List) = fhirPathEngine.convertToBoolean(items) + +/** Parse the given expression into [ExpressionNode] */ +internal fun extractExpressionNode(fhirPath: String) = fhirPathEngine.parse(fhirPath) diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/ProfileLoader.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/ProfileLoader.kt new file mode 100644 index 0000000..a30fc91 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/ProfileLoader.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2022-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.mapping + +import org.hl7.fhir.r4.model.CanonicalType +import org.hl7.fhir.r4.model.StructureDefinition + +/** Loads `StructureDefinition` for profile based on canonical URL. */ +interface ProfileLoader { + /** + * @param url the canonical URL for the [StructureDefinition] to be loaded. This may come from + * `resource.meta.profile` or as part of `questionnaire.item.definition` to inform extraction of + * values into fields defined in the profile. + * @return a [StructureDefinition] with the specified canonical `url` or `null` if it cannot be + * found + */ + fun loadProfile(url: CanonicalType): StructureDefinition? +} diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt new file mode 100644 index 0000000..0b4a9d7 --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -0,0 +1,810 @@ +/* + * Copyright 2022-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.mapping + +import com.google.android.fhir.datacapture.extensions.* +import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem +import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension +import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts +import com.google.android.fhir.datacapture.extensions.validateLaunchContextExtensions +import com.google.android.fhir.datacapture.extensions.zipByLinkId +import com.google.android.fhir.toCodeType +import com.google.android.fhir.toCoding +import com.google.android.fhir.toIdType +import com.google.android.fhir.toUriType +import com.google.android.fhir.datacapture.fhirpath.evaluateToBase +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.lang.reflect.ParameterizedType +import java.util.Locale +import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.context.IWorkerContext +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CanonicalType +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.DomainResource +import org.hl7.fhir.r4.model.Enumeration +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.IdType +import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.StructureDefinition +import org.hl7.fhir.r4.model.Type +import org.hl7.fhir.r4.model.UriType +import org.hl7.fhir.r4.utils.StructureMapUtilities + +/** + * Maps a [QuestionnaireResponse] to FHIR resources and vice versa. + * + * The process of converting [QuestionnaireResponse] s to other FHIR resources is called + * [extraction](http://build.fhir.org/ig/HL7/sdc/extraction.html). The reverse process of converting + * existing FHIR resources to [QuestionnaireResponse] s to be used to pre-fill the UI is called + * [population](http://build.fhir.org/ig/HL7/sdc/populate.html). + * + * This class supports + * [Definition-based extraction](http://build.fhir.org/ig/HL7/sdc/extraction.html#definition-based-extraction) + * , + * [StructureMap-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#structuremap-based-extraction) + * , and + * [expression-based population](http://build.fhir.org/ig/HL7/sdc/populate.html#expression-based-population) + * . + * + * See the [developer guide](https://github.com/google/android-fhir/wiki/SDCL%3A-Use-ResourceMapper) + * for more information. + */ +object ResourceMapper { + + /** + * Extract FHIR resources from a [questionnaire] and [questionnaireResponse]. + * + * This method will perform + * [StructureMap-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#structuremap-based-extraction) + * if the [Questionnaire] specified by [questionnaire] includes a `targetStructureMap` extension. + * In this case [structureMapExtractionContext] is required; extraction will fail and an empty + * [Bundle] is returned if [structureMapExtractionContext] is not provided. + * + * Otherwise, this method will perform + * [Definition-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#definition-based-extraction) + * . + * + * @param questionnaire A [Questionnaire] with data extraction extensions. + * @param questionnaireResponse A [QuestionnaireResponse] with answers for [questionnaire]. + * @param structureMapExtractionContext The [IWorkerContext] may be used along with + * [StructureMapUtilities] to parse the script and convert it into [StructureMap]. + * @return [Bundle] containing the extracted [Resource]s or empty Bundle if the extraction fails. + * An exception might also be thrown in a few cases + * @throws IllegalArgumentException when Resource getting extracted does conform different profile + * than standard FHIR profile and argument loadProfile callback Implementation is not provided + * to load different profile + */ + suspend fun extract( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + structureMapExtractionContext: StructureMapExtractionContext? = null, + profileLoader: ProfileLoader? = null, + ): Bundle { + return when { + questionnaire.targetStructureMap == null -> + extractByDefinition( + questionnaire, + questionnaireResponse, + object : ProfileLoader { + // Mutable map of key-canonical url as string for profile and + // value-StructureDefinition of resource claims to conforms to. + val structureDefinitionMap: MutableMap = hashMapOf() + + override fun loadProfile(url: CanonicalType): StructureDefinition? { + if (profileLoader == null) { + println( + "ProfileLoader implementation required to load StructureDefinition that this resource claims to conform to", + ) + return null + } + + structureDefinitionMap[url.toString()]?.also { + return it + } + + return profileLoader.loadProfile(url).also { + structureDefinitionMap[url.toString()] = it + } + } + }, + ) + structureMapExtractionContext != null -> { + extractByStructureMap(questionnaire, questionnaireResponse, structureMapExtractionContext) + } + else -> { + Bundle() + } + } + } + + /** + * Extracts FHIR resources from [questionnaireResponse] (as response to [questionnaire]) using + * Definition-based extraction. + * + * See http://build.fhir.org/ig/HL7/sdc/extraction.html#definition-based-extraction for more on + * Definition-based extraction. + */ + private fun extractByDefinition( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + profileLoader: ProfileLoader, + ): Bundle { + val rootResource: Resource? = questionnaire.createResource() + val extractedResources = mutableListOf() + + extractByDefinition( + questionnaire.item, + questionnaireResponse.item, + rootResource, + extractedResources, + profileLoader, + ) + + if (rootResource != null) { + extractedResources += rootResource + } + + return Bundle().apply { + type = Bundle.BundleType.TRANSACTION + entry = extractedResources.map { Bundle.BundleEntryComponent().apply { resource = it } } + } + } + + /** + * Extracts FHIR resources from [questionnaireResponse] (as response to [questionnaire]) using + * StructureMap-based extraction. + * + * @param structureMapProvider provides the referenced [StructureMap] either from persistence or a + * remote service. + * @return a [Bundle] including the extraction results, or `null` if [structureMapProvider] is + * missing. + * + * See http://build.fhir.org/ig/HL7/sdc/extraction.html#structuremap-based-extraction for more on + * StructureMap-based extraction. + */ + private suspend fun extractByStructureMap( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + structureMapExtractionContext: StructureMapExtractionContext, + ): Bundle { + val structureMapProvider = structureMapExtractionContext.structureMapProvider + val iWorkerContext = + structureMapExtractionContext.workerContext.apply { setExpansionProfile(Parameters()) } + val structureMap = structureMapProvider(questionnaire.targetStructureMap!!, iWorkerContext) + + return Bundle().apply { + StructureMapUtilities( + iWorkerContext, + structureMapExtractionContext.transformSupportServices, + ) + .transform(iWorkerContext, questionnaireResponse, structureMap, this) + } + } + + /** + * Performs + * [Expression-based population](http://build.fhir.org/ig/HL7/sdc/populate.html#expression-based-population) + * and returns a [QuestionnaireResponse] for the [questionnaire] that is populated from the + * [launchContexts]. + */ + suspend fun populate( + questionnaire: Questionnaire, + launchContexts: Map, + ): QuestionnaireResponse { + validateLaunchContextExtensions(questionnaire.questionnaireLaunchContexts ?: listOf()) + val filteredLaunchContexts = + filterByCodeInNameExtension( + launchContexts, + questionnaire.questionnaireLaunchContexts ?: listOf(), + ) + populateInitialValues(questionnaire.item, filteredLaunchContexts) + return QuestionnaireResponse().apply { + item = questionnaire.item.map { it.createQuestionnaireResponseItem() } + } + } + + private suspend fun populateInitialValues( + questionnaireItems: List, + launchContexts: Map, + ) { + questionnaireItems.forEach { populateInitialValue(it, launchContexts) } + } + + private suspend fun populateInitialValue( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + launchContexts: Map, + ) { + check(questionnaireItem.initial.isEmpty() || questionnaireItem.initialExpression == null) { + "QuestionnaireItem item is not allowed to have both initial.value and initial expression. See rule at http://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression." + } + + questionnaireItem.initialExpression + ?.let { + evaluateToBase( + questionnaireResponse = null, + questionnaireResponseItem = null, + expression = it.expression, + contextMap = launchContexts, + ) + .firstOrNull() + } + ?.let { + // Set initial value for the questionnaire item. Questionnaire items should not have both + // initial value and initial expression. + val value = it.asExpectedType(questionnaireItem.type) + questionnaireItem.initial = + mutableListOf(Questionnaire.QuestionnaireItemInitialComponent().setValue(value)) + } + + populateInitialValues(questionnaireItem.item, launchContexts) + } + + /** + * Updates corresponding fields in [extractionContext] with answers in + * [questionnaireResponseItemList]. The fields are defined in the definitions in + * [questionnaireItemList]. + * + * `extractionContext` can be a resource (e.g. Patient) or a complex value (e.g. HumanName). + * + * Handles nested questionnaire items recursively. New extraction contexts may be defined in the + * recursion. + */ + private fun extractByDefinition( + questionnaireItemList: List, + questionnaireResponseItemList: List, + extractionContext: Base?, + extractionResult: MutableList, + profileLoader: ProfileLoader, + ) { + questionnaireItemList.zipByLinkId(questionnaireResponseItemList) { + questionnaireItem, + questionnaireResponseItem, + -> + extractByDefinition( + questionnaireItem, + questionnaireResponseItem, + extractionContext, + extractionResult, + profileLoader, + ) + } + } + + /** + * Updates corresponding field in [extractionContext] with answer in [questionnaireResponseItem]. + * The field is defined in the definition in [questionnaireItem]. + * + * `extractionContext` can be a resource (e.g. Patient) or a complex value (e.g. HumanName). + * + * Handles nested questionnaire items recursively. New extraction contexts may be defined in the + * recursion. + */ + private fun extractByDefinition( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + extractionContext: Base?, + extractionResult: MutableList, + profileLoader: ProfileLoader, + ) { + when (questionnaireItem.type) { + Questionnaire.QuestionnaireItemType.GROUP -> + // A group in a questionnaire can do one of the following three things: + // 1) define a new resource (e.g. Patient) to extract using item extraction context + // extension + // 2) define a new complex value (e.g. HumanName) to extract using the `definition` field + // (see http://www.hl7.org/fhir/datatypes.html#complex) + // 3) simply group questions (e.g. for display reasons) without altering the extraction + // semantics + when { + questionnaireItem.extension.itemExtractionContextExtensionValue != null -> + // Extract a new resource for a new item extraction context + extractResourceByDefinition( + questionnaireItem, + questionnaireResponseItem, + extractionResult, + profileLoader, + ) + questionnaireItem.definition != null -> { + // Extract a new element (which is not a resource) e.g. HumanName, Quantity, etc + check(extractionContext != null) { + "No extraction context defined for ${questionnaireItem.definition}" + } + extractComplexTypeValueByDefinition( + questionnaireItem, + questionnaireResponseItem, + extractionContext, + extractionResult, + profileLoader, + ) + } + else -> + // Continue to traverse the descendants of the group item + extractByDefinition( + questionnaireItem.item, + questionnaireResponseItem.item, + extractionContext, + extractionResult, + profileLoader, + ) + } + else -> + if (questionnaireItem.definition != null) { + // Extract a new primitive value (see http://www.hl7.org/fhir/datatypes.html#primitive) + check(extractionContext != null) { + "No extraction context defined for ${questionnaireItem.definition}" + } + extractPrimitiveTypeValueByDefinition( + questionnaireItem, + questionnaireResponseItem, + extractionContext, + profileLoader, + ) + } + } + } + + /** + * Creates a new resource using the item extraction context extension on the [questionnaireItem], + * populates it with the answers nested in [questionnaireResponseItem], and append it to + * [extractionResult]. + */ + private fun extractResourceByDefinition( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + extractionResult: MutableList, + profileLoader: ProfileLoader, + ) { + val resource = questionnaireItem.createResource() as Resource + extractByDefinition( + questionnaireItem.item, + questionnaireResponseItem.item, + resource, + extractionResult, + profileLoader, + ) + extractionResult += resource + } + + /** + * Creates a complex type value using the definition in [questionnaireItem], populate it with + * answers nested in [questionnaireResponseItem], and update the corresponding field in [base] + * with it. + */ + private fun extractComplexTypeValueByDefinition( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + base: Base, + extractionResult: MutableList, + profileLoader: ProfileLoader, + ) { + val fieldName = getFieldNameByDefinition(questionnaireItem.definition) + val value = + try { + // Add a value in a repeated field if it can be done, e.g., by calling `Patient#addName` + base.addRepeatedFieldValue(fieldName) + } catch (e: NoSuchMethodException) { + // If the above attempt to add a value in a repeated field failed, try to get a value in the + // choice of data type field, e.g., by calling `Observation#getValueQuantity` + base.getChoiceFieldValue(fieldName) + } + extractByDefinition( + questionnaireItem.item, + questionnaireResponseItem.item, + value, + extractionResult, + profileLoader, + ) + } + + /** + * Updates the field in [base] defined by the definition in [questionnaireItem] with the primitive + * type value extracted from [questionnaireResponseItem]. + */ + private fun extractPrimitiveTypeValueByDefinition( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + base: Base, + profileLoader: ProfileLoader, + ) { + if (questionnaireResponseItem.answer.isEmpty()) return + + // Set the primitive type value if the field exists + val fieldName = getFieldNameByDefinition(questionnaireItem.definition) + base.javaClass.getFieldOrNull(fieldName)?.let { field -> + if (field.nonParameterizedType.isEnum) { + updateFieldWithEnum(base, field, questionnaireResponseItem.answer.first().value) + } else { + updateField(base, field, questionnaireResponseItem.answer) + } + return + } + + // If the primitive type value isn't a field, try to use the `setValue` method to set the + // answer, e.g., `Observation#setValue`. We set the answer component of the questionnaire + // response item directly as the value (e.g `StringType`). + try { + base.javaClass + .getMethod("setValue", Type::class.java) + .invoke(base, questionnaireResponseItem.answer.singleOrNull()?.value) + return + } catch (e: NoSuchMethodException) { + // Do nothing + } + + // Handle fields not present in base Java class but defined as extensions in profile e.g. + // "http://fhir.org/guides/who/anc-cds/StructureDefinition/anc-patient#Patient.address.address-preferred" + if (base.javaClass.getFieldOrNull(fieldName) == null) { + // base url from definition + val canonicalUrl = + questionnaireItem.definition.substring(0, questionnaireItem.definition.lastIndexOf("#")) + profileLoader.loadProfile(CanonicalType(canonicalUrl))?.let { + // Example "definition": + // "http://fhir.org/guides/who/anc-cds/StructureDefinition/anc-patient#Patient.address.address-preferred" + // extensionForType is "Patient.address" + val extensionForType = + questionnaireItem.definition.substring( + questionnaireItem.definition.lastIndexOf("#") + 1, + questionnaireItem.definition.lastIndexOf("."), + ) + if ( + isExtensionSupportedByProfile( + structureDefinition = it, + extensionForType = extensionForType, + fieldName = fieldName, + ) + ) { + addDefinitionBasedCustomExtension(questionnaireItem, questionnaireResponseItem, base) + return + } else { + println( + "Extension for field '$fieldName' is not defined in StructureDefinition of ${base.fhirType()}, so field is ignored", + ) + } + } + } + } +} + +private fun isExtensionSupportedByProfile( + structureDefinition: StructureDefinition, + extensionForType: String, + fieldName: String, +): Boolean { + // Partial ElementDefinition from StructureDefinition to check extension is + // "id": "Patient.address.extension:address-preferred", + // "path": "Patient.address.extension", + val listOfElementDefinition = + structureDefinition.snapshot.element.filter { it.path.equals("$extensionForType.extension") } + listOfElementDefinition.forEach { + if (it.id.substringAfterLast(":").equals(fieldName)) { + return true + } + } + return false +} + +/** + * Adds custom extension for Resource. + * + * @param questionnaireItem QuestionnaireItemComponent with details for extension + * @param questionnaireResponseItem QuestionnaireResponseItemComponent for response value + * @param base + * - resource's Base class instance See + * https://hapifhir.io/hapi-fhir/docs/model/profiles_and_extensions.html#extensions for more on + * custom extensions + */ +private fun addDefinitionBasedCustomExtension( + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + base: Base, +) { + if (base is Type) { + // Create an extension + val ext = Extension() + ext.url = questionnaireItem.definition + ext.setValue(questionnaireResponseItem.answer.first().value) + // Add the extension to the resource + base.addExtension(ext) + } + if (base is DomainResource) { + // Create an extension + val ext = Extension() + ext.url = questionnaireItem.definition + ext.setValue(questionnaireResponseItem.answer.first().value) + // Add the extension to the resource + base.addExtension(ext) + } +} + +/** + * Retrieves details about the target field defined in + * [org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent.definition] for easier searching + * of the setter methods and converting the answer to the expected parameter type. + */ +private fun getFieldNameByDefinition(definition: String): String { + val last = definition.substringAfterLast(".") + check(last.isNotEmpty()) { "Invalid field definition: $definition" } + return last +} + +/** + * Invokes the function to add a new value in the repeated field in the `Base` object, e.g., + * `addName` function for `name`. + */ +private fun Base.addRepeatedFieldValue(fieldName: String) = + javaClass.getMethod("add${fieldName.capitalize()}").invoke(this) as Base + +/** + * Invokes the function to get a value in the choice of data type field in the `Base` object, e.g., + * `getValueQuantity` for `valueQuantity`. + */ +private fun Base.getChoiceFieldValue(fieldName: String) = + javaClass.getMethod("get${fieldName.capitalize()}").invoke(this) as Base + +/** + * Updates a field of name [field.name] on this object with the generated enum from [value] using + * the declared setter. [field] helps to determine the field class type. The enum is generated by + * calling fromCode method on the enum class + */ +private fun updateFieldWithEnum(base: Base, field: Field, value: Base) { + /** + * We have a org.hl7.fhir.r4.model.Enumerations class which contains inner classes of code-names + * and re-implements the classes in the org.hl7.fhir.r4.model.codesystems package The + * inner-classes in the Enumerations package are valid and not dependent on the classes in the + * codesystems package All enum classes in the org.hl7.fhir.r4.model package implement the + * fromCode(), toCode() methods among others + */ + val dataTypeClass: Class<*> = field.nonParameterizedType + val fromCodeMethod: Method = dataTypeClass.getDeclaredMethod("fromCode", String::class.java) + + val stringValue = if (value is Coding) value.code else value.toString() + + base.javaClass + .getMethod("set${field.name.capitalize()}", field.nonParameterizedType) + .invoke(base, fromCodeMethod.invoke(dataTypeClass, stringValue)) +} + +/** + * The api's used to updateField with answers are: + * * For Parameterized list of primitive type e.g HumanName.given of type List + * + * ``` + * addGiven(String) - adds a new StringType to the list. + * ``` + * * For any primitive value e.g for Patient.active which is of BooleanType + * + * ``` + * setActiveElement(BooleanType) + * ``` + * * In case they fail, + * ``` + * setName(List) - replaces old list if any with the new list. + * ``` + */ +private fun updateField( + base: Base, + field: Field, + answers: List, +) { + val answersOfFieldType = + answers.map { wrapAnswerInFieldType(it.value, field) }.toCollection(mutableListOf()) + + try { + if (field.isParameterized && field.isList) { + addAnswerToListField(base, field, answersOfFieldType) + } else { + setFieldElementValue(base, field, answersOfFieldType.first()) + } + } catch (e: NoSuchMethodException) { + // some set methods expect a list of objects + updateListFieldWithAnswer(base, field, answersOfFieldType) + } +} + +private fun setFieldElementValue(base: Base, field: Field, answerValue: Base) { + base.javaClass + .getMethod("set${field.name.capitalize()}Element", field.type) + .invoke(base, answerValue) +} + +private fun addAnswerToListField(base: Base, field: Field, answerValue: List) { + base.javaClass + .getMethod( + "add${field.name.replaceFirstChar(Char::uppercase)}", + answerValue.first().fhirType().replaceFirstChar(Char::uppercase).javaClass, + ) + .let { method -> answerValue.forEach { method.invoke(base, it.primitiveValue()) } } +} + +private fun updateListFieldWithAnswer(base: Base, field: Field, answerValue: List) { + base.javaClass + .getMethod("set${field.name.capitalize()}", field.type) + .invoke(base, if (field.isParameterized && field.isList) answerValue else answerValue.first()) +} + +private fun String.capitalize() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() +} + +/** + * This method enables us to perform an extra step to wrap the answer using the correct type. This + * is useful in cases where a single question maps to a CompositeType such as [CodeableConcept] or + * enum. Normally, composite types are mapped using group questions which provide direct alignment + * to the type elements in the group questions. + */ +private fun wrapAnswerInFieldType(answer: Base, fieldType: Field): Base { + when (fieldType.nonParameterizedType) { + CodeableConcept::class.java -> { + if (answer is Coding) { + return CodeableConcept(answer).apply { text = answer.display } + } + } + IdType::class.java -> { + if (answer is StringType) { + return answer.toIdType() + } + } + CodeType::class.java -> { + if (answer is Coding) { + return answer.toCodeType() + } else if (answer is StringType) { + return answer.toCodeType() + } + } + UriType::class.java -> { + if (answer is StringType) { + return answer.toUriType() + } + } + DecimalType::class.java -> { + if (answer is IntegerType) { + return DecimalType(answer.asStringValue()) + } + } + } + return answer +} + +private val Field.isList: Boolean + get() = isParameterized && type == List::class.java + +private val Field.isParameterized: Boolean + get() = genericType is ParameterizedType + +/** The non-parameterized type of this field (e.g. `String` for a field of type `List`). */ +private val Field.nonParameterizedType: Class<*> + get() = + if (isParameterized) { + (genericType as ParameterizedType).actualTypeArguments[0] as Class<*> + } else { + type + } + +private fun Class<*>.getFieldOrNull(name: String): Field? { + return try { + getDeclaredField(name) + } catch (ex: NoSuchFieldException) { + superclass?.getFieldOrNull(name) + } +} + +/** + * Returns a newly created [Resource] from the item extraction context extension if one and only one + * such extension exists in the questionnaire, or null otherwise. + */ +private fun Questionnaire.createResource(): Resource? = + this.extension.itemExtractionContextExtensionValue?.let { + Class.forName("org.hl7.fhir.r4.model.$it").newInstance() as Resource + } + +/** + * Returns the [Base] object as a [Type] as expected by + * [Questionnaire.QuestionnaireItemAnswerOptionComponent.setValue]. Also, + * [Questionnaire.QuestionnaireItemAnswerOptionComponent.setValue] only takes a certain [Type] + * objects and throws exception otherwise. This extension function takes care of the conversion + * based on the input and expected [Type]. + */ +fun Base.asExpectedType( + questionnaireItemType: Questionnaire.QuestionnaireItemType? = null, +): Type { + return when { + questionnaireItemType == Questionnaire.QuestionnaireItemType.REFERENCE -> + asExpectedReferenceType() + this is Enumeration<*> -> toCoding() + this is IdType -> StringType(idPart) + else -> this as Type + } +} + +private fun Base.asExpectedReferenceType(): Type { + return when { + this.isResource -> { + this@asExpectedReferenceType as Resource + Reference().apply { + reference = + if (this@asExpectedReferenceType.resourceType != null) { + "${this@asExpectedReferenceType.resourceType}/${this@asExpectedReferenceType.logicalId}" + } else { + this@asExpectedReferenceType.logicalId + } + } + } + this is IdType -> + Reference().apply { + reference = + if (this@asExpectedReferenceType.resourceType != null) { + "${this@asExpectedReferenceType.resourceType}/${this@asExpectedReferenceType.idPart}" + } else { + this@asExpectedReferenceType.idPart + } + } + else -> throw FHIRException("Expression supplied does not evaluate to IdType.") + } +} + +/** + * Returns a newly created [Resource] from the item extraction context extension if one and only one + * such extension exists in the questionnaire item, or null otherwise. + */ +private fun Questionnaire.QuestionnaireItemComponent.createResource(): Resource? = + this.extension.itemExtractionContextExtensionValue?.let { + Class.forName("org.hl7.fhir.r4.model.$it").newInstance() as Resource + } + +/** + * The item extraction context extension value of type Expression or CodeType if one and only one + * such extension exists or null otherwise. If there are multiple extensions exists, it will be + * ignored. See + * http://hl7.org/fhir/uv/sdc/STU3/StructureDefinition-sdc-questionnaire-itemExtractionContext.html + */ +private val List.itemExtractionContextExtensionValue + get() = + this.singleOrNull { it.url == ITEM_CONTEXT_EXTENSION_URL } + ?.let { + when (it.value) { + is Expression -> { + // TODO update the existing resource + val expression = it.value as Expression + expression.expression + } + is CodeType -> { + val code = it.value as CodeType + code.value + } + else -> null + } + } + +/** + * URL for the + * [item extraction context extension](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-itemExtractionContext.html) + */ +private const val ITEM_CONTEXT_EXTENSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemExtractionContext" \ No newline at end of file diff --git a/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/StructureMapExtractionContext.kt b/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/StructureMapExtractionContext.kt new file mode 100644 index 0000000..4c061dd --- /dev/null +++ b/core/src/main/kotlin/com/google/android/fhir/datacapture/mapping/StructureMapExtractionContext.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2022-2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.mapping + +import org.hl7.fhir.r4.context.IWorkerContext +import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.model.StructureMap +import org.hl7.fhir.r4.utils.StructureMapUtilities + +/** Data used during StructureMap-based extraction. */ +data class StructureMapExtractionContext( + /** + * Optionally pass a custom version of [StructureMapUtilities.ITransformerServices] to support + * specific use cases. + */ + val transformSupportServices: StructureMapUtilities.ITransformerServices? = null, + /** + * Optionally pass a custom version of [IWorkerContext]. + * + * @default [SimpleWorkerContext] + */ + val workerContext: IWorkerContext = SimpleWorkerContext(), + /** + * A lambda function which returns a [StructureMap]. Depending on your app this could be + * hard-coded or use the [String] parameter to fetch the appropriate structure map. + * + * @param String The canonical URL for the Structure Map referenced in the + * [Target structure map extension](http://hl7.org/fhir/uv/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html) + * of the questionnaire. + * @param IWorkerContext May be used with other HAPI FHIR classes, like using + * [StructureMapUtilities.parse] to parse content in the FHIR Mapping Language. + */ + val structureMapProvider: (suspend (String, IWorkerContext) -> StructureMap?), +) diff --git a/core/src/main/kotlin/org/dtree/fhir/core/di/DXFhirQueryResolver.kt b/core/src/main/kotlin/org/dtree/fhir/core/di/DXFhirQueryResolver.kt new file mode 100644 index 0000000..2eab7d0 --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/di/DXFhirQueryResolver.kt @@ -0,0 +1,11 @@ +package org.dtree.fhir.core.di + +import com.google.android.fhir.datacapture.XFhirQueryResolver +import org.hl7.fhir.r4.model.Resource + +class DXFhirQueryResolver() : XFhirQueryResolver { + override suspend fun resolve(xFhirQuery: String): List { + // TODO: Implement resolution logic + return emptyList() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/di/FhirProvider.kt b/core/src/main/kotlin/org/dtree/fhir/core/di/FhirProvider.kt index bbc3113..07543a1 100644 --- a/core/src/main/kotlin/org/dtree/fhir/core/di/FhirProvider.kt +++ b/core/src/main/kotlin/org/dtree/fhir/core/di/FhirProvider.kt @@ -4,9 +4,15 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import org.dtree.fhir.core.fhir.FhirConfigs +import org.dtree.fhir.core.utilities.TransformSupportServices import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.utils.StructureMapUtilities class FhirProvider { - fun parser(): IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() - fun context(): SimpleWorkerContext = FhirConfigs.createWorkerContext() + fun scu(): StructureMapUtilities { + return StructureMapUtilities(context, TransformSupportServices(context)) + } + + val parser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + val context = FhirConfigs.createWorkerContext() } \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/fhir/PatchMaker.kt b/core/src/main/kotlin/org/dtree/fhir/core/fhir/PatchMaker.kt new file mode 100644 index 0000000..65ccf6d --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/fhir/PatchMaker.kt @@ -0,0 +1,119 @@ +package org.dtree.fhir.core.fhir + +import ca.uhn.fhir.parser.IParser +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.fge.jsonpatch.diff.JsonDiff +import com.google.android.fhir.LocalChange +import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.sync.upload.patch.PatchOrdering.sccOrderByReferences +import org.dtree.fhir.core.utils.logicalId +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent +import org.hl7.fhir.r4.model.Resource +import org.json.JSONArray +import java.time.Instant +import java.util.UUID +import kotlin.random.Random + +object PatchMaker { + private val resourceHelper = FhirResourceHelper() + fun createPatchedRequest( + iParser: IParser, + resourceMap: Map, + resources: List + ): List { + val changes = resources.mapNotNull { resource -> + val resourceID = resource.logicalId + var oldResource = resourceMap[resourceID] + if (oldResource == null) { + oldResource = resourceMap[resourceID.replace("#", "")] + } + if (oldResource == null) return@mapNotNull LocalChange( + resourceType = resource.fhirType(), + resourceId = resourceID, + versionId = resource.meta.versionId, + timestamp = Instant.now(), + type = LocalChange.Type.INSERT, + payload = iParser.encodeResourceToString(resource.apply { + id = logicalId + }), + token = LocalChangeToken(listOf(Random.nextLong())), + ) + + val jsonDiff = patch(iParser, oldResource.apply { + id = logicalId + }, resource.apply { + id = logicalId + }) + return@mapNotNull if (jsonDiff == null) { + null + } else { + LocalChange( + resourceType = resource.fhirType(), + resourceId = resourceID, + versionId = resource.meta.versionId, + timestamp = Instant.now(), + type = LocalChange.Type.UPDATE, + payload = jsonDiff.second, + token = LocalChangeToken(listOf(Random.nextLong())), + isPatch = jsonDiff.first + ) + } + } + val refs = changes.flatMap { + val resource: Resource = if (it.isPatch) { + resourceMap[it.resourceId]!! + } else { + iParser.parseResource(it.payload) as Resource + } + resourceHelper.extractLocalChangeWithRefs(it, resource) + } + val ordered = refs.sccOrderByReferences() + val newLocalChanges = ordered.flatMap { it.patchMappings.map { it.localChange } } + return newLocalChanges.map { it.createPatchRequest(iParser) } + } + + private fun patch(parser: IParser, source: Resource, target: Resource): Pair? { + val objectMapper = ObjectMapper() + val sourceStr = objectMapper.readValue(parser.encodeResourceToString(source), JsonNode::class.java) + val targetStr = objectMapper.readValue(parser.encodeResourceToString(target), JsonNode::class.java) + + val diff = JsonDiff.asJson( + sourceStr, + targetStr, + ) + + val response = getFilteredJSONArray(diff) + val jsonDiff = response.first + if (jsonDiff.length() == 0) { + println("New resource same as last one") + return null + } + + return Pair(true, jsonDiff.toString()) + } + + private fun getFilteredJSONArray(jsonDiff: JsonNode): Pair { + var hasMeta = false + val ignorePaths = listOf("/meta", "/text") + val array = with(JSONArray(jsonDiff.toString())) { + return@with JSONArray( + (0.. + val isRemove = jsonObject.optString("op") + .equals("remove") + if (jsonObject.optString("path").startsWith("/meta") && !isRemove) { + hasMeta = true + } + var paths = ignorePaths + if (hasMeta && !isRemove) { + paths = listOf("/text") + } + paths.any { jsonObject.optString("path").startsWith(it) } || isRemove + }, + ) + } + return Pair(array, hasMeta) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/models/PatientData.kt b/core/src/main/kotlin/org/dtree/fhir/core/models/PatientData.kt new file mode 100644 index 0000000..ae3082b --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/models/PatientData.kt @@ -0,0 +1,168 @@ +package org.dtree.fhir.core.models + +import org.dtree.fhir.core.utils.TracingHelpers +import org.dtree.fhir.core.utils.extractId +import org.dtree.fhir.core.utils.isHomeTracingTask +import org.dtree.fhir.core.utils.logicalId +import org.hl7.fhir.r4.model.* + +enum class TracingType { + home, phone, none +} + +data class PatientData( + var patient: Patient = Patient(), + val guardians: MutableList = mutableListOf(), + val linkedPatients: MutableList = mutableListOf(), + val observations: MutableList = mutableListOf(), + val practitioners: MutableList = mutableListOf(), + val carePlans: MutableList = mutableListOf(), + val oldCarePlans: MutableList = mutableListOf(), + val tasks: MutableList = mutableListOf(), + val conditions: MutableList = mutableListOf(), + val appointments: MutableList = mutableListOf(), + val lists: MutableList = mutableListOf(), + val tracingTasks: MutableList = mutableListOf(), + val currentCarePlan: CarePlan? = null, +) { + fun isEmpty(): Boolean { + return !patient.hasId() && + guardians.isEmpty() && + observations.isEmpty() && + practitioners.isEmpty() && + carePlans.isEmpty() && + tasks.isEmpty() && + conditions.isEmpty() && + appointments.isEmpty() && + lists.isEmpty() + } + + fun toBundle(): Bundle { + val bundle = Bundle() + val entries = + (listOf(patient) + guardians + linkedPatients + observations + practitioners + carePlans + oldCarePlans + tasks + conditions + appointments + lists + tracingTasks) + + entries.forEach { resourceToAdd -> + bundle.entry.add( + Bundle.BundleEntryComponent().apply { + resource = resourceToAdd + } + ) + } + + return bundle + } + + fun toPopulationResource(): List { + val currentCarePlan = carePlans.firstOrNull() + val lastCarePlan = oldCarePlans.firstOrNull() + + val resources = conditions + guardians + observations + val resourcesAsBundle = Bundle().apply { resources.map { this.addEntry().resource = it } } + + val tracingBundle = Bundle() + tracingBundle.id = TracingHelpers.tracingBundleId + + // TODO: filter tracing ones + tracingTasks.forEach { tracingBundle.addEntry(Bundle.BundleEntryComponent().setResource(it)) } + lists.forEach { tracingBundle.addEntry(Bundle.BundleEntryComponent().setResource(it)) } + + if (lastCarePlan != null) { + tracingBundle.addEntry(Bundle.BundleEntryComponent().setResource(lastCarePlan)) + } + + resourcesAsBundle.addEntry( + Bundle.BundleEntryComponent().setResource(tracingBundle).apply { + id = TracingHelpers.tracingBundleId + }, + ) + appointments.forEach { resourcesAsBundle.addEntry(Bundle.BundleEntryComponent().setResource(it)) } + + val list = arrayListOf(*practitioners.toTypedArray(), resourcesAsBundle, patient) + if (currentCarePlan != null) { + list.add(currentCarePlan) + } + + return list + } + + fun getTracingType(): TracingType { + val task = tracingTasks.firstOrNull() ?: return TracingType.none + return if (task.isHomeTracingTask()) TracingType.home else TracingType.phone + } + + fun toLaunchContextMap(): Map? { + return null + } + + fun getAllItemMap(): Map { + val items = + listOf(patient) + guardians + linkedPatients + observations + practitioners + carePlans + tasks + conditions + appointments + lists + tracingTasks + return items.associateBy { it.logicalId } + } +} + +fun Bundle.parsePatientResources(patientId: String): Pair, PatientData> { + val patientData = PatientData() + val tasks = mutableMapOf() + val tracingTasks = mutableListOf() + + this.entry?.forEach { entry -> + // For batch responses, we need to handle the response bundle + val resources = when { + entry.resource is Bundle -> (entry.resource as Bundle).entry?.map { it.resource } ?: emptyList() + else -> listOf(entry.resource) + } + + resources.forEach { resource -> + when (resource) { + is Patient -> { + if (resource.logicalId != patientId) { + patientData.linkedPatients.add(resource) + } else { + patientData.patient = resource + } + } + + is RelatedPerson -> patientData.guardians.add(resource) + is Observation -> patientData.observations.add(resource) + is Practitioner -> patientData.practitioners.add(resource) + is CarePlan -> patientData.carePlans.add(resource) + is Task -> { + if (resource.code.codingFirstRep.code == "225368008") { + tracingTasks.add(resource) + } else { + tasks[resource.logicalId] = resource + } + } + + is Condition -> patientData.conditions.add(resource) + is Appointment -> patientData.appointments.add(resource) + is ListResource -> patientData.lists.add(resource) + } + } + } + + val allCarePlans = patientData.carePlans.sortedByDescending { it.period.start } + val activeCarePlans = + allCarePlans.filter { it.status == CarePlan.CarePlanStatus.ACTIVE || it.status == CarePlan.CarePlanStatus.ONHOLD } + .toMutableList() + val currentCarePlan = activeCarePlans.firstOrNull() + + var taskIds = listOf() + if (currentCarePlan != null) { + taskIds = currentCarePlan.activity.mapNotNull { + val id = it.outcomeReference.firstOrNull()?.extractId() + if (!tasks.contains(id)) id else null + } + } + + return Pair(taskIds, patientData.copy( + tasks = tasks.values.toMutableList(), + tracingTasks = tracingTasks, + carePlans = activeCarePlans, + currentCarePlan = currentCarePlan, + oldCarePlans = allCarePlans.filter { it.status != CarePlan.CarePlanStatus.ACTIVE && it.status != CarePlan.CarePlanStatus.ONHOLD } + .toMutableList(), + )) +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/uploader/general/DataResponseState.kt b/core/src/main/kotlin/org/dtree/fhir/core/uploader/general/DataResponseState.kt new file mode 100644 index 0000000..0d0a93d --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/uploader/general/DataResponseState.kt @@ -0,0 +1,7 @@ +package org.dtree.fhir.core.uploader.general + +sealed class DataResponseState { + data class Success(val data: T) : DataResponseState() + + data class Error(val exception: Exception) : DataResponseState() +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/uploader/general/FhirClient.kt b/core/src/main/kotlin/org/dtree/fhir/core/uploader/general/FhirClient.kt index 80a3085..4635c87 100644 --- a/core/src/main/kotlin/org/dtree/fhir/core/uploader/general/FhirClient.kt +++ b/core/src/main/kotlin/org/dtree/fhir/core/uploader/general/FhirClient.kt @@ -6,28 +6,26 @@ import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.client.api.IGenericClient import ca.uhn.fhir.rest.gclient.IQuery import ca.uhn.fhir.util.BundleUtil -import org.dtree.fhir.core.utils.Logger -import org.dtree.fhir.core.utils.logicalId import io.github.cdimascio.dotenv.Dotenv import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor +import org.apache.commons.net.ntp.TimeStamp +import org.dtree.fhir.core.models.PatientData +import org.dtree.fhir.core.models.parsePatientResources +import org.dtree.fhir.core.utils.Logger +import org.dtree.fhir.core.utils.createFile +import org.dtree.fhir.core.utils.logicalId import org.hl7.fhir.instance.model.api.IBaseBundle import org.hl7.fhir.instance.model.api.IBaseResource -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.Resource -import java.net.URL +import org.hl7.fhir.r4.model.* import java.util.concurrent.TimeUnit - +import kotlin.io.path.Path class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { - val client: IGenericClient + val fhirClient: IGenericClient private val okHttpClient: OkHttpClient val ctx: FhirContext = FhirContext.forR4() @@ -37,7 +35,7 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { factory.fhirContext = ctx factory.setHttpClient(okHttpClient) ctx.restfulClientFactory = factory - client = ctx.newRestfulGenericClient(dotenv["FHIR_BASE_URL"]) + fhirClient = ctx.newRestfulGenericClient(dotenv["FHIR_BASE_URL"]) } private fun createOkHttpClient(): OkHttpClient { @@ -60,7 +58,7 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { ): List { val resources: MutableList = mutableListOf() val query = - client.search().forResource(T::class.java).apply(search).returnBundle(Bundle::class.java) + fhirClient.search().forResource(T::class.java).apply(search).returnBundle(Bundle::class.java) .count(count) if (limit != null) { query.count(limit) @@ -71,14 +69,14 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { if (limit == null) { while (bundle.getLink(IBaseBundle.LINK_NEXT) != null) { Logger.info(bundle.link.map { it.url }.toString()) - bundle = client.loadPage().next(bundle).execute() + bundle = fhirClient.loadPage().next(bundle).execute() resources.addAll(BundleUtil.toListOfResources(ctx, bundle)) } } return resources.toList() as List } - suspend inline fun transaction(requests: List): List { + suspend inline fun transaction(requests: List): List { val bundle = Bundle() bundle.setType(Bundle.BundleType.TRANSACTION) bundle.entry.addAll(requests.map { rq -> @@ -86,17 +84,17 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { request = rq } }) - var resBundle = client.transaction().withBundle(bundle).execute() + val resBundle = fhirClient.transaction().withBundle(bundle).execute() val resources: MutableList = mutableListOf() resources.addAll(BundleUtil.toListOfResources(ctx, resBundle)) return resources.toList() as List } - fun fetchBundle(list: List): Bundle { + fun fetchBundle(list: List): Bundle { val bundle = Bundle() - bundle.setType(Bundle.BundleType.BATCH) + bundle.setType(Bundle.BundleType.BATCH) bundle.entry.addAll( - list.map {rq -> + list.map { rq -> Bundle.BundleEntryComponent().apply { request = rq fullUrl = rq.url @@ -104,7 +102,113 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { } } ) - return client.transaction().withBundle(bundle).execute() + return fhirClient.transaction().withBundle(bundle).execute() + } + + fun fetchResourcesFromList(ids: List): Bundle { + Appointment.AppointmentStatus.WAITLIST + val item = Bundle.BundleEntryRequestComponent().apply { + method = Bundle.HTTPVerb.GET + url = "Appointment?patient=${ids.joinToString(",")}&status=${ + listOf( + "waitlist", + "booked", + "noshow" + ).joinToString(",") + }&_count=20000" + id = "filter" + } + val bundle = fetchBundle(listOf(item)) + return bundle.entry.first().resource as Bundle + } + + fun fetchResourcesFromList(type: ResourceType, ids: List): List { + val item = Bundle.BundleEntryRequestComponent().apply { + method = Bundle.HTTPVerb.GET + url = "${type.name}?_id=${ids.joinToString(",")}" + id = "filter" + } + val items = mutableListOf() + for (entry in fetchBundle(listOf(item)).entry) { + val resources = when { + entry.resource is Bundle -> (entry.resource as Bundle).entry?.map { it.resource } ?: emptyList() + else -> listOf(entry.resource) + } + items.addAll(resources) + } + return items.toList() as List + } + + /** + * Given a patient id fetch the following active items + * - Patient + * - Guardians (Patient/RelatedPerson) + * - Observations + * - Practitioner + * - Current CarePlan + * - Tasks + * - Conditions + * - Observations + * - Appointment + * - List + */ + fun fetchAllPatientsActiveItems(patientId: String): PatientData { + val bundle = Bundle() + bundle.type = Bundle.BundleType.BATCH + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("Patient?_id=$patientId&_include=Patient:link&_include=Patient:general-practitioner") + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("Observation?subject=$patientId&status=preliminary") + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("CarePlan?subject=$patientId&_count=1&status=completed&_sort=-_lastUpdated") + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("CarePlan?subject=$patientId&status=active,on-hold&_revinclude=Task:based-on") + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("Task?patient=$patientId&status=ready,in-progress,on-hold&_count=100000") + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("Condition?subject=$patientId&clinical-status=active") + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("Appointment?actor=$patientId&status=waitlist,booked,noshow") + + bundle.addEntry() + .request + .setMethod(Bundle.HTTPVerb.GET) + .setUrl("List?subject=$patientId&status=current") + + println(iParser.encodeResourceToString(bundle)) + val data = fhirClient.transaction() + .withBundle(bundle) + .prettyPrint() + .execute().parsePatientResources(patientId) + val patientData = data.second + + if (data.first.isNotEmpty()) { + val tasks = fetchResourcesFromList(ResourceType.Task, data.first) + patientData.tasks.addAll(tasks) + } + + return patientData } suspend fun bundleUpload( @@ -113,7 +217,7 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { ) { val totalBatches = if (list.size % batchSize == 0) list.size / batchSize else list.size / batchSize + 1 - for (batchIndex in 0 until totalBatches) { + for (batchIndex in 0.., batchSize: Int ) { - bundleUpload(list.map { res -> Bundle.BundleEntryComponent().apply { - resource = res - request = Bundle.BundleEntryRequestComponent().apply { - method = Bundle.HTTPVerb.PUT - url = "${res.resourceType.name}/${res.logicalId}" + bundleUpload(list.map { res -> + Bundle.BundleEntryComponent().apply { + resource = res.apply { + id = logicalId + } + request = Bundle.BundleEntryRequestComponent().apply { + if (res.hasId()) { + method = Bundle.HTTPVerb.PUT + url = "${res.resourceType.name}/${res.logicalId}" + } else { + method = Bundle.HTTPVerb.POST + url = res.resourceType.name + } + } } - } }, batchSize) + }, batchSize) } private suspend fun uploadBatchUpload(list: List): DataResponseState { @@ -147,27 +261,19 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { entry = list type = Bundle.BundleType.TRANSACTION } - val mediaType = "application/json".toMediaTypeOrNull() - val requestBody = iParser.encodeResourceToString(bundle).toRequestBody(mediaType) -// println(iParser.encodeResourceToString(bundle)) - val request = Request.Builder() - .url(dotenv["FHIR_BASE_URL"]) - .post(requestBody) - .build() - val call = okHttpClient.newCall(request) + return withContext(Dispatchers.IO) { try { - val response = call.execute() - if (!response.isSuccessful) { - Logger.error("Failed to upload batch: ${response.code} - ${response.message}") - return@withContext DataResponseState.Error(exceptionFromResponse(response)) - } else { - Logger.info("Uploaded successfully") - } - response.close() + val response = fhirClient.transaction().withBundle(bundle).execute() +// if (BundleUtil.getBundleType()) { +// Logger.error("Failed to upload batch: ${response.code} - ${response.message}") +// return@withContext DataResponseState.Error(exceptionFromResponse(response)) +// } + Logger.info("Uploaded successfully") DataResponseState.Success(true) } catch (e: Exception) { Logger.error("Failed to upload batch: ${e.message}") + Logger.info(iParser.encodeResourceToString(bundle)) DataResponseState.Error(e) } } @@ -176,10 +282,33 @@ class FhirClient(private val dotenv: Dotenv, private val iParser: IParser) { private fun exceptionFromResponse(response: Response): Exception { return Exception("Status: ${response.code},message: ${response.message}, body: ${response.body?.string()} ") } + + private fun saveRemainingData(subList: List) { + dotenv["REPORT_DIR"]?.apply { + val time = TimeStamp.getCurrentTime() + val bundle = Bundle() + subList.forEach { + bundle.entry.add(it) + } + iParser.encodeResourceToString(bundle) + .createFile(Path(this).resolve("batch-upload-${time.time}.json").toString()) + } + + } } -sealed class DataResponseState { - data class Success(val data: T) : DataResponseState() +fun IQuery.paginateExecute(client: FhirClient): List { + val list = mutableListOf() + + var bundle = this.execute() + list.addAll(BundleUtil.toListOfResources(client.ctx, bundle)) + + while (bundle.getLink(IBaseBundle.LINK_NEXT) != null) { + bundle = client.fhirClient.loadPage().next(bundle).execute(); + list.addAll(BundleUtil.toListOfResources(client.ctx, bundle)); + } + + return list.toList() as List +} - data class Error(val exception: Exception) : DataResponseState() -} \ No newline at end of file +class FailedToUploadException(message: String) : Exception(message) {} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/utilities/ReasonConstants.kt b/core/src/main/kotlin/org/dtree/fhir/core/utilities/ReasonConstants.kt new file mode 100644 index 0000000..5e4b696 --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/utilities/ReasonConstants.kt @@ -0,0 +1,34 @@ +package org.dtree.fhir.core.utilities + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding + +object ReasonConstants { + // TODO: change code to "welcome-service" + val WelcomeServiceCode = + CodeableConcept(Coding(SystemConstants.REASON_CODE_SYSTEM, "Welcome", "Welcome Service")) + .apply { text = "Welcome Service" } + + val homeTracingCoding = + Coding(SystemConstants.CONTACT_TRACING_SYSTEM, "home-tracing", "Home Tracing") + val phoneTracingCoding = + Coding(SystemConstants.CONTACT_TRACING_SYSTEM, "phone-tracing", "Phone Tracing") + + var missedAppointmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "missed-appointment", "Missed Appointment") + var missedMilestoneAppointmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "missed-milestone", "Missed Milestone Appointment") + var missedRoutineAppointmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "missed-routine", "Missed Routine Appointment") + var interruptedTreatmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "interrupted-treatment", "Interrupted Treatment") + + var pendingTransferOutCode = + Coding("https://d-tree.org/fhir/transfer-out-status", "pending", "Pending") + + const val TRACING_OUTCOME_CODE = "tracing-outcome" + const val DATE_OF_AGREED_APPOINTMENT = "date-of-agreed-appointment" + + val resourceEnteredInError = + Coding(SystemConstants.RESOURCE_REMOVAL_REASON_SYSTEM, "entered-in-error", "Entered in error") +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/utilities/SystemConstants.kt b/core/src/main/kotlin/org/dtree/fhir/core/utilities/SystemConstants.kt new file mode 100644 index 0000000..37f5163 --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/utilities/SystemConstants.kt @@ -0,0 +1,49 @@ +package org.dtree.fhir.core.utilities + +object SystemConstants { + const val REASON_CODE_SYSTEM = "https://d-tree.org/fhir/reason-code" + const val BASE_URL = "https://d-tree.org" + const val TASK_FILTER_TAG_SYSTEM = "https://d-tree.org/fhir/task-filter-tag" + const val RESOURCE_CREATED_ON_TAG_SYSTEM = "https://d-tree.org/fhir/created-on-tag" + const val TASK_TASK_ORDER_SYSTEM = "https://d-tree.org/fhir/clinic-visit-task-order" + const val PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM = + "https://d-tree.org/fhir/patient-meta-tag" + const val CONTACT_TRACING_SYSTEM = "https://d-tree.org/fhir/contact-tracing" + const val OBSERVATION_CODE_SYSTEM = "https://d-tree.org/fhir/observation-codes" + const val CARE_PLAN_REFERENCE_SYSTEM = "https://d-tree.org/fhir/careplan-reference" + const val QUESTIONNAIRE_REFERENCE_SYSTEM = "https://d-tree.org/fhir/procedure-code" + const val LOCATION_TAG = "http://smartregister.org/fhir/location-tag" + const val RESOURCE_REMOVAL_REASON_SYSTEM = "https://d-tree.org/fhir/resource-removal-reason-code" + const val LOCATION_HIERARCHY_BINARY = "location-hierarchy" + + fun getIdentifierSystemFromPatientType(patientType: String): String { + return when (patientType) { + "client-already-on-art", + "newly-diagnosed-client", -> { + "https://d-tree.org/fhir/patient-identifier-art" + } + "exposed-infant" -> { + "https://d-tree.org/fhir/patient-identifier-hcc" + } + else -> { + "https://d-tree.org/fhir/patient-identifier-hts" + } + } + } + + fun getCodeByPriority(codes: List): String? { + val priorityOrder = listOf("client-already-on-art", "newly-diagnosed-client", "exposed-infant") + + if (codes.size == 1) { + return codes[0] + } + + for (priorityCode in priorityOrder) { + if (codes.contains(priorityCode)) { + return priorityCode + } + } + + return codes.firstOrNull() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/utils/Dates.kt b/core/src/main/kotlin/org/dtree/fhir/core/utils/Dates.kt index b109c9b..0af0734 100644 --- a/core/src/main/kotlin/org/dtree/fhir/core/utils/Dates.kt +++ b/core/src/main/kotlin/org/dtree/fhir/core/utils/Dates.kt @@ -1,6 +1,12 @@ package org.dtree.fhir.core.utils +import java.text.SimpleDateFormat import java.time.LocalDateTime +import java.util.* + +val SDF_DD_MMM_YYYY = SimpleDateFormat("dd-MMM-yyyy") +val SDF_YYYY_MM_DD = SimpleDateFormat("yyyy-MM-dd") +val SDF_DD_MM_YYYY = SimpleDateFormat("dd/MM/yyyy") fun parseDate(value: String?, useIso: Boolean = false): LocalDateTime? { return try { @@ -18,4 +24,16 @@ fun parseDate(value: String?, useIso: Boolean = false): LocalDateTime? { } catch (e: Exception) { null } +} + +fun Date.asDdMmmYyyy(): String { + return SDF_DD_MMM_YYYY.format(this) +} + +fun Date.asDdMmYyyy(): String { + return SDF_DD_MM_YYYY.format(this) +} + +fun Date.asYyyyMmDd(): String { + return SDF_YYYY_MM_DD.format(this) } \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/utils/FhirHelpers.kt b/core/src/main/kotlin/org/dtree/fhir/core/utils/FhirHelpers.kt index 9df8870..4e086a3 100644 --- a/core/src/main/kotlin/org/dtree/fhir/core/utils/FhirHelpers.kt +++ b/core/src/main/kotlin/org/dtree/fhir/core/utils/FhirHelpers.kt @@ -3,10 +3,10 @@ package org.dtree.fhir.core.utils import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser +import com.google.android.fhir.LocalChange import org.dtree.fhir.core.structureMaps.createStructureMapFromFile -import org.dtree.fhir.core.utils.CoreResponse -import org.dtree.fhir.core.utils.readFile import org.hl7.fhir.r4.model.* +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent import org.hl7.fhir.r4.utils.StructureMapUtilities fun formatStructureMap(path: String, srcName: String?): CoreResponse { @@ -29,20 +29,30 @@ fun Reference.extractId(): String = "" } else this.reference.substringAfterLast(delimiter = '/', missingDelimiterValue = "") +fun String.asReference(resourceType: ResourceType): Reference { + val resourceId = this + return Reference().apply { reference = "${resourceType.name}/$resourceId" } +} + +fun Resource.asReference(): Reference { + val resourceId = this + return Reference().apply { reference = "${resourceType.name}/$logicalId" } +} + val Resource.logicalId: String get() { - return this.idElement?.idPart.orEmpty() + return this.idElement?.idPart?.replace("#", "").orEmpty() } fun CarePlan.isCompleted(): Boolean { val tasks = fetchCarePlanActivities(this) - return tasks.isNotEmpty() && tasks.all { it.detail.status == CarePlan.CarePlanActivityStatus.COMPLETED } + return tasks.isNotEmpty() && tasks.all { it.detail.status == CarePlan.CarePlanActivityStatus.COMPLETED } } fun CarePlan.isStarted(): Boolean { val statuses = listOf(CarePlan.CarePlanActivityStatus.CANCELLED, CarePlan.CarePlanActivityStatus.COMPLETED) - return this.activity.firstOrNull { statuses.contains(it.detail.status) } != null + return this.activity.firstOrNull { statuses.contains(it.detail.status) } != null } fun CarePlan.CarePlanActivityComponent.shouldShowOnProfile(): Boolean { @@ -71,4 +81,19 @@ private fun fetchCarePlanActivities( return activityOnList.values.sortedWith( compareBy(nullsLast()) { it.detail?.code?.text?.toBigIntegerOrNull() }, ) +} + +fun Resource.createBundleComponent(): BundleEntryComponent { + return BundleEntryComponent().apply { + resource = this@createBundleComponent + request = Bundle.BundleEntryRequestComponent().apply { + if (this@createBundleComponent.hasId()) { + method = Bundle.HTTPVerb.PUT + url = "${this@createBundleComponent.resourceType.name}/${this@createBundleComponent.logicalId}" + } else { + method = Bundle.HTTPVerb.POST + url = this@createBundleComponent.resourceType.name + } + } + } } \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/utils/PatientExtension.kt b/core/src/main/kotlin/org/dtree/fhir/core/utils/PatientExtension.kt new file mode 100644 index 0000000..9ed0884 --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/utils/PatientExtension.kt @@ -0,0 +1,50 @@ +package org.dtree.fhir.core.utils + +import org.dtree.fhir.core.utilities.SystemConstants +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Patient + +val Patient.category: String + get() { + return extractPatientTypeCoding()?.code ?: "" + } + + +fun Patient.extractOfficialIdentifier(): String? { + val patientTypes = + this.meta.tag + .filter { it.system == SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM } + .map { it.code } + val patientType: String? = SystemConstants.getCodeByPriority(patientTypes) + return if (this.hasIdentifier() && patientType != null) { + var actualId: Identifier? = null + var hasNewSystem = false + for (pId in this.identifier) { + if (pId.system?.contains("https://d-tree.org/fhir/patient-identifier") == true) { + hasNewSystem = true + } + if (pId.system == SystemConstants.getIdentifierSystemFromPatientType(patientType)) { + actualId = pId + } + } + if (!hasNewSystem) { + this.identifier + .lastOrNull { it.use == Identifier.IdentifierUse.OFFICIAL && it.system != "WHO-HCID" } + ?.value + } else { + actualId?.value + } + } else { + null + } +} + +fun Patient.extractPatientTypeCoding(): Coding? { + val patientTypes = + this.meta.tag.filter { + it.system == SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM + } + val patientType: String? = SystemConstants.getCodeByPriority(patientTypes.map { it.code }) + return patientTypes.firstOrNull { patientType == it.code } +} \ No newline at end of file diff --git a/core/src/main/kotlin/org/dtree/fhir/core/utils/TracingExtension.kt b/core/src/main/kotlin/org/dtree/fhir/core/utils/TracingExtension.kt new file mode 100644 index 0000000..769eb00 --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/utils/TracingExtension.kt @@ -0,0 +1,10 @@ +package org.dtree.fhir.core.utils + +import org.dtree.fhir.core.utilities.ReasonConstants +import org.hl7.fhir.r4.model.Task + +fun Task.isHomeTracingTask(): Boolean { + return this.meta.tag.firstOrNull { + it.`is`(ReasonConstants.homeTracingCoding.system, ReasonConstants.homeTracingCoding.code) + } !== null +} diff --git a/core/src/main/kotlin/org/dtree/fhir/core/utils/TracingHelpers.kt b/core/src/main/kotlin/org/dtree/fhir/core/utils/TracingHelpers.kt new file mode 100644 index 0000000..d40ce1d --- /dev/null +++ b/core/src/main/kotlin/org/dtree/fhir/core/utils/TracingHelpers.kt @@ -0,0 +1,27 @@ +package org.dtree.fhir.core.utils + + +object TracingHelpers { + private val tracingQuestionnaires: List = + listOf( + "art-client-viral-load-test-results", + "phone-tracing-outcome", + "home-tracing-outcome", + "art-client-welcome-service-high-or-detectable-viral-load", + "art-client-viral-load-collection", + "exposed-infant-convert-to-art-client", + "patient-finish-visit", + "exposed-infant-record-hiv-test-results", + "art-client-child-contact-registration", + "art-client-biological-parent-contact-registration", + "art-client-child-contact-registration", + "art-client-sexual-contact-registration", + "art-client-sibling-contact-registration", + "art-client-social-network-contact-registration", + "contact-and-community-positive-hiv-test-and-next-appointment", + ) + const val tracingBundleId = "tracing" + + fun requireTracingTasks(id: String): Boolean = + tracingQuestionnaires.firstOrNull { x -> x == id } != null +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..21806af --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,10 @@ +version: '3.8' + +services: + fhir-service: + image: fhir-helper-service:local + build: + context: . + dockerfile: Dockerfile + volumes: + - ./fhir_data:/data \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index 8c7e8fd..0000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,3 +0,0 @@ -services: - fhir-service: - image: ghcr.io/d-tree-org/fhir-helper-service:dev \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 48f0c84..95054b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,9 @@ version: '3.8' services: fhir-service: - image: ghcr.io/d-tree-org/fhir-helper-service:v0.0.3 + image: ${DC_IMAGE:-ghcr.io/d-tree-org/fhir-helper-service:v0.0.3} ports: - - "49100:4040" + - ${PORT}:4040 environment: - TZ:"Africa/Blantyre" - KEYCLOAK_ID:${KEYCLOAK_ID} @@ -15,3 +15,11 @@ services: - KEYCLOAK_USERNAME:${KEYCLOAK_USERNAME} - KEYCLOAK_PASSWORD:${KEYCLOAK_PASSWORD} - FHIR_BASE_URL:${FHIR_BASE_URL} + - RESOURCES_CACHE_DIR=${RESOURCES_CACHE_DIR} + - RESOURCES_CACHE_TAG=${RESOURCES_CACHE_TAG} + - RESOURCES_CACHE_URL=${RESOURCES_CACHE_URL} + - RESOURCES_SSH_PASSPHRASE=${RESOURCES_SSH_PASSPHRASE} + - RESOURCES_SSH_LOCATION=${RESOURCES_SSH_LOCATION} + - RESOURCES_GIT_KEY=${RESOURCES_GIT_KEY} + volumes: + - fhir_data:/data \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30c079b..761e665 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ hamcrest = "2.2" kotlinLogging = "5.1.0" junit = "5.9.2" jsonToolsJsonPatch = "1.13" +mustache = "0.9.10" [libraries] ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } @@ -47,6 +48,7 @@ ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version. dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" } ktor-server-test-host = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +spullara-mustache = {module = "com.github.spullara.mustache.java:compiler", version.ref = "mustache"} # HapiFhir modules hapi-fhir-base = { group = "ca.uhn.hapi.fhir", name = "hapi-fhir-base", version.ref = "hapiFhir" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index fa10d3d..dabdeec 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -62,6 +62,11 @@ dependencies { implementation(libs.hapi.fhir.caching.guava) implementation(libs.hapi.fhir.client.okhttp) + implementation(libs.spullara.mustache) + testImplementation(libs.ktor.server.test.host) testImplementation(libs.kotlin.test.junit) + + implementation("org.eclipse.jgit:org.eclipse.jgit:7.0.0.202409031743-r") + implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.0.0.202409031743-r") } \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/controller/AppointmentController.kt b/server/src/main/kotlin/org/dtree/fhir/server/controller/AppointmentController.kt new file mode 100644 index 0000000..2d512c5 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/controller/AppointmentController.kt @@ -0,0 +1,18 @@ +package org.dtree.fhir.server.controller + +import org.dtree.fhir.server.services.appointment.AppointmentService +import org.dtree.fhir.server.services.appointment.AppointmentListResults +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.time.LocalDate + +class AppointmentControllerImpl : AppointmentController, BaseController(), KoinComponent { + private val appointmentService by inject() + + override fun getAppointmentList(facilityId: String, date: LocalDate): AppointmentListResults { + return appointmentService.getAppointmentList(facilityId, date) + } +} +interface AppointmentController { + fun getAppointmentList(facilityId: String, date: LocalDate) : AppointmentListResults +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/controller/CarePlanController.kt b/server/src/main/kotlin/org/dtree/fhir/server/controller/CarePlanController.kt new file mode 100644 index 0000000..d1e9e52 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/controller/CarePlanController.kt @@ -0,0 +1,16 @@ +package org.dtree.fhir.server.controller + +import org.dtree.fhir.server.services.carePlan.CarePlanService +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class CarePlanControllerImpl() : CarePlanController, BaseController(), KoinComponent { + private val service by inject() + override fun getCarePlans() { + return service.getCarePlans() + } +} + +interface CarePlanController { + fun getCarePlans() +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/controller/PatientController.kt b/server/src/main/kotlin/org/dtree/fhir/server/controller/PatientController.kt new file mode 100644 index 0000000..3435df7 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/controller/PatientController.kt @@ -0,0 +1,21 @@ +package org.dtree.fhir.server.controller + +import org.dtree.fhir.server.services.patient.PatientService +import org.hl7.fhir.r4.model.Bundle +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class PatientControllerImpl : PatientController, BaseController(), KoinComponent { + private val patientService by inject() + override fun fetchPatientActiveResource(patientId: String): Bundle? { + val patientData = patientService.fetchPatientActiveResource(patientId) + if (patientData.isEmpty()) { + return null + } + return patientData.toBundle() + } +} + +interface PatientController { + fun fetchPatientActiveResource(patientId: String): Bundle? +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/controller/TasksController.kt b/server/src/main/kotlin/org/dtree/fhir/server/controller/TasksController.kt new file mode 100644 index 0000000..b17748a --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/controller/TasksController.kt @@ -0,0 +1,43 @@ +package org.dtree.fhir.server.controller + +import org.dtree.fhir.server.plugins.tasks.ChangeAppointmentData +import org.dtree.fhir.server.plugins.tasks.ChangeStatusType +import org.dtree.fhir.server.plugins.tasks.FinishVisitRequest +import org.dtree.fhir.server.plugins.tasks.TracingRemovalType +import org.dtree.fhir.server.services.form.FormService +import org.dtree.fhir.server.services.util.UtilService +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class TasksControllerImpl : TasksController, BaseController(), KoinComponent { + private val formService by inject() + private val utilService by inject() + + override fun finishVisits(finishVisitRequestList: List) { + formService.finishVisit(finishVisitRequestList) + } + + override suspend fun changeAppointmentData(changeAppointmentDataList: List) { + formService.changeAppointmentData(changeAppointmentDataList) + } + + override suspend fun changeStatus(patients: List, type: ChangeStatusType) { + formService.changeStatus(patients, type) + } + + override suspend fun tracingEnteredInError(patients: List, type: TracingRemovalType) { + formService.tracingEnteredInError(patients, type) + } + + override suspend fun runUtil() { + utilService.runCli() + } +} + +interface TasksController { + fun finishVisits(finishVisitRequestList: List) + suspend fun changeAppointmentData(changeAppointmentDataList: List) + suspend fun tracingEnteredInError(patients: List, type: TracingRemovalType) + suspend fun runUtil() + suspend fun changeStatus(patients: List, type: ChangeStatusType) +} diff --git a/server/src/main/kotlin/org/dtree/fhir/server/controller/TracingController.kt b/server/src/main/kotlin/org/dtree/fhir/server/controller/TracingController.kt index 7e69d88..9ebe4de 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/controller/TracingController.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/controller/TracingController.kt @@ -1,6 +1,6 @@ package org.dtree.fhir.server.controller -import org.dtree.fhir.server.services.tracing.AppointmentListResults +import org.dtree.fhir.server.services.tracing.TracingListResults import org.dtree.fhir.server.services.tracing.TracingService import org.dtree.fhir.server.services.tracing.TracingStatsResults import org.koin.core.component.KoinComponent @@ -13,13 +13,39 @@ class TracingControllerImpl : TracingController, BaseController(), KoinComponent return tracingService.getStats(id) } - override fun getAppointmentList(facilityId: String, date: LocalDate): AppointmentListResults { - return tracingService.getAppointmentList(facilityId, date) + override fun getTracingList(facilityId: String, date: LocalDate): TracingListResults { + val result = tracingService.getTracingList(facilityId, date) + println(result.results.size) + return result + } + + override suspend fun setPatientsEnteredInError(patients: List): Boolean { + return try { + patients.chunked(30).mapIndexed { idx, chunk -> + tracingService.setTracingEnteredInError(chunk) + println("Finished chuck ${idx + 1} - size ${chunk.size}") + } + true + } catch (e: Exception) { + false + } + } + + override suspend fun cleanFutureDateMissedAppointment(facilityId: String): Boolean { + return try { + tracingService.cleanFutureDateMissedAppointment(facilityId) + true + } catch (e: Exception) { + false + } } } interface TracingController { fun getStats(id: String): TracingStatsResults - fun getAppointmentList(facilityId: String, date: LocalDate) : AppointmentListResults + fun getTracingList(facilityId: String, date: LocalDate): TracingListResults + + suspend fun setPatientsEnteredInError(patients: List): Boolean + suspend fun cleanFutureDateMissedAppointment(facilityId: String): Boolean } \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/core/search/filters/General.kt b/server/src/main/kotlin/org/dtree/fhir/server/core/search/filters/General.kt index ff7e933..ca4a797 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/core/search/filters/General.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/core/search/filters/General.kt @@ -77,7 +77,7 @@ fun filterAddCount(count: Int) = FilterFormItem( ) ) -fun filterRevInclude() = FilterFormItem( +fun filterRevInclude(custom: String? = null) = FilterFormItem( filterId = "_include", template = "_include={value}", filterType = FilterTemplateType.template, @@ -85,7 +85,7 @@ fun filterRevInclude() = FilterFormItem( FilterFormParamData( name = "value", type = FilterParamType.string, - value = "Appointment:patient" + value = custom ?: "Appointment:patient" ) ) ) diff --git a/server/src/main/kotlin/org/dtree/fhir/server/core/search/filters/tasks.kt b/server/src/main/kotlin/org/dtree/fhir/server/core/search/filters/tasks.kt new file mode 100644 index 0000000..6b9adc7 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/core/search/filters/tasks.kt @@ -0,0 +1,37 @@ +package org.dtree.fhir.server.core.search.filters + +import org.dtree.fhir.server.core.models.FilterFormItem +import org.dtree.fhir.server.core.models.FilterFormParamData +import org.dtree.fhir.server.core.models.FilterParamType +import org.dtree.fhir.server.core.models.FilterTemplateType + + fun tracingFiltersByFacility(facilityId: String): List { + val locationFilter = filterByLocation(facilityId) + val filterByActive = FilterFormItem( + filterId = "filter-by-task-status", + template = "status={status}", + filterType = FilterTemplateType.template, + params = listOf( + FilterFormParamData( + name = "status", + type = FilterParamType.string, + value = listOf("ready", "in-progress").joinToString(",") + ) + ), + ) + val filterTracingTask = FilterFormItem( + filterId = "filter-by-task-tracing-code", + template = "code={code}", + filterType = FilterTemplateType.template, + params = listOf( + FilterFormParamData( + name = "code", + type = FilterParamType.string, + value = "225368008" + ) + ), + ) + return listOf( locationFilter, + filterByActive, + filterTracingTask) +} diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/Routing.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/Routing.kt index dbd080e..d652064 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/plugins/Routing.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/Routing.kt @@ -11,7 +11,11 @@ import io.ktor.server.resources.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.routing.get +import org.dtree.fhir.server.plugins.appointment.appointmentModule +import org.dtree.fhir.server.plugins.careplan.carePlanModule +import org.dtree.fhir.server.plugins.patient.patientModule import org.dtree.fhir.server.plugins.stats.statsModule +import org.dtree.fhir.server.plugins.tasks.tasksModule import org.dtree.fhir.server.plugins.tracing.tracingModule fun Application.configureRouting() { @@ -47,6 +51,10 @@ fun Application.configureRouting() { statsModule() tracingModule() + appointmentModule() + carePlanModule() + tasksModule() + patientModule() get("/") { call.respondText("Hello, World!") diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/appointment/Appointment.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/appointment/Appointment.kt new file mode 100644 index 0000000..ea1a270 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/appointment/Appointment.kt @@ -0,0 +1,16 @@ +package org.dtree.fhir.server.plugins.appointment + +import io.ktor.resources.* + + +@Resource("/appointments") +class Appointment() { + @Resource("facility") + class Facility(val parent: Appointment = Appointment()) { + @Resource("{id}") + class Id(val parent: Facility = Facility(), val id: String = "") { + @Resource("all") + class List(val parent: Id = Facility.Id(), val date: String? = "") + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/appointment/AppointmentModule.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/appointment/AppointmentModule.kt new file mode 100644 index 0000000..c918747 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/appointment/AppointmentModule.kt @@ -0,0 +1,21 @@ +package org.dtree.fhir.server.plugins.appointment + +import io.ktor.server.application.* +import io.ktor.server.resources.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.dtree.fhir.server.controller.AppointmentController +import org.koin.ktor.ext.inject +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +fun Route.appointmentModule() { + val controller by inject() + + get { appointment -> + println("Jeff") + val formatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE + val result = controller.getAppointmentList(appointment.parent.id, if(appointment.date.isNullOrBlank()) LocalDate.now() else LocalDate.parse(appointment.date, formatter)) + call.respond(result) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/careplan/CarePlan.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/careplan/CarePlan.kt new file mode 100644 index 0000000..88296ee --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/careplan/CarePlan.kt @@ -0,0 +1,16 @@ +package org.dtree.fhir.server.plugins.careplan + +import io.ktor.resources.* + +@Resource("/careplan") +class CarePlan { + @Resource("facility") + class Facility(val parent: CarePlan = CarePlan()) { + @Resource("{id}") + class Id(val parent: Facility = Facility(), val id: String = "") { + + @Resource("all") + class All(val parent: Id = Facility.Id(), val date: String? = "") + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/careplan/CarePlanModule.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/careplan/CarePlanModule.kt new file mode 100644 index 0000000..fe7d0f2 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/careplan/CarePlanModule.kt @@ -0,0 +1,17 @@ +package org.dtree.fhir.server.plugins.careplan + +import io.ktor.server.application.* +import io.ktor.server.resources.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.dtree.fhir.server.controller.TracingController +import org.koin.ktor.ext.inject +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +fun Route.carePlanModule() { + + get { facility -> + + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/injection/ModulesInjection.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/injection/ModulesInjection.kt index 4bd2e9d..f761874 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/plugins/injection/ModulesInjection.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/injection/ModulesInjection.kt @@ -1,18 +1,43 @@ package org.dtree.fhir.server.plugins.injection +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.parser.IParser +import com.google.android.fhir.datacapture.XFhirQueryResolver import io.github.cdimascio.dotenv.Dotenv +import org.dtree.fhir.core.di.DXFhirQueryResolver import org.dtree.fhir.core.di.FhirProvider import org.dtree.fhir.core.uploader.general.FhirClient +import org.dtree.fhir.server.controller.* +import org.dtree.fhir.server.services.form.LocalResourceFetcher +import org.dtree.fhir.server.services.form.ResourceFetcher +import org.dtree.fhir.server.services.form.ResponseGenerator +import org.koin.core.module.dsl.singleOf import org.dtree.fhir.server.controller.StatsController import org.dtree.fhir.server.controller.StatsControllerImpl import org.dtree.fhir.server.controller.TracingController import org.dtree.fhir.server.controller.TracingControllerImpl import org.koin.dsl.module -object ModulesInjection { +object ModulesInjection { val koinBeans = module { + single { DXFhirQueryResolver() } + single(createdAtStart = true) { FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() } + + single(createdAtStart = true) { + val fetcher = LocalResourceFetcher(this.get(), this.get().parser, this.get()) + fetcher.getRepository() + fetcher + } single { StatsControllerImpl() } - single { TracingControllerImpl() } - single { FhirClient(this.get(), this.get().parser()) } + single { TracingControllerImpl() } + single { AppointmentControllerImpl() } + single { CarePlanControllerImpl() } + single { TasksControllerImpl() } + single { PatientControllerImpl() } + + single { FhirClient(this.get(), this.get().parser) } + + singleOf(::ResponseGenerator) } } \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/patient/PatientModule.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/patient/PatientModule.kt new file mode 100644 index 0000000..098c62b --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/patient/PatientModule.kt @@ -0,0 +1,28 @@ +package org.dtree.fhir.server.plugins.patient + +import ca.uhn.fhir.parser.IParser +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.resources.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.dtree.fhir.server.controller.PatientController +import org.koin.ktor.ext.inject + +fun Route.patientModule() { + val controller by inject() + val iParser by inject() + + get { patient -> + val bundle = controller.fetchPatientActiveResource(patient.id) + if (bundle == null) { + call.respond(HttpStatusCode.BadRequest, "Could not find patient") + return@get + } + call.respondText( + iParser.encodeResourceToString(bundle), + ContentType.Application.Json, + HttpStatusCode.OK + ) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/patient/PatientResource.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/patient/PatientResource.kt new file mode 100644 index 0000000..dc50def --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/patient/PatientResource.kt @@ -0,0 +1,9 @@ +package org.dtree.fhir.server.plugins.patient + +import io.ktor.resources.* + +@Resource("/patients") +class PatientResource { + @Resource("{id}") + class Id(val parent: PatientResource = PatientResource(), val id: String = "") {} +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/ChangeAppointmentData.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/ChangeAppointmentData.kt new file mode 100644 index 0000000..93af87a --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/ChangeAppointmentData.kt @@ -0,0 +1,30 @@ +package org.dtree.fhir.server.plugins.tasks + +import java.util.* + +data class ChangeAppointmentData( + val id: String, + val date: Date, +) + +data class TracingEnteredErrorData( + val type: TracingRemovalType, + val data: List, +) + +data class ChangeStatusData( + val type: ChangeStatusType, + val data: List, +) + +enum class TracingRemovalType { + EnteredInError, + TransferredOut, + Deceased +} + +enum class ChangeStatusType { + Discharged, + Deceased, + EnteredInError +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/FinishVisitRequest.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/FinishVisitRequest.kt new file mode 100644 index 0000000..3b8249f --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/FinishVisitRequest.kt @@ -0,0 +1,9 @@ +package org.dtree.fhir.server.plugins.tasks + +import java.util.* + +data class FinishVisitRequest( + val id: String, + val date: Date, + val dateVisited: Date? = null, +) \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/Tasks.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/Tasks.kt new file mode 100644 index 0000000..ca856d4 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/Tasks.kt @@ -0,0 +1,23 @@ +package org.dtree.fhir.server.plugins.tasks + +import io.ktor.resources.* + +@Resource("/tasks") +class Tasks() { + @Resource("fixes") + class Fixes(val parent: Tasks = Tasks()) { + @Resource("finish-visits") + class FinishVisits(val parent: Fixes = Fixes()) + + @Resource("appointment-date") + class AppointmentData(val parent: Fixes = Fixes()) + + @Resource("tracing-entered-error") + class TracingEnteredError(val parent: Fixes = Fixes()) + + @Resource("change-status") + class ChangeStatus(val parent: Fixes = Fixes()) + } + @Resource("util") + class Utils(val parent: Tasks = Tasks()) {} +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/TasksModule.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/TasksModule.kt new file mode 100644 index 0000000..f8b55c0 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tasks/TasksModule.kt @@ -0,0 +1,47 @@ +package org.dtree.fhir.server.plugins.tasks + +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.resources.* +import io.ktor.server.resources.post +import io.ktor.server.response.* +import io.ktor.server.routing.Route +import org.dtree.fhir.server.controller.TasksController +import org.koin.ktor.ext.inject + +fun Route.tasksModule() { + val controller by inject() + + post { + val body = call.receive>() + controller.finishVisits(body) + call.respond("Jeff") + } + + post { + val body = call.receive>() + controller.changeAppointmentData(body) + call.respond("Jeff") + } + + post { + val body = call.receive() + + controller.tracingEnteredInError(body.data, body.type) + + call.respond("Jeff") + } + + post { + val body = call.receive() + + controller.changeStatus(body.data, body.type) + + call.respond("Jeff") + } + + get { + controller.runUtil() + call.respond("Jeff") + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/Tracing.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/Tracing.kt index bde2f56..3bc053d 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/Tracing.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/Tracing.kt @@ -11,6 +11,14 @@ class Tracing() { class Id(val parent: Facility = Facility(), val id: String = "") { @Resource("appointments") class List(val parent: Id = Facility.Id(), val date: String? = "") + + @Resource("all") + class All(val parent: Id = Facility.Id(), val date: String? = "") + + @Resource("clean-future-date") + class CleanFutureDate(val parent: Id = Facility.Id()) } } + @Resource("entered-in-error") + class EnteredInError(val parent: Tracing = Tracing()) {} } \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/TracingModule.kt b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/TracingModule.kt index 6b33eb8..5b61672 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/TracingModule.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/plugins/tracing/TracingModule.kt @@ -1,5 +1,12 @@ package org.dtree.fhir.server.plugins.tracing +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.resources.* +import io.ktor.server.resources.post +import io.ktor.server.response.* +import io.ktor.server.routing.Route import io.ktor.server.application.* import io.ktor.server.resources.* import io.ktor.server.response.* @@ -17,10 +24,24 @@ fun Route.tracingModule() { call.respond(result) } - get { appointment -> - println("Jeff") - val formatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE - val result = controller.getAppointmentList(appointment.parent.id, if(appointment.date.isNullOrBlank()) LocalDate.now() else LocalDate.parse(appointment.date, formatter)) + get { values -> + val formatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE + val result = controller.getTracingList( + values.parent.id, + if (values.date.isNullOrBlank()) LocalDate.now() else LocalDate.parse(values.date, formatter) + ) + call.respond(result) + } + + post {facility -> + val result = controller.cleanFutureDateMissedAppointment(facility.parent.id) call.respond(result) } + + post { + val patients = call.receive>() + if (patients.isEmpty()) return@post call.respond(HttpStatusCode.BadRequest, "Patients empty") + call.respond("Job started") + controller.setPatientsEnteredInError(patients) + } } \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/appointment/AppointmentListResults.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/appointment/AppointmentListResults.kt new file mode 100644 index 0000000..a3221ac --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/appointment/AppointmentListResults.kt @@ -0,0 +1,9 @@ +package org.dtree.fhir.server.services.appointment + +import java.time.LocalDate + +data class AppointmentListResults( + val results: List +) + +data class AppointmentResultItem(val uuid: String, val id: String?, val name: String, val date: LocalDate?) \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/appointment/AppointmentService.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/appointment/AppointmentService.kt new file mode 100644 index 0000000..ca89a45 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/appointment/AppointmentService.kt @@ -0,0 +1,45 @@ +package org.dtree.fhir.server.services.appointment + +import org.dtree.fhir.core.uploader.general.FhirClient +import org.dtree.fhir.core.utils.extractOfficialIdentifier +import org.dtree.fhir.core.utils.logicalId +import org.dtree.fhir.server.core.models.FilterFormData +import org.dtree.fhir.server.core.search.filters.filterAddCount +import org.dtree.fhir.server.core.search.filters.filterByDate +import org.dtree.fhir.server.core.search.filters.filterByLocation +import org.dtree.fhir.server.core.search.filters.filterRevInclude +import org.dtree.fhir.server.services.tracing.fetch +import org.hl7.fhir.r4.model.Appointment +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.ResourceType +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.time.LocalDate +import java.time.ZoneId + +class AppointmentService : KoinComponent { + private val client by inject() + + fun getAppointmentList(facilityId: String, date: LocalDate): AppointmentListResults { + val dateFilter = filterByDate(date) + val locationFilter = filterByLocation(facilityId) + val filter = FilterFormData( + resource = ResourceType.Appointment.name, + filterId = "random_filter", + filters = listOf(dateFilter, filterAddCount(20000), filterRevInclude(), locationFilter) + ) + + val results = fetch(client, listOf(filter)) + return AppointmentListResults(results.map { + val mPatient = (it.include as Patient) + val appointment = it.main as Appointment + val mDate = appointment.start?.toInstant()?.atZone(ZoneId.systemDefault())?.toLocalDate() + AppointmentResultItem( + uuid = mPatient.logicalId, + id = mPatient.extractOfficialIdentifier(), + name = mPatient.nameFirstRep.nameAsSingleString, + date = mDate + ) + }) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/carePlan/CarePlanService.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/carePlan/CarePlanService.kt new file mode 100644 index 0000000..1bbe6ba --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/carePlan/CarePlanService.kt @@ -0,0 +1,12 @@ +package org.dtree.fhir.server.services.carePlan + +import org.dtree.fhir.core.uploader.general.FhirClient +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class CarePlanService : KoinComponent { + private val client by inject() + fun getCarePlans() { + + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/form/FinishVisitData.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/form/FinishVisitData.kt new file mode 100644 index 0000000..ac3214e --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/form/FinishVisitData.kt @@ -0,0 +1,18 @@ +package org.dtree.fhir.server.services.form + +data class FinishVisitData( + val patient: Patient, + val isClientAvailable: Boolean, + val carePlanId: String, + val dateVisited: String, + val nextAppointment: String, + val patientLinkArrayContainsPatientAtIndex0: Boolean = false, + val patientLinkArrayContainsPatientAtIndex1: Boolean = false, + val patientLinkArrayContainsRelatedpersonAtIndex0: Boolean = false, + val patientLinkArrayContainsRelatedpersonAtIndex1: Boolean = false, + val patientLinkRefer: Int = 0, + val patientLinkSeealso: Int = 0, + val contained: String? = null +) { + data class Patient(val id: String, val category: String, val birthDate: String) +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/form/FormService.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/form/FormService.kt new file mode 100644 index 0000000..3aea2c3 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/form/FormService.kt @@ -0,0 +1,300 @@ +package org.dtree.fhir.server.services.form + +import ca.uhn.fhir.parser.IParser +import com.google.android.fhir.datacapture.XFhirQueryResolver +import org.apache.commons.lang3.time.DateUtils +import org.dtree.fhir.core.fhir.PatchMaker +import org.dtree.fhir.core.models.PatientData +import org.dtree.fhir.core.models.TracingType +import org.dtree.fhir.core.uploader.general.FhirClient +import org.dtree.fhir.core.utils.asYyyyMmDd +import org.dtree.fhir.core.utils.category +import org.dtree.fhir.core.utils.createBundleComponent +import org.dtree.fhir.server.plugins.tasks.ChangeAppointmentData +import org.dtree.fhir.server.plugins.tasks.ChangeStatusType +import org.dtree.fhir.server.plugins.tasks.FinishVisitRequest +import org.dtree.fhir.server.plugins.tasks.TracingRemovalType +import org.hl7.fhir.r4.model.* +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.* + +object FormService : KoinComponent { + private val responseGenerator by inject() + private val fetcher by inject() + private val client by inject() + private val iParser by inject() + private val xFhirQueryResolver by inject() + + fun finishVisit(body: List) { + val strMap = fetcher.fetchStructureMap("finish-visit") + for (entry in body) { + val patientData = client.fetchAllPatientsActiveItems(entry.id) + if (patientData.isEmpty()) continue + responseGenerator.generateFinishVisit(patientData.patient, CarePlan(), Date(), Date(), listOf()) + } + } + + suspend fun changeStatus(patients: List, type: ChangeStatusType) { + val entriesToSave = mutableListOf() + + val structureMapChangeStatus: StructureMap = + fetcher.fetchStructureMap("structure_map/change_status/change_status.map") + val statusQuestionnaire: Questionnaire = + fetcher.getQuestionnaire("questionnaire/profile/change_status.json") + + val structureMapMilestone = + fetcher.fetchStructureMap("structure_map/visit/exposed_infant/milestone_hiv_test/exposed_infant_milestone_hiv_test.map") + val questionnaireMilestone: Questionnaire = + fetcher.getQuestionnaire("questionnaire/visit/exposed_infant/milestone_hiv_test/exposed_infant_milestone_hiv_test.json") + + for (patientId in patients) { + val patientData = client.fetchAllPatientsActiveItems(patientId) + if (patientData.isEmpty()) continue + val questionnaire: Questionnaire + val structureMap: StructureMap + val exposedDischarge = + type == ChangeStatusType.Discharged && patientData.patient.category == "exposed-infant" + if (exposedDischarge) { + questionnaire = questionnaireMilestone.copy() + structureMap = structureMapMilestone + } else { + questionnaire = statusQuestionnaire.copy() + structureMap = structureMapChangeStatus + } + entriesToSave.addAll(handleParse( + questionnaire, + structureMap, + patientData + ) { + if (exposedDischarge) { + updateAnswerInGroup("page-1", "able-to-conduct-test", listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + )) + updateAnswerInGroup("page-1", "test-type", listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { + code = "dbs" + display = "DBS" + } + } + )) + updateAnswerInGroup("page-1", "are-dbs-test-results-available", listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + )) + updateAnswerInGroup("page-1", "hiv-test-results", listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { + code = "negative" + display = "Negative" + } + } + )) + updateAnswerInGroup("page-1", "is-child-weaned", listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { + code = "yes" + display = "Yes" + } + } + )) + updateAnswerInGroup("page-1", "when-was-child-weaned", listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { + code = "more-than-6-weeks-ago" + display = "More than 6 weeks ago" + } + } + )) + } else if (type == ChangeStatusType.Discharged) { + updateSingleAnswer("why-are-you-removing-this-person-art-client", Coding().apply { + code = "negative-confirmatory-hiv-test" + display = "Their confirmatory HIV test came back with a negative result" + }) + } + }) + } + + saveResources(entriesToSave) + } + + suspend fun changeAppointmentData(body: List) { + val structureMap = + fetcher.fetchStructureMap("structure_map/profile/patient_edit_profile/patient_edit_profile.map") + val questionnaireRaw: Questionnaire = + fetcher.getQuestionnaire("questionnaire/profile/patient_edit_profile.json") + val entriesToSave = mutableListOf() + + for (entry in body) { + val questionnaire = questionnaireRaw.copy() + val patientData = client.fetchAllPatientsActiveItems(entry.id) + if (patientData.isEmpty()) continue + var questionnaireResponse = responseGenerator.generateQuestionerResponse(questionnaire, patientData) + + if (patientData.currentCarePlan?.period?.end != null && DateUtils.isSameDay( + patientData.currentCarePlan?.period?.end, + entry.date + ) + ) { + println("The date is already the same ${entry.id} - ${entry.date.asYyyyMmDd()}") + continue + } + + val responseUpdater = QuestionnaireResponseUpdater( + questionnaire, + questionnaireResponse, + patientData.toLaunchContextMap(), + xFhirQueryResolver, + ) + responseUpdater.updateAnswerInGroup( + "page-5", + "careplan-end-date", + listOf(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(entry.date) + }) + ) + questionnaireResponse = responseUpdater.getQuestionnaireResponse() + println(iParser.encodeResourceToString(questionnaireResponse)) + val bundle = responseGenerator.extractBundle(questionnaire, questionnaireResponse, structureMap) + println(iParser.encodeResourceToString(bundle)) + val bundleResources = bundle.entry.map { + val resource = it.resource + if (resource is Appointment) { + val idx = + resource.participant.indexOfFirst { it.actor?.reference?.contains("Practitioner/Practitioner/") == true } + if (idx != -1) { + resource.participant[idx] = resource.participant[idx].apply { + actor.reference = actor.reference.replace("Practitioner/Practitioner/", "Practitioner/") + } + } + } + resource + } + questionnaireResponse.contained = bundleResources + + entriesToSave.add(questionnaireResponse.createBundleComponent()) + entriesToSave.addAll(PatchMaker.createPatchedRequest(iParser, patientData.getAllItemMap(), bundleResources)) + } + saveResources(entriesToSave) + } + + suspend fun tracingEnteredInError(patients: List, type: TracingRemovalType) { + val entriesToSave = mutableListOf() + val phoneStructureMap = + fetcher.fetchStructureMap("structure_map/tracing/phone_tracing/phone_tracing_outcomes.map") + val homeStructureMap = + fetcher.fetchStructureMap("structure_map/tracing/home_tracing/home_tracing_outcomes.map") + val phoneQuestionnaire: Questionnaire = + fetcher.getQuestionnaire("questionnaire/tracing/phone_tracing/phone_tracing_outcomes.json") + val homeQuestionnaire: Questionnaire = + fetcher.getQuestionnaire("questionnaire/tracing/home_tracing/home_tracing_outcomes.json") + + for (patientId in patients) { + val patientData = client.fetchAllPatientsActiveItems(patientId) + if (patientData.isEmpty()) continue + val tracingType = patientData.getTracingType() + if (tracingType == TracingType.none) continue + + val questionnaire = + if (tracingType == TracingType.phone) phoneQuestionnaire.copy() else homeQuestionnaire.copy() + val structureMap = if (tracingType == TracingType.phone) phoneStructureMap else homeStructureMap + + + var questionnaireResponse = responseGenerator.generateQuestionerResponse(questionnaire, patientData) + + val responseUpdater = QuestionnaireResponseUpdater( + questionnaire, + questionnaireResponse, + patientData.toLaunchContextMap(), + xFhirQueryResolver + ) + if (type != TracingRemovalType.TransferredOut) { + responseUpdater.updateSingleAnswer("is-tracing-conducted", BooleanType(false)) + responseUpdater.updateAnswerInGroup( + "not-conducted-group", + "reason-for-no-tracing", + listOf(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + if (type == TracingRemovalType.EnteredInError) { + value = Coding().apply { + code = "no-tracing-required" + display = "Error, this person does not require tracing" + } + } else if (type == TracingRemovalType.Deceased) { + value = Coding().apply { + code = "deceased" + display = "Deceased" + } + } + }) + ) + } else { + responseUpdater.updateSingleAnswer("is-tracing-conducted", BooleanType(true)) + responseUpdater.updateAnswerInGroup( + "conducted-group", + "tracing-outcome", + listOf(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { + code = "transfer-out" + display = "Transfer Out" + } + }) + ) + } + questionnaireResponse = responseUpdater.getQuestionnaireResponse() + println(iParser.encodeResourceToString(questionnaireResponse)) + val bundle = responseGenerator.extractBundle(questionnaire, questionnaireResponse, structureMap) + val bundleResources = bundle.entry.map { it.resource } + questionnaireResponse.contained = bundleResources + + entriesToSave.add(questionnaireResponse.createBundleComponent()) + entriesToSave.addAll(PatchMaker.createPatchedRequest(iParser, patientData.getAllItemMap(), bundleResources)) + } + + saveResources(entriesToSave) + } + + private suspend fun saveResources(resources: List) { + if (resources.isEmpty()) return + val bundle = Bundle() + resources.forEach { + bundle.addEntry(it) + } + println(iParser.encodeResourceToString(bundle)) +// throw Exception("Jeff") + client.bundleUpload(resources, 30) + } + + private suspend fun handleParse( + questionnaire: Questionnaire, + structureMap: StructureMap, + patientData: PatientData, + updateResponse: QuestionnaireResponseUpdater.() -> Unit + ): List { + var questionnaireResponse = responseGenerator.generateQuestionerResponse(questionnaire, patientData) + + val responseUpdater = QuestionnaireResponseUpdater( + questionnaire, + questionnaireResponse, + patientData.toLaunchContextMap(), + xFhirQueryResolver + ) + updateResponse.invoke(responseUpdater) + questionnaireResponse = responseUpdater.getQuestionnaireResponse() + println(iParser.encodeResourceToString(questionnaireResponse)) + val bundle = responseGenerator.extractBundle(questionnaire, questionnaireResponse, structureMap) + val bundleResources = bundle.entry.mapNotNull { it.resource } + questionnaireResponse.contained = bundleResources + + return listOf(questionnaireResponse.createBundleComponent()) + PatchMaker.createPatchedRequest( + iParser, + patientData.getAllItemMap(), + bundleResources + ) + } + +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/form/QuestionnaireResponseUpdater.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/form/QuestionnaireResponseUpdater.kt new file mode 100644 index 0000000..f6555fe --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/form/QuestionnaireResponseUpdater.kt @@ -0,0 +1,241 @@ +package org.dtree.fhir.server.services.form + +import com.google.android.fhir.datacapture.XFhirQueryResolver +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent +import org.hl7.fhir.r4.model.Type +import com.google.android.fhir.datacapture.enablement.EnablementEvaluator +import com.google.android.fhir.datacapture.extensions.localizedTextSpanned +import com.google.android.fhir.datacapture.extensions.unpackRepeatedGroups +import org.hl7.fhir.r4.model.Resource + +typealias ItemToParentMap = MutableMap + +class QuestionnaireResponseUpdater( + private val questionnaire: Questionnaire, + private val questionnaireResponse: QuestionnaireResponse, + private val questionnaireLaunchContextMap: Map?, + private var xFhirQueryResolver: XFhirQueryResolver, +) { + private val enablementEvaluator: EnablementEvaluator + private var questionnaireItemParentMap: + Map + + init { + fun buildParentList( + item: QuestionnaireItemComponent, + questionnaireItemToParentMap: ItemToParentMap, + ) { + for (child in item.item) { + questionnaireItemToParentMap[child] = item + buildParentList(child, questionnaireItemToParentMap) + } + } + + questionnaireItemParentMap = buildMap { + for (item in questionnaire.item) { + buildParentList(item, this) + } + } + + enablementEvaluator = EnablementEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + } + + /** + * Updates an answer in the QuestionnaireResponse by finding the matching linkId + * Handles nested questions by recursively searching through item groups + */ + fun updateAnswer( + linkId: String, + newAnswer: List + ): Boolean { + return updateAnswerInItems(questionnaireResponse.item, linkId, newAnswer) + } + + /** + * Updates an answer by providing both parent group ID and child question ID + */ + fun updateAnswerInGroup( + groupId: String, + childId: String, + newAnswer: List + ): Boolean { + // First find the parent group + val group = findItemByLinkId(questionnaireResponse.item, groupId) + if (group != null) { + // Then update the child within that group + return updateAnswerInItems(group.item, childId, newAnswer) + } + return false + } + + /** + * Updates a single answer within a specific group + */ + fun updateSingleAnswerInGroup(groupId: String, childId: String, answerValue: Type): Boolean { + val answer = QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + answer.value = answerValue + return updateAnswerInGroup(groupId, childId, listOf(answer)) + } + + /** + * Finds an item component by its linkId + */ + private fun findItemByLinkId( + items: List, + targetLinkId: String + ): QuestionnaireResponse.QuestionnaireResponseItemComponent? { + items.forEach { item -> + if (item.linkId == targetLinkId) { + return item + } + + if (item.hasItem()) { + findItemByLinkId(item.item, targetLinkId)?.let { return it } + } + + if (item.hasAnswer()) { + item.answer.forEach { answer -> + if (answer.hasItem()) { + findItemByLinkId(answer.item, targetLinkId)?.let { return it } + } + } + } + } + return null + } + + /** + * Convenience method to update a single answer value + */ + fun updateSingleAnswer(linkId: String, answerValue: Type): Boolean { + val answer = QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + answer.value = answerValue + return updateAnswer(linkId, listOf(answer)) + } + + private fun updateAnswerInItems( + items: List, + targetLinkId: String, + newAnswer: List + ): Boolean { + items.forEach { item -> + // Check if current item matches the target linkId + if (item.linkId == targetLinkId) { + item.answer = newAnswer + return true + } + + // Recursively check nested items + if (item.hasItem()) { + if (updateAnswerInItems(item.item, targetLinkId, newAnswer)) { + return true + } + } + + // Check answers for nested items + if (item.hasAnswer()) { + item.answer.forEach { answer -> + if (answer.hasItem()) { + if (updateAnswerInItems(answer.item, targetLinkId, newAnswer)) { + return true + } + } + } + } + } + return false + } + + /** + * Retrieves an answer by linkId + */ + fun getAnswer(linkId: String): List? { + return findAnswerInItems(questionnaireResponse.item, linkId) + } + + /** + * Retrieves an answer using both group ID and child ID + */ + fun getAnswerFromGroup( + groupId: String, + childId: String + ): List? { + val group = findItemByLinkId(questionnaireResponse.item, groupId) + return group?.let { findAnswerInItems(it.item, childId) } + } + + private fun findAnswerInItems( + items: List, + targetLinkId: String + ): List? { + items.forEach { item -> + if (item.linkId == targetLinkId) { + return item.answer + } + + if (item.hasItem()) { + findAnswerInItems(item.item, targetLinkId)?.let { return it } + } + + if (item.hasAnswer()) { + item.answer.forEach { answer -> + if (answer.hasItem()) { + findAnswerInItems(answer.item, targetLinkId)?.let { return it } + } + } + } + } + return null + } + + suspend fun getQuestionnaireResponse(): QuestionnaireResponse { + return questionnaireResponse.copy().apply { + // Use the view model's questionnaire and questionnaire response for calculating enabled items + // because the calculation relies on references to the questionnaire response items. + item = + getEnabledResponseItems( + this@QuestionnaireResponseUpdater.questionnaire.item, + questionnaireResponse.item, + ) + .map { it.copy() } + this.unpackRepeatedGroups(this@QuestionnaireResponseUpdater.questionnaire) + } + } + + private suspend fun getEnabledResponseItems( + questionnaireItemList: List, + questionnaireResponseItemList: List, + ): List { + val responseItemKeys = questionnaireResponseItemList.map { it.linkId } + val result = mutableListOf() + + for ((questionnaireItem, questionnaireResponseItem) in + questionnaireItemList.zip(questionnaireResponseItemList)) { + if ( + responseItemKeys.contains(questionnaireItem.linkId) && + enablementEvaluator.evaluate(questionnaireItem, questionnaireResponseItem) + ) { + questionnaireResponseItem.apply { + if (text.isNullOrBlank()) { + text = questionnaireItem.localizedTextSpanned + } + // Nested group items + item = getEnabledResponseItems(questionnaireItem.item, questionnaireResponseItem.item) + // Nested question items + answer.forEach { it.item = getEnabledResponseItems(questionnaireItem.item, it.item) } + } + result.add(questionnaireResponseItem) + } + } + return result + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/form/ResourceFetcher.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/form/ResourceFetcher.kt new file mode 100644 index 0000000..f295bff --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/form/ResourceFetcher.kt @@ -0,0 +1,145 @@ +package org.dtree.fhir.server.services.form + +import ca.uhn.fhir.parser.IParser +import io.github.cdimascio.dotenv.Dotenv +import org.dtree.fhir.core.compiler.parsing.ParseJsonCommands +import org.dtree.fhir.core.config.ProjectConfig +import org.dtree.fhir.core.config.ProjectConfigManager +import org.dtree.fhir.core.di.FhirProvider +import org.dtree.fhir.core.utils.readFile +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.TransportConfigCallback +import org.eclipse.jgit.transport.TransportHttp +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.StructureMap +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import kotlin.io.path.exists +import kotlin.io.path.getLastModifiedTime +import kotlin.io.path.pathString + + +interface ResourceFetcher { + fun fetchStructureMap(id: String): StructureMap + fun getResponseTemplate(s: String): String + fun getQuestionnaire(s: String): Questionnaire +} + +class LocalResourceFetcher(private val dotEnv: Dotenv, private val iParser: IParser, val fhirProvider: FhirProvider) : + ResourceFetcher { + + private val baseDir: Path = Path.of(dotEnv["RESOURCES_CACHE_DIR"] ?: "repo-cache") + private val cacheExpiryHours: Long = 24 + private var projectConfig: ProjectConfig = ProjectConfig() + private val credentialsProvider: UsernamePasswordCredentialsProvider + private val transportConfigCallback: TransportConfigCallback + + init { + Files.createDirectories(baseDir) + credentialsProvider = UsernamePasswordCredentialsProvider(dotEnv["RESOURCES_GIT_KEY"], "") + transportConfigCallback = TransportConfigCallback { transport -> + if (transport is TransportHttp) { + transport.additionalHeaders = + mapOf(Pair("Authorization", "Bearer ${dotEnv["RESOURCES_GIT_KEY"]}")) + } + } + } + + private fun repoPath(): Path { + val repoUrl = dotEnv["RESOURCES_CACHE_URL"] ?: "" + val repoName = extractRepoName(repoUrl) + return baseDir.resolve(repoName) + } + + fun getRepository(): Path { + val repoUrl = dotEnv["RESOURCES_CACHE_URL"] ?: "" + val repoCachePath = repoPath() + + if (shouldUpdateCache(repoCachePath)) { + synchronized(this) { + cloneOrUpdateRepo(repoUrl, repoCachePath.toFile()) + } + } + + return repoCachePath + } + + private fun extractRepoName(repoUrl: String): String { + return repoUrl.substringAfterLast("/") + .removeSuffix(".git") + } + + private fun shouldUpdateCache(repoCachePath: Path): Boolean { + if (!repoCachePath.exists()) return true + + val lastModified = repoCachePath.getLastModifiedTime().toInstant() + val expiryThreshold = Instant.now().minusSeconds(cacheExpiryHours * 3600) + + return lastModified.isBefore(expiryThreshold) + } + + private fun updateExistingRepo(repoDir: File) { + try { + Git.open(repoDir).use { git -> + val call = git.pull() + .setCredentialsProvider(credentialsProvider) + .setTransportConfigCallback(transportConfigCallback) + .call() + call + } + } catch (e: Exception) { + throw GitHubRepoCacheException("Failed to update repository", e) + } + } + + private fun cloneNewRepo(repoUrl: String, repoDir: File) { + try { + Git.cloneRepository() + .setURI(repoUrl) + .setDepth(1) + .setBranch(dotEnv["RESOURCES_CACHE_TAG"] ?: "production") + .setDirectory(repoDir) + .setCredentialsProvider(credentialsProvider) + .setTransportConfigCallback(transportConfigCallback) + .call() + .close() + } catch (e: Exception) { + throw GitHubRepoCacheException("Failed to clone repository", e) + } + } + + private fun cloneOrUpdateRepo(repoUrl: String, repoDir: File) { + if (repoDir.exists()) { + updateExistingRepo(repoDir) + } else { + cloneNewRepo(repoUrl, repoDir) + } + projectConfig = ProjectConfigManager().loadProjectConfig( + projectRoot = repoPath().pathString, + file = null + ) + } + + override fun fetchStructureMap(id: String): StructureMap { + return ParseJsonCommands().parseStructureMap( + repoPath().resolve(id).pathString, + fhirProvider.parser, + fhirProvider.scu(), projectConfig + ) ?: throw Exception("Failed to fetch StructureMap") + } + + override fun getResponseTemplate(s: String): String { + return repoPath().resolve("response-templates/${s}.mustache").toFile().readFile() + } + + override fun getQuestionnaire(s: String): Questionnaire { + val questStr = repoPath().resolve(s).toFile().readFile() + return iParser.parseResource(Questionnaire::class.java, questStr) + } +} + +class GitHubRepoCacheException(message: String, cause: Throwable? = null) : + Exception(message, cause) \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/form/ResponseGenerator.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/form/ResponseGenerator.kt new file mode 100644 index 0000000..9cd29f3 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/form/ResponseGenerator.kt @@ -0,0 +1,91 @@ +package org.dtree.fhir.server.services.form + +import com.github.mustachejava.DefaultMustacheFactory +import com.github.mustachejava.MustacheFactory +import com.google.android.fhir.datacapture.mapping.ResourceMapper +import com.google.android.fhir.datacapture.mapping.StructureMapExtractionContext +import org.dtree.fhir.core.di.FhirProvider +import org.dtree.fhir.core.models.PatientData +import org.dtree.fhir.core.utilities.TransformSupportServices +import org.dtree.fhir.core.utils.asYyyyMmDd +import org.dtree.fhir.core.utils.category +import org.dtree.fhir.core.utils.logicalId +import org.hl7.fhir.r4.model.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.ByteArrayOutputStream +import java.io.OutputStreamWriter +import java.io.StringReader +import java.io.Writer +import java.util.* + + +class ResponseGenerator : KoinComponent { + + private val fetcher by inject() + private val fhirProvider by inject() + private val transformSupportServices = TransformSupportServices(fhirProvider.context) + + fun generateFinishVisit( + patient: Patient, + carePlan: CarePlan, + nextAppointment: Date, + dateVisited: Date, + extras: List + ): QuestionnaireResponse { + val template = fetcher.getResponseTemplate("finish-visit") + val mf: MustacheFactory = DefaultMustacheFactory() + val byteArrayOutputStream = ByteArrayOutputStream() + + val writer: Writer = OutputStreamWriter(byteArrayOutputStream) + + val mustache = mf.compile(StringReader(template), "patient-finish-visit") + mustache.execute( + writer, FinishVisitData( + nextAppointment = nextAppointment.asYyyyMmDd(), + dateVisited = dateVisited.asYyyyMmDd(), + isClientAvailable = true, + carePlanId = carePlan.logicalId, + patient = FinishVisitData.Patient( + id = patient.logicalId, + category = patient.category, + birthDate = patient.birthDate.asYyyyMmDd(), + ), + contained = extras.joinToString(",") { fhirProvider.parser.encodeResourceToString(it) } + ) + ) + writer.flush() + val output = byteArrayOutputStream.toString() + return fhirProvider.parser.parseResource(output) as QuestionnaireResponse + } + + suspend fun generateQuestionerResponse( + questionnaire: Questionnaire, + patientData: PatientData + ): QuestionnaireResponse { + val populationResourcesList = patientData.toPopulationResource() + val populationResourceTypeResourceMap = + populationResourcesList.associateBy { it.resourceType.name.lowercase() } + val response = ResourceMapper.populate(questionnaire, populationResourceTypeResourceMap) + response.contained = populationResourcesList + return response + } + + suspend fun extractBundle( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + structureMap: StructureMap + ): Bundle { + return ResourceMapper.extract( + questionnaire = questionnaire, + questionnaireResponse = questionnaireResponse, + StructureMapExtractionContext( + transformSupportServices = transformSupportServices, + structureMapProvider = { _, _ -> + return@StructureMapExtractionContext structureMap + }, + workerContext = fhirProvider.context, + ), + ) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/injection/ServicesInjection.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/injection/ServicesInjection.kt index 419a19d..dca560b 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/services/injection/ServicesInjection.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/injection/ServicesInjection.kt @@ -1,12 +1,18 @@ package org.dtree.fhir.server.services.injection +import org.dtree.fhir.server.services.form.FormService +import org.dtree.fhir.server.services.patient.PatientService import org.dtree.fhir.server.services.stats.StatsService import org.dtree.fhir.server.services.tracing.TracingService +import org.dtree.fhir.server.services.util.UtilService import org.koin.dsl.module object ServicesInjection { val koinBeans = module { + single { FormService } + single { UtilService } single { StatsService } single { TracingService } + single { PatientService } } } \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/patient/PatientService.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/patient/PatientService.kt new file mode 100644 index 0000000..e50c04e --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/patient/PatientService.kt @@ -0,0 +1,13 @@ +package org.dtree.fhir.server.services.patient + +import org.dtree.fhir.core.models.PatientData +import org.dtree.fhir.core.uploader.general.FhirClient +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +object PatientService : KoinComponent { + private val client by inject() + fun fetchPatientActiveResource(patientId: String): PatientData { + return client.fetchAllPatientsActiveItems(patientId) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/stats/StatsService.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/stats/StatsService.kt index 34dc912..8eb3547 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/services/stats/StatsService.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/stats/StatsService.kt @@ -1,9 +1,12 @@ package org.dtree.fhir.server.services.stats import org.dtree.fhir.core.uploader.general.FhirClient +import org.dtree.fhir.server.core.models.FilterFormData import org.dtree.fhir.server.core.search.filters.* import org.dtree.fhir.server.services.PatientType import org.dtree.fhir.server.services.fetchDataTest +import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.Task import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.time.LocalDate @@ -139,12 +142,25 @@ object StatsService : KoinComponent { title = "Viral Load Collected" ) + + val tracingFilters = FilterFormData( + resource = ResourceType.Task.name, + filterId = "tracing_filter", + groupId = "tracing", + title = "Tracing", + filters = listOf(filterAddCount(2000000)) + tracingFiltersByFacility(id), + customParser = { bundle -> + val results = bundle.entry.associateBy { (it.resource as Task).`for`.reference } + results.size + } + ) + return fetchDataTest( client, listOf( newlyDiagnosed, alreadyOnArt, exposedInfants, newExposedInfants, newNewlyDiagnosed, newAlreadyOnArt, allVisits, allVisitsExposed, allVisitsNewly, allVisitsArt, - milestone, viralLoad + milestone, viralLoad, tracingFilters ) ) } diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingService.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingService.kt index 9b7b91d..94719a6 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingService.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingService.kt @@ -1,13 +1,20 @@ package org.dtree.fhir.server.services.tracing +import ca.uhn.fhir.rest.gclient.TokenClientParam import org.dtree.fhir.core.uploader.general.FhirClient +import org.dtree.fhir.core.uploader.general.paginateExecute +import org.dtree.fhir.core.utilities.ReasonConstants +import org.dtree.fhir.core.utilities.SystemConstants +import org.dtree.fhir.core.utils.extractOfficialIdentifier import org.dtree.fhir.core.utils.logicalId import org.dtree.fhir.server.core.models.FilterFormData import org.dtree.fhir.server.core.models.FilterTemplateType -import org.dtree.fhir.server.core.search.filters.* +import org.dtree.fhir.server.core.search.filters.PredefinedFilters +import org.dtree.fhir.server.core.search.filters.filterAddCount +import org.dtree.fhir.server.core.search.filters.filterRevInclude +import org.dtree.fhir.server.core.search.filters.tracingFiltersByFacility import org.dtree.fhir.server.services.QueryParam import org.dtree.fhir.server.services.createFilter -import org.dtree.fhir.server.util.extractOfficialIdentifier import org.hl7.fhir.r4.model.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -16,24 +23,128 @@ import java.time.ZoneId object TracingService : KoinComponent { private val client by inject() + + fun getStats(id: String): TracingStatsResults { - TODO("Not yet implemented") + return TracingStatsResults(getTracingList(id, LocalDate.now()).results.size) } - fun getAppointmentList(facilityId: String, date: LocalDate): AppointmentListResults { - val dateFilter = filterByDate(date) - val locationFilter = filterByLocation(facilityId) + fun getTracingList(facilityId: String, date: LocalDate): TracingListResults { val filter = FilterFormData( - resource = ResourceType.Appointment.name, + resource = ResourceType.Task.name, filterId = "random_filter", - filters = listOf(dateFilter, filterAddCount(20000), filterRevInclude(), locationFilter) + filters = listOf( + filterAddCount(20000), + filterRevInclude("Task:patient"), + ) + tracingFiltersByFacility(facilityId) ) - return fetch(client, listOf(filter)) + val results = fetch(client, listOf(filter)) + val map = mutableMapOf() + results.forEach { result -> + val patient = result.include as Patient + if (map.contains(patient.logicalId)) { + val item = map[patient.logicalId]!! + map[patient.logicalId] = (item.copy(tasks = item.tasks + listOf(result.main as Task))) + } else { + map[patient.logicalId] = Temp(listOf(result.main as Task), patient) + } + } + val allPatientsToFetch = map.keys + val appointmentMap = mutableMapOf() + val appointmentBundle = client.fetchResourcesFromList(allPatientsToFetch.toList()) + appointmentBundle.entry.forEach { entry -> + val appointment = entry.resource as Appointment + if (appointment.hasStart() && appointment.hasParticipant() && (appointment.status == Appointment.AppointmentStatus.BOOKED || + appointment.status == Appointment.AppointmentStatus.WAITLIST || + appointment.status == Appointment.AppointmentStatus.NOSHOW) + ) { + val patient = + appointment.participant.first { it.actor.reference.contains("Patient") }.actor.reference.split("/") + .last() + appointmentMap[patient] = appointment + } + } + return TracingListResults(map.map { entry -> + val mPatient = entry.value.patient + val type = mutableSetOf() + var mDate: LocalDate? = null + val reasons = entry.value.tasks.map { task -> + println(task.logicalId) + task.meta.tag.firstOrNull { tag -> tag.system == "https://d-tree.org/fhir/contact-tracing" }?.code?.let { + type.add(it) + } + mDate = task.authoredOn?.toInstant()?.atZone(ZoneId.systemDefault())?.toLocalDate() + task.reasonCode.text + } + val appointmentDate = appointmentMap[mPatient.logicalId]?.start?.toInstant()?.atZone(ZoneId.systemDefault()) + ?.toLocalDate() + val patientTypes = + mPatient.meta.tag + .filter { it.system == SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM } + .map { it.code } + val patientType: String = SystemConstants.getCodeByPriority(patientTypes) ?: patientTypes.first() + TracingResult( + uuid = mPatient.logicalId, + id = mPatient.extractOfficialIdentifier(), + name = mPatient.nameFirstRep.nameAsSingleString, + dateAdded = mDate, + type = type.toList(), + patientType = patientType, + reasons = reasons, + nextAppointment = appointmentDate, + isFutureAppointment = appointmentDate?.isAfter(LocalDate.now()) + ) + }.distinctBy { it.uuid }) + } + + suspend fun setTracingEnteredInError(patientId: List) { + val tasks: List = client.fhirClient.search().forResource(Task::class.java) + .where(Task.PATIENT.hasAnyOfIds(*patientId.toTypedArray())) + .where( + TokenClientParam("_tag").exactly() + .codings(ReasonConstants.homeTracingCoding, ReasonConstants.phoneTracingCoding) + ) + .where( + Task.STATUS.exactly().codes( + Task.TaskStatus.READY.toCode(), + Task.TaskStatus.INPROGRESS.toCode(), + Task.TaskStatus.REQUESTED.toCode(), + Task.TaskStatus.ACCEPTED.toCode(), + Task.TaskStatus.ONHOLD.toCode() + ) + ) + .paginateExecute(client).mapNotNull { task -> + if (task.status == Task.TaskStatus.COMPLETED || task.status == Task.TaskStatus.CANCELLED || task.status == Task.TaskStatus.ENTEREDINERROR) { + return@mapNotNull null + } + task.meta.addTag( + ReasonConstants.resourceEnteredInError + ) + task.status = Task.TaskStatus.ENTEREDINERROR + task + } + println("Tasks entered in error ${tasks.size}") + if (tasks.isEmpty()) { + println("No Tracing tasks") + return + } + client.bundleUpload(tasks, 30) + } + + suspend fun cleanFutureDateMissedAppointment(facilityId: String) { + val results = getTracingList(facilityId, LocalDate.now()).results.mapNotNull { + if(it.isFutureAppointment == true) it.uuid + else null + } + if (results.isEmpty()) return + setTracingEnteredInError(results) } } -fun fetch(client: FhirClient, actions: List): AppointmentListResults { +data class Temp(val tasks: List, val patient: Patient) + +fun fetch(client: FhirClient, actions: List): MutableList { val requests = mutableListOf() val results = mutableListOf() for (data in actions) { @@ -67,12 +178,7 @@ fun fetch(client: FhirClient, actions: List): AppointmentListRes results.addAll(handleIncludes(resource)) } } - return AppointmentListResults(results.map { - var patient = (it.include as Patient) - val appointment = it.main as Appointment - val date = appointment.start?.toInstant()?.atZone(ZoneId.systemDefault())?.toLocalDate() - Stuff(patient.nameFirstRep.nameAsSingleString, patient.extractOfficialIdentifier(), date) - }) + return results } fun handleIncludes(bundle: Bundle): List { @@ -93,12 +199,13 @@ fun handleIncludes(bundle: Bundle): List { resource.participant.first { it.actor.reference.contains("Patient") }.actor.reference.split("/") .last() includes[patient] = resource.logicalId + } else if (resource is Task) { + val patient = resource.`for`.reference.split("/").last() + includes[patient] = resource.logicalId } } } return final } -data class Stuff(val name: String, val id: String?, val date: LocalDate?) - data class ResultClass(val main: Resource, val include: Resource) \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingStatsResults.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingStatsResults.kt index a25d20a..cc95bff 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingStatsResults.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/tracing/TracingStatsResults.kt @@ -1,8 +1,23 @@ package org.dtree.fhir.server.services.tracing -class TracingStatsResults { -} +import java.time.LocalDate -data class AppointmentListResults( - val results: List +data class TracingStatsResults( + val count: Int +) + +data class TracingResult( + val uuid: String, + val id: String?, + val name: String, + val dateAdded: LocalDate?, + val nextAppointment: LocalDate?, + val type: List, + val reasons: List, + val isFutureAppointment: Boolean?, + val patientType: String +) + +data class TracingListResults( + val results: List ) \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/services/util/UtilService.kt b/server/src/main/kotlin/org/dtree/fhir/server/services/util/UtilService.kt new file mode 100644 index 0000000..764b779 --- /dev/null +++ b/server/src/main/kotlin/org/dtree/fhir/server/services/util/UtilService.kt @@ -0,0 +1,38 @@ +package org.dtree.fhir.server.services.util + +import ca.uhn.fhir.parser.IParser +import io.github.cdimascio.dotenv.Dotenv +import org.dtree.fhir.core.uploader.general.FailedToUploadException +import org.dtree.fhir.core.uploader.general.FhirClient +import org.dtree.fhir.core.utils.readFile +import org.hl7.fhir.r4.model.Bundle +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.Path +import kotlin.io.path.walk + +object UtilService : KoinComponent { + private val dotenv by inject() + private val iParser by inject() + private val fhirClient by inject() + + @OptIn(ExperimentalPathApi::class) + suspend fun runCli() { + val baseDir = dotenv["REPORT_DIR"] ?: return + val allFiles = Path(baseDir).walk() + allFiles.forEach { path -> + try { + val bundleRaw = path.toString().readFile() + val bundle = iParser.parseResource(Bundle::class.java, bundleRaw) + fhirClient.bundleUpload(bundle.entry, 2) + } catch (e: Exception) { + if (e !is FailedToUploadException) { + println("Something else happened, I am dying") + throw e + } + path.toFile().delete() + } + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/org/dtree/fhir/server/util/PatientHelpers.kt b/server/src/main/kotlin/org/dtree/fhir/server/util/PatientHelpers.kt index e52c15e..4b5e493 100644 --- a/server/src/main/kotlin/org/dtree/fhir/server/util/PatientHelpers.kt +++ b/server/src/main/kotlin/org/dtree/fhir/server/util/PatientHelpers.kt @@ -5,34 +5,7 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Patient -fun Patient.extractOfficialIdentifier(): String? { - val patientTypes = - this.meta.tag - .filter { it.system == SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM } - .map { it.code } - val patientType: String? = SystemConstants.getCodeByPriority(patientTypes) - return if (this.hasIdentifier() && patientType != null) { - var actualId: Identifier? = null - var hasNewSystem = false - for (pId in this.identifier) { - if (pId.system?.contains("https://d-tree.org/fhir/patient-identifier") == true) { - hasNewSystem = true - } - if (pId.system == SystemConstants.getIdentifierSystemFromPatientType(patientType)) { - actualId = pId - } - } - if (!hasNewSystem) { - this.identifier - .lastOrNull { it.use == Identifier.IdentifierUse.OFFICIAL && it.system != "WHO-HCID" } - ?.value - } else { - actualId?.value - } - } else { - null - } -} + object SystemConstants { const val REASON_CODE_SYSTEM = "https://d-tree.org/fhir/reason-code"