From 1a5bb44c3ba30ad1afd948f8fc7dadf9582dec53 Mon Sep 17 00:00:00 2001 From: delcroip Date: Tue, 4 Aug 2026 19:51:39 +0200 Subject: [PATCH 01/11] Support CQL initialExpression population on questionnaires Evaluate text/cql-identifier (and text/cql) initialExpression values from linked cqf-library extensions when opening a new questionnaire, and cover the flow with a unit test. --- .../util/extension/QuestionnaireExtension.kt | 15 +- .../questionnaire/QuestionnaireViewModel.kt | 140 ++++++++++++++++++ .../QuestionnaireViewModelTest.kt | 90 +++++++++++ 3 files changed, 243 insertions(+), 2 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt index 40ec4261bbe..2a567941760 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt @@ -56,12 +56,23 @@ fun Questionnaire.extractByStructureMap() = fun Questionnaire.cqfLibraryIds() = this.extension .filter { it.url.contains("cqf-library", ignoreCase = true) } - .mapNotNull { it.value?.asStringValue()?.replace("Library/", "") } + .mapNotNull { it.cqfLibraryValue()?.replace("Library/", "") } fun Questionnaire.cqfLibraryUrls() = this.extension .filter { it.url.contains("cqf-library", ignoreCase = true) } - .mapNotNull { it.value?.asStringValue() } + .mapNotNull { it.cqfLibraryValue() } + +/** Supports valueString and valueCanonical for cqf-library extensions. */ +private fun org.hl7.fhir.r4.model.Extension.cqfLibraryValue(): String? { + val value = this.value ?: return null + return when (value) { + is org.hl7.fhir.r4.model.CanonicalType -> value.value + is org.hl7.fhir.r4.model.UriType -> value.value + is org.hl7.fhir.r4.model.StringType -> value.value + else -> value.asStringValue()?.takeIf { it.isNotBlank() } + } +} fun QuestionnaireResponse.findSubject(bundle: Bundle?) = IdType(this.subject.reference).let { subject -> diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index f9ab40f411e..02cf141d894 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -54,12 +54,14 @@ import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.ListResource.ListEntryComponent import org.hl7.fhir.r4.model.MedicationRequest import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.StructureMap import org.smartregister.fhircore.engine.BuildConfig import org.smartregister.fhircore.engine.configuration.ConfigType @@ -94,6 +96,7 @@ import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuidFromURI import org.smartregister.fhircore.engine.util.extension.find import org.smartregister.fhircore.engine.util.extension.generateMissingId +import org.smartregister.fhircore.engine.util.extension.initialExpression import org.smartregister.fhircore.engine.util.extension.isIn import org.smartregister.fhircore.engine.util.extension.prepopulateWithComputedConfigValues import org.smartregister.fhircore.engine.util.extension.questionnaireResponseStatus @@ -965,6 +968,129 @@ constructor( base.toString() } + /** + * Evaluates CQL-based `initialExpression` extensions (`text/cql-identifier` or `text/cql`) when + * the Questionnaire declares one or more `cqf-library` extensions. + * + * Results are written to [Questionnaire.QuestionnaireItemComponent.initial] and the CQL + * `initialExpression` is removed so [ResourceMapper.populate] can seed the + * [QuestionnaireResponse] from those initials. + */ + private suspend fun evaluateCqlInitialExpressions( + questionnaire: Questionnaire, + launchContextResources: List, + ) { + val cqlLibraryUrls = questionnaire.cqfLibraryUrls() + if (cqlLibraryUrls.isEmpty()) return + + val subject = + launchContextResources.firstOrNull { res -> + questionnaire.subjectType.firstOrNull()?.code.equals(res.resourceType.name, ignoreCase = true) + } ?: launchContextResources.firstOrNull() + + if (subject == null) return + + val expressionSet = mutableSetOf() + collectCqlInitialExpressionItems(questionnaire.item, expressionSet) + if (expressionSet.isEmpty()) return + + val dataBundle = + Bundle().apply { + launchContextResources.forEach { addEntry(Bundle.BundleEntryComponent().setResource(it)) } + } + + val inputParameters = Parameters() + launchContextResources.firstOrNull { it.resourceType == ResourceType.Encounter }?.let { enc -> + inputParameters.addParameter( + Parameters.ParametersParameterComponent().apply { + name = "encounterid" + value = StringType(enc.logicalId) + }, + ) + } + (subject as? Patient)?.let { p -> + inputParameters.addParameter( + Parameters.ParametersParameterComponent().apply { + name = "patient" + value = StringType(p.logicalId) + }, + ) + } + + cqlLibraryUrls.distinct().forEach { libraryUrl -> + runCatching { + val resultParameters = + fhirOperator.evaluateLibrary( + libraryUrl, + subject.asReference().reference, + if (inputParameters.hasParameter()) inputParameters else null, + dataBundle, + expressionSet, + ) as? Parameters + ?: return@forEach + + applyCqlExpressionResultsToInitial(questionnaire.item, resultParameters, expressionSet) + } + .onFailure { e -> + Timber.e(e, "Failed to evaluate CQL initialExpression using library $libraryUrl") + } + } + } + + private fun collectCqlInitialExpressionItems( + items: List, + expressionSet: MutableSet, + ) { + items.forEach { item -> + item.initialExpression?.let { expr -> + if ( + !expr.expression.isNullOrBlank() && + expr.language in CQL_INITIAL_EXPRESSION_LANGUAGES + ) { + expressionSet.add(expr.expression) + } + } + if (item.item.isNotEmpty()) { + collectCqlInitialExpressionItems(item.item, expressionSet) + } + } + } + + private fun applyCqlExpressionResultsToInitial( + items: List, + resultParameters: Parameters, + expressionSet: Set, + ) { + items.forEach { item -> + item.initialExpression?.let { expr -> + val exprName = expr.expression + if ( + !exprName.isNullOrBlank() && + expressionSet.contains(exprName) && + expr.language in CQL_INITIAL_EXPRESSION_LANGUAGES + ) { + val param = resultParameters.getParameter(exprName) + if (param != null) { + val cqlResultValue = (param.value ?: param.resource) as? org.hl7.fhir.r4.model.Type + if (cqlResultValue != null) { + // Avoid ResourceMapper rejecting items that have both initial and initialExpression. + item.removeExtension(org.smartregister.fhircore.engine.util.extension.EXTENSION_INITIAL_EXPRESSION_URL) + item.initial = + mutableListOf( + Questionnaire.QuestionnaireItemInitialComponent().apply { + value = cqlResultValue + }, + ) + } + } + } + } + if (item.item.isNotEmpty()) { + applyCqlExpressionResultsToInitial(item.item, resultParameters, expressionSet) + } + } + } + /** * This function generates CarePlans for the [QuestionnaireResponse.subject] using the configured * [QuestionnaireConfig.planDefinitions] @@ -1188,6 +1314,19 @@ constructor( }, ) + // Apply CQL initialExpression defaults before ResourceMapper.populate so they become QR answers. + // Skip when reopening a saved/editable/draft response (saved answers take precedence). + val willLoadSavedResponse = + resourceType != null && + !resourceIdentifier.isNullOrEmpty() && + (questionnaireConfig.isEditable() || + questionnaireConfig.isReadOnly() || + questionnaireConfig.isSummary() || + questionnaireConfig.saveDraft) + if (!willLoadSavedResponse) { + evaluateCqlInitialExpressions(questionnaire, launchContextResources) + } + // Populate questionnaire with latest QuestionnaireResponse and initial default values val questionnaireResponse = fetchRepositoryQuestionnaireResponse( @@ -1375,6 +1514,7 @@ constructor( const val CONTAINED_LIST_TITLE = "GeneratedResourcesList" const val OUTPUT_PARAMETER_KEY = "OUTPUT" const val DELIMITER = "," + private val CQL_INITIAL_EXPRESSION_LANGUAGES = setOf("text/cql-identifier", "text/cql") } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt index ab231abbbfa..f4cd0744aeb 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -2073,6 +2073,96 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertTrue((result.item.single().answerFirstRep.value as DateType).isToday) } + @Test + fun testPopulateQuestionnaireEvaluatesCqlBasedInitialExpression() = runTest { + val questionnaireViewModelInstance = + QuestionnaireViewModel( + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + fhirCarePlanGenerator = fhirCarePlanGenerator, + rulesExecutor = rulesExecutor, + transformSupportServices = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, + fhirOperator = fhirOperator, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, + fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, + ) + val cqlIdentifier = "hasChronicCondition" + val questionnaireWithCqlInitExpr = + Questionnaire().apply { + id = questionnaireConfig.id + addExtension( + Extension( + "http://hl7.org/fhir/StructureDefinition/cqf-library", + StringType("http://example.org/Library/test-cql-lib|1.0.0"), + ), + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "chronicCondition" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + addExtension( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + Expression().apply { + language = "text/cql-identifier" + expression = cqlIdentifier + }, + ), + ) + }, + ) + } + + // New questionnaire (not edit/draft) so CQL initialExpression population runs. + val thisConfig = + questionnaireConfig.copy( + resourceType = ResourceType.Patient, + resourceIdentifier = "patient-1", + type = QuestionnaireType.DEFAULT.name, + ) + + coEvery { fhirEngine.get(ResourceType.Questionnaire, thisConfig.id) } returns + questionnaireWithCqlInitExpr + coEvery { defaultRepository.loadResource("patient-1", ResourceType.Patient) } returns patient + + val cqlResultParams = + Parameters().apply { + addParameter( + Parameters.ParametersParameterComponent().apply { + name = cqlIdentifier + value = BooleanType(true) + }, + ) + } + coEvery { fhirOperator.evaluateLibrary(any(), any(), any(), any(), any()) } returns + cqlResultParams + + questionnaireViewModelInstance.populateQuestionnaire( + questionnaireWithCqlInitExpr, + thisConfig, + emptyList(), + ) + + val initial = + questionnaireWithCqlInitExpr.item + .first { it.linkId == "chronicCondition" } + .initial + .firstOrNull() + ?.value + Assert.assertTrue(initial is BooleanType && (initial as BooleanType).booleanValue()) + coVerify { + fhirOperator.evaluateLibrary( + any(), + any(), + any(), + any(), + any(), + ) + } + } + @Test fun testThatPopulateQuestionnaireReturnsQuestionnaireResponseWithUnAnsweredRemoved() = runTest { val questionnaireViewModelInstance = From 756ad2cbcdbb07735a6bba164a251194dbaa774f Mon Sep 17 00:00:00 2001 From: delcroip Date: Fri, 7 Aug 2026 08:17:25 +0200 Subject: [PATCH 02/11] chore: adding changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c1828cbd6..a1fc0aa0cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Evaluate CQL-based `initialExpression` (`text/cql-identifier` and `text/cql`) on Questionnaire items using the linked `cqf-library` Library, so CQL-derived defaults prepopulate answers the same way FHIRPath `initialExpression` already does. Skipped when reopening a saved/editable/read-only/summary/draft response. See `android/feature/cql-initial-expression.md`. + ## [1.1.1] - 2024-05-20 ### Added From ec1b7ee8cf361423beaf525fc36130874d53c0e5 Mon Sep 17 00:00:00 2001 From: delcroip Date: Fri, 7 Aug 2026 08:48:50 +0200 Subject: [PATCH 03/11] feat: register all client, PD based applicable care --- .gitignore | 22 + .../workflow/ApplicationWorkflow.kt | 8 + .../data/local/register/RegisterRepository.kt | 65 +++ .../engine/rulesengine/RulesExecutor.kt | 41 ++ .../task/NamedEventInterventionService.kt | 319 +++++++++++ .../engine/task/WorkflowCarePlanGenerator.kt | 41 +- .../util/extension/RelatedPersonAsPatient.kt | 205 +++++++ .../extension/RelatedPersonAsPatientTest.kt | 141 +++++ .../client-register-applicable-care.md | 172 ++++++ android/feature/register-tricc.md | 539 ++++++++++++++++++ .../model/RegisterPagingSourceState.kt | 3 + .../di/NamedEventInterventionEntryPoint.kt | 28 + .../quest/ui/register/RegisterViewModel.kt | 3 + .../quest/util/extensions/ConfigExtensions.kt | 108 ++++ 14 files changed, 1685 insertions(+), 10 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt create mode 100644 android/feature/client-register-applicable-care.md create mode 100644 android/feature/register-tricc.md create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt diff --git a/.gitignore b/.gitignore index 132dccfa9c2..f641054064d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,20 @@ .DS_Store node_modules/ +# Generated by docker compose init-setup (do not commit) +conf/.setup.lock +conf/hapi/application.yaml +conf/glitchtip/.setup.lock +conf/glitchtip/public-dsn.txt +conf/glitchtip/public-dsn.host.txt +conf/glitchtip/public-dsn.android.txt +conf/glitchtip/public-dsn.*.txt +conf/glitchtip/dsn.env +conf/glitchtip/env.hapi +conf/glitchtip/env.gateway +target/ + + # Gradle files .gradle/ build/ @@ -41,3 +55,11 @@ android/quest/src/main/assets/resources/echis/ # Claude Code documentation (local only) CLAUDE.md android/CLAUDE.md + +.env + +# Traefik TLS material +conf/traefik/certs/ +conf/traefik/acme.json +# Rendered from dynamic.yml.template +conf/traefik/dynamic.yml diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt index ff0b1288844..cb26464994e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt @@ -71,4 +71,12 @@ enum class ApplicationWorkflow { /** A workflow that launches an external application via Intent */ LAUNCH_EXTERNAL_APP, + + /** + * Discovers PlanDefinitions with a configured named-event trigger (e.g. `available-care`), runs + * `$apply` so applicability conditions are evaluated, and presents RequestGroup recommendations + * for the user to start an intervention. Intervention catalog is synced FHIR content — not + * hardcoded in the app. See `feature/register-tricc.md`. + */ + APPLY_NAMED_EVENT, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt index d0cd0656dee..befe159ca55 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt @@ -19,10 +19,15 @@ package org.smartregister.fhircore.engine.data.local.register import android.content.Context import ca.uhn.fhir.parser.IParser import com.google.android.fhir.FhirEngine +import com.google.android.fhir.datacapture.extensions.logicalId +import com.google.android.fhir.get import com.google.android.fhir.search.Search import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.RelatedPerson +import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry @@ -37,7 +42,13 @@ import org.smartregister.fhircore.engine.domain.repository.Repository import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.DEPENDENT_CHILDREN_RESOURCE_KEY +import org.smartregister.fhircore.engine.util.extension.DEPENDENT_RELATED_PERSONS_RESOURCE_KEY +import org.smartregister.fhircore.engine.util.extension.childPatientId +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid +import org.smartregister.fhircore.engine.util.extension.groupByGuardianPatientId import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import timber.log.Timber @Singleton class RegisterRepository @@ -98,9 +109,61 @@ constructor( repositoryResourceDataList = repositoryResourceDataList, ) + enrichDependentChildrenFromRelatedPersons(repositoryResourceDataList) + return repositoryResourceDataList } + /** + * For TRICC flexible client registers: mother/father/guardian are Patients; kids are nested via + * RelatedPerson (`patient` = child, `identifier` = guardian Patient URL). + * + * Populates [DEPENDENT_CHILDREN_RESOURCE_KEY] and [DEPENDENT_RELATED_PERSONS_RESOURCE_KEY] on + * each Patient row so register LIST views can render dependents. No-op when no matching + * RelatedPersons exist. + * + * See `feature/register-tricc.md`. + */ + suspend fun enrichDependentChildrenFromRelatedPersons( + repositoryResourceDataList: List, + ) { + val patientRows = repositoryResourceDataList.filter { it.resource is Patient } + if (patientRows.isEmpty()) return + + val allRelatedPersons = + runCatching { search(Search(ResourceType.RelatedPerson)) } + .onFailure { Timber.e(it, "Failed to load RelatedPerson for dependent enrichment") } + .getOrDefault(emptyList()) + if (allRelatedPersons.isEmpty()) return + + val byGuardian = allRelatedPersons.groupByGuardianPatientId() + if (byGuardian.isEmpty()) return + + val childIds = byGuardian.values.flatten().mapNotNull { it.childPatientId() }.distinct() + val childrenById = + childIds + .mapNotNull { childId -> + runCatching { fhirEngine.get(childId) } + .onFailure { + Timber.w(it, "Dependent child Patient/$childId not found for register nest") + } + .getOrNull() + ?.let { childId to it } + } + .toMap() + + patientRows.forEach { row -> + val guardianId = row.resource.logicalId.extractLogicalIdUuid() + val relatedPersonsForGuardian = byGuardian[guardianId].orEmpty() + if (relatedPersonsForGuardian.isEmpty()) return@forEach + + val dependentChildren = + relatedPersonsForGuardian.mapNotNull { rp -> rp.childPatientId()?.let { childrenById[it] } } + row.relatedResourcesMap[DEPENDENT_RELATED_PERSONS_RESOURCE_KEY] = relatedPersonsForGuardian + row.relatedResourcesMap[DEPENDENT_CHILDREN_RESOURCE_KEY] = dependentChildren + } + } + /** Count register data for the provided [registerId]. Use the configured base resource filters */ override suspend fun countRegisterData( registerId: String, @@ -173,6 +236,8 @@ constructor( configComputedRuleValues = configComputedRuleValues, repositoryResourceDataList = repositoryResourceDataList, ) + // Same RelatedPerson → dependent children join as registers (parent profile shows kids) + enrichDependentChildrenFromRelatedPersons(repositoryResourceDataList) return repositoryResourceDataList.firstOrNull() } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutor.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutor.kt index 109343f9e2c..fa98be9b5fa 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutor.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutor.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.engine.rulesengine import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateMap import com.google.android.fhir.datacapture.extensions.logicalId @@ -64,6 +65,46 @@ class RulesExecutor @Inject constructor(val rulesFactory: RulesFactory) { ) } + /** + * Like [processResourceData], then pre-computes nested [ListProperties] into a plain + * [ResourceData.listResourceDataMap] suitable for register paging (where Compose SnapshotStateMap + * is not shared across rows). + */ + fun processResourceDataWithLists( + repositoryResourceData: RepositoryResourceData, + rules: Rules, + params: Map?, + listProperties: List, + ): ResourceData { + val resourceData = + processResourceData( + repositoryResourceData = repositoryResourceData, + rules = rules, + params = params, + ) + if (listProperties.isEmpty()) return resourceData + + val listResourceDataStateMap = mutableStateMapOf>() + val paramsMap = params ?: emptyMap() + listProperties.forEach { listConfig -> + processListResourceData( + listProperties = listConfig, + relatedResourcesMap = repositoryResourceData.relatedResourcesMap, + computedValuesMap = + if (paramsMap.isNotEmpty()) { + resourceData.computedValuesMap.plus(paramsMap) + } else { + resourceData.computedValuesMap + }, + listResourceDataStateMap = listResourceDataStateMap, + ) + } + + val listResourceDataMap = + listResourceDataStateMap.mapValues { (_, snapshotList) -> snapshotList.toList() } + return resourceData.copy(listResourceDataMap = listResourceDataMap) + } + /** * This function pre-computes all the Rules for [ViewType]'s of List including list nested in the * views. The LIST view computed values includes the parent's. Every list identified by diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt new file mode 100644 index 00000000000..ad93456770b --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.task + +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.datacapture.extensions.logicalId +import com.google.android.fhir.get +import com.google.android.fhir.search.Search +import javax.inject.Inject +import javax.inject.Singleton +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.PlanDefinition +import org.hl7.fhir.r4.model.RequestGroup +import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.TriggerDefinition +import org.hl7.fhir.r4.utils.FHIRPathEngine +import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.batchedSearch +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid +import timber.log.Timber + +/** + * Discovers synced PlanDefinitions by named-event trigger, evaluates applicability (trigger + + * conditions), and returns intervention options for the user to pick. Catalog is FHIR content only + * — see `feature/register-tricc.md`. + */ +@Singleton +class NamedEventInterventionService +@Inject +constructor( + private val fhirEngine: FhirEngine, + private val fhirPathEngine: FHIRPathEngine, + private val workflowCarePlanGenerator: WorkflowCarePlanGenerator, +) { + + data class InterventionOption( + val id: String, + val title: String, + val description: String? = null, + val definitionCanonical: String? = null, + val planDefinitionId: String? = null, + val questionnaireId: String? = null, + ) + + /** + * Returns applicable interventions for [subjectPatientId] whose PlanDefinition actions declare + * named-event [namedEvent] (default `available-care`). + * + * Prefers evaluating action.condition with FHIRPath (no side effects). Falls back to workflow + * `$apply` when RequestGroup recommendations are needed or conditions use non-FHIRPath languages. + * This is a read-only discovery/preview operation: the `$apply` fallback runs with + * `persist = false`, so browsing for applicable care never writes Task/RequestGroup/CarePlan + * resources to the local database — only actually starting an intervention should do that. + */ + suspend fun listInterventions( + namedEvent: String, + subjectPatientId: String, + ): List { + val patientId = subjectPatientId.extractLogicalIdUuid() + val patient = + runCatching { fhirEngine.get(patientId) } + .onFailure { Timber.e(it, "Patient/$patientId not found for named-event apply") } + .getOrNull() ?: return emptyList() + + val planDefinitions = loadPlanDefinitions() + if (planDefinitions.isEmpty()) { + Timber.w("No PlanDefinitions in local store for named-event '$namedEvent'") + return emptyList() + } + + val matching = planDefinitions.filter { it.hasNamedEventTrigger(namedEvent) } + if (matching.isEmpty()) { + Timber.i("No PlanDefinitions with named-event '$namedEvent'") + return emptyList() + } + + val options = linkedMapOf() + + matching.forEach { planDefinition -> + collectFromPlanDefinition( + planDefinition = planDefinition, + namedEvent = namedEvent, + patient = patient, + options = options, + ) + } + + return options.values.toList() + } + + private suspend fun loadPlanDefinitions(): List { + return runCatching { + fhirEngine.batchedSearch(Search(ResourceType.PlanDefinition)).map { + it.resource + } + } + .onFailure { Timber.e(it, "Failed to search PlanDefinitions") } + .getOrDefault(emptyList()) + } + + private suspend fun collectFromPlanDefinition( + planDefinition: PlanDefinition, + namedEvent: String, + patient: Patient, + options: MutableMap, + ) { + val actionsWithEvent = planDefinition.action.filter { it.hasNamedEventTrigger(namedEvent) } + val actionsToEvaluate = + if (actionsWithEvent.isNotEmpty()) { + // Include nested actions under matching top-level actions (strategy PD) + actionsWithEvent.flatMap { parent -> + if (parent.action.isNullOrEmpty()) listOf(parent) else parent.action + } + } else { + emptyList() + } + + val needsApply = + actionsToEvaluate.any { action -> + action.condition.any { + it.hasExpression() && + it.expression.language != Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() + } + } + + if (needsApply) { + collectFromWorkflowApply(planDefinition, patient, options) + return + } + + actionsToEvaluate.forEach { action -> + if (!action.passesFhirPathConditions(patient)) return@forEach + val option = action.toInterventionOption(planDefinition) + if (option != null) { + options.putIfAbsent(option.id, option) + } + } + + // Nested strategy actions may not carry the named-event themselves; if top-level matched and + // we only evaluated children via flatMap above, also try apply when no options yet. + if (options.isEmpty() && planDefinition.action.any { it.action.isNotEmpty() }) { + collectFromWorkflowApply(planDefinition, patient, options) + } + } + + private suspend fun collectFromWorkflowApply( + planDefinition: PlanDefinition, + patient: Patient, + options: MutableMap, + ) { + runCatching { + val carePlan = + CarePlan().apply { + status = CarePlan.CarePlanStatus.DRAFT + intent = CarePlan.CarePlanIntent.PROPOSAL + subject = patient.asReference() + } + workflowCarePlanGenerator.applyPlanDefinitionOnPatient( + planDefinition = planDefinition, + patient = patient, + data = Bundle(), + output = carePlan, + persist = false, + ) + carePlan.contained.filterIsInstance().forEach { requestGroup -> + requestGroup.action.forEach { rgAction -> + val option = rgAction.toInterventionOption(planDefinition) + if (option != null) options.putIfAbsent(option.id, option) + } + } + // Fallback: activities with descriptions + if (options.isEmpty()) { + carePlan.activity.forEachIndexed { index, activity -> + val title = + activity.detail?.description + ?: activity.detail?.code?.codingFirstRep?.display + ?: planDefinition.title + ?: planDefinition.name + ?: "Intervention ${index + 1}" + val id = "${planDefinition.logicalId}-activity-$index" + options.putIfAbsent( + id, + InterventionOption( + id = id, + title = title, + description = activity.detail?.description, + planDefinitionId = planDefinition.logicalId, + ), + ) + } + } + } + .onFailure { + Timber.e(it, "Workflow \$apply failed for PlanDefinition/${planDefinition.logicalId}") + } + } + + private fun PlanDefinition.PlanDefinitionActionComponent.passesFhirPathConditions( + patient: Patient, + ): Boolean { + if (condition.isNullOrEmpty()) return true + return condition.all { conditionComponent -> + if (conditionComponent.kind != PlanDefinition.ActionConditionKind.APPLICABILITY) { + return@all true + } + if (!conditionComponent.hasExpression()) return@all true + val language = conditionComponent.expression.language + if (language != Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode()) { + Timber.w("Skipping non-FHIRPath condition language=$language") + return@all false + } + runCatching { + fhirPathEngine.evaluateToBoolean( + null, + null, + patient, + conditionComponent.expression.expression, + ) + } + .onFailure { + Timber.e(it, "Applicability FHIRPath failed: ${conditionComponent.expression.expression}") + } + .getOrDefault(false) + } + } + + private fun PlanDefinition.PlanDefinitionActionComponent.toInterventionOption( + planDefinition: PlanDefinition, + ): InterventionOption? { + val title = title ?: description ?: planDefinition.title ?: planDefinition.name ?: return null + val definition = + when { + hasDefinitionCanonicalType() -> definitionCanonicalType.value + hasDefinitionUriType() -> definitionUriType.valueAsString + else -> null + } + val questionnaireId = + definition + ?.substringAfter("Questionnaire/", missingDelimiterValue = "") + ?.takeIf { definition.contains("Questionnaire/") && it.isNotBlank() } + ?.extractLogicalIdUuid() + val nestedPlanId = + definition + ?.substringAfter("PlanDefinition/", missingDelimiterValue = "") + ?.takeIf { definition.contains("PlanDefinition/") && it.isNotBlank() } + ?.extractLogicalIdUuid() + val id = id ?: definition ?: "${planDefinition.logicalId}-${title.hashCode()}" + return InterventionOption( + id = id, + title = title, + description = description, + definitionCanonical = definition, + planDefinitionId = nestedPlanId ?: planDefinition.logicalId, + questionnaireId = questionnaireId, + ) + } + + private fun RequestGroup.RequestGroupActionComponent.toInterventionOption( + planDefinition: PlanDefinition, + ): InterventionOption? { + val title = title ?: description ?: return null + val resourceRef = resource?.reference + val definition = resourceRef + val questionnaireId = + resourceRef + ?.substringAfter("Questionnaire/", missingDelimiterValue = "") + ?.takeIf { resourceRef.contains("Questionnaire/") && it.isNotBlank() } + ?.extractLogicalIdUuid() + val planId = + resourceRef + ?.substringAfter("PlanDefinition/", missingDelimiterValue = "") + ?.takeIf { resourceRef.contains("PlanDefinition/") && it.isNotBlank() } + ?.extractLogicalIdUuid() + val id = this.id ?: definition ?: "${planDefinition.logicalId}-${title.hashCode()}" + return InterventionOption( + id = id, + title = title, + description = description, + definitionCanonical = definition, + planDefinitionId = planId ?: planDefinition.logicalId, + questionnaireId = questionnaireId, + ) + } + + private fun PlanDefinition.hasNamedEventTrigger(namedEvent: String): Boolean { + return action.any { it.hasNamedEventTrigger(namedEvent) } + } + + private fun PlanDefinition.PlanDefinitionActionComponent.hasNamedEventTrigger( + namedEvent: String, + ): Boolean { + if ( + trigger.any { + it.type == TriggerDefinition.TriggerType.NAMEDEVENT && + it.name.equals(namedEvent, ignoreCase = true) + } + ) { + return true + } + return action.any { it.hasNamedEventTrigger(namedEvent) } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt index 641edb2f42d..00029163097 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt @@ -66,12 +66,17 @@ constructor( * @param patient Patient resource for which the [PlanDefinition] $apply is run * @param data Bundle resource containing the input resource/data * @param output [CarePlan] resource object with the generated care plan + * @param persist Whether the request resources proposed by $apply (Task, RequestGroup, etc.) + * should be written to the local database. Set to `false` for read-only previews/discovery + * (e.g. listing applicable interventions) so browsing does not create duplicate resources; the + * proposed resources are still attached to [output] either way. */ suspend fun applyPlanDefinitionOnPatient( planDefinition: PlanDefinition, patient: Patient, data: Bundle = Bundle(), output: CarePlan, + persist: Boolean = true, ) { withContext(Dispatchers.IO) { val carePlanProposal = @@ -81,7 +86,7 @@ constructor( data, ) as CarePlan - acceptCarePlan(carePlanProposal, output) + acceptCarePlan(carePlanProposal, output, persist) resolveDynamicValues( planDefinition = planDefinition, @@ -191,6 +196,15 @@ constructor( "DiagnosticReport" -> TODO("Not supported yet") "Communication" -> TODO("Not supported yet") "CommunicationRequest" -> TODO("Not supported yet") + "CarePlan", + "RequestGroup", -> { + // Attach as a contained resource (in addition to an activity reference) so callers can + // read it straight off `carePlan.contained` — e.g. named-event intervention discovery + // reads the RequestGroup produced by $apply this way (see + // feature/client-register-applicable-care.md). + carePlan.addContained(resource) + carePlan.addActivity().setReference(Reference(resource)) + } else -> TODO("Not a valid request resource ${resource.fhirType()}") } } @@ -201,10 +215,14 @@ constructor( * proposed [CarePlan] * * @param resourceList List of request resources to be created - * @param requestResourceConfigs Application-specific configurations to be applied on the created - * request resources + * @param persist Whether matched resources should be persisted via [DefaultRepository]. When + * `false` this only classifies/returns the resources for linking onto the CarePlan of record, + * without writing anything to the database (used for read-only previews). */ - private suspend fun createProposedRequestResources(resourceList: List): List { + private suspend fun createProposedRequestResources( + resourceList: List, + persist: Boolean, + ): List { val createdRequestResources = ArrayList() for (resource in resourceList) { when (resource.fhirType()) { @@ -212,8 +230,11 @@ constructor( "QuestionnaireResponse", "OperationOutcome", "MedicationRequest", - "CarePlan", -> { - defaultRepository.create(true, resource) + "CarePlan", + "RequestGroup", -> { + if (persist) { + defaultRepository.create(true, resource) + } createdRequestResources.add(resource) } "ServiceRequest" -> TODO("Not supported yet") @@ -222,7 +243,6 @@ constructor( "DiagnosticReport" -> TODO("Not supported yet") "Communication" -> TODO("Not supported yet") "CommunicationRequest" -> TODO("Not supported yet") - "RequestGroup" -> {} else -> TODO("Not a valid request resource ${resource.fhirType()}") } } @@ -236,14 +256,15 @@ constructor( * @param proposedCarePlan Proposed [CarePlan] generated when $apply is run on a [PlanDefinition] * @param carePlanOfRecord CarePlan of record for a [Patient] which needs to be updated with the * new request resources created as per the proposed CarePlan - * @param requestResourceConfigs Application-specific configurations to be applied on the created - * request resources + * @param persist Whether the proposed request resources should be persisted; see + * [applyPlanDefinitionOnPatient]. */ private suspend fun acceptCarePlan( proposedCarePlan: CarePlan, carePlanOfRecord: CarePlan, + persist: Boolean, ) { - val resourceList = createProposedRequestResources(proposedCarePlan.contained) + val resourceList = createProposedRequestResources(proposedCarePlan.contained, persist) addRequestResourcesToCarePlanOfRecord(carePlanOfRecord, resourceList) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt new file mode 100644 index 00000000000..1bff4a7e553 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util.extension + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.RelatedPerson +import org.hl7.fhir.r4.model.Resource + +/** + * TRICC / flexible client register convention: mother, father and guardian always exist as + * [org.hl7.fhir.r4.model.Patient] clients. [RelatedPerson] links them to a child: + * - [RelatedPerson.patient] → child Patient + * - [RelatedPerson.relationship] → mother | father | guardian (e.g. RoleCode `MTH` / `FTH` / + * RoleClass `GUARD`) + * - [RelatedPerson.identifier] → guardian / mother / father **Patient URL** (who this related + * person is as a registered client), preferably typed `PI` and `use=secondary` + * + * See `feature/register-tricc.md` and + * [RelatedPerson.identifier](https://build.fhir.org/relatedperson-definitions.html#RelatedPerson.identifier). + */ + +/** + * Prefer absolute URI identifiers ([FHIR Identifier with URI value](https://hl7.org/fhir/datatypes.html#Identifier)). + * Value holds a Patient reference or absolute Patient URL, e.g. `Patient/marie` or + * `https://fhir.example.org/Patient/marie`. + */ +const val RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM = "urn:ietf:rfc:3986" + +/** HL7 Identifier Type Codes (v2-0203) — Patient internal identifier. */ +const val IDENTIFIER_TYPE_SYSTEM_V2_0203 = "http://terminology.hl7.org/CodeSystem/v2-0203" + +/** Patient internal identifier — preferred type for the linked client Patient URL. */ +const val IDENTIFIER_TYPE_PI = "PI" + +/** + * Patient external identifier — accepted as a weaker alternative when authors used `PT` instead of + * `PI` for the same Patient URL link. + */ +const val IDENTIFIER_TYPE_PT = "PT" + +/** + * Map key used in + * [org.smartregister.fhircore.engine.domain.model.RepositoryResourceData.relatedResourcesMap] for + * nested dependent children. + */ +const val DEPENDENT_CHILDREN_RESOURCE_KEY = "dependentChildren" + +/** + * Map key for RelatedPerson rows that define dependent children of the current guardian Patient. + */ +const val DEPENDENT_RELATED_PERSONS_RESOURCE_KEY = "dependentRelatedPersons" + +/** + * Returns the guardian / mother / father Patient reference (`Patient/{id}`) from + * [RelatedPerson.identifier], or null if none is present. + * + * Resolution order: + * 1. Identifier with type `PI` (or `PT`) and a Patient URL/ref value + * 2. Identifier with [RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM] and a Patient URL/ref value + * 3. Any other identifier whose value looks like a Patient URL or `Patient/{id}` + */ +fun RelatedPerson.guardianPatientReference(): String? { + val typed = + identifier + .firstOrNull { + it.hasPatientLinkIdentifierType() && + it.value.patientReferenceFromIdentifierValue() != null + } + ?.value + ?.patientReferenceFromIdentifierValue() + if (typed != null) return typed + + val preferredSystem = + identifier + .firstOrNull { + it.system == RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM && + it.value.patientReferenceFromIdentifierValue() != null + } + ?.value + ?.patientReferenceFromIdentifierValue() + if (preferredSystem != null) return preferredSystem + + return identifier + .asSequence() + .mapNotNull { it.value?.patientReferenceFromIdentifierValue() } + .firstOrNull() +} + +/** True when this identifier is typed as patient internal/external id (`PI` / `PT`). */ +fun Identifier.hasPatientLinkIdentifierType(): Boolean { + return type?.coding?.any { + it.system == IDENTIFIER_TYPE_SYSTEM_V2_0203 && + (it.code.equals(IDENTIFIER_TYPE_PI, ignoreCase = true) || + it.code.equals(IDENTIFIER_TYPE_PT, ignoreCase = true)) + } == true +} + +/** True when this RelatedPerson links [guardianPatientId] as mother/father/guardian of a child. */ +fun RelatedPerson.isDependentOfGuardian(guardianPatientId: String): Boolean { + val expected = "Patient/${guardianPatientId.extractLogicalIdUuid()}" + val ref = guardianPatientReference()?.extractLogicalIdUuid()?.let { "Patient/$it" } + return ref != null && ref.equals(expected, ignoreCase = true) +} + +/** Logical id of the child Patient referenced by [RelatedPerson.patient], or null. */ +fun RelatedPerson.childPatientId(): String? = + patient?.reference?.extractLogicalIdUuid()?.takeIf { it.isNotBlank() } + +/** + * Builds an [Identifier] that stores the guardian Patient as a URL / relative reference in + * [Identifier.value]. + * + * Default shape (recommended): + * - `type` = v2-0203 **`PI`** (Patient internal identifier) + * - `use` = **`secondary`** (structural link; leaves room for official/usual national IDs) + * - `system` = [RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM] + * - `value` = `Patient/{id}` or absolute Patient URL + */ +fun patientUrlIdentifier( + patientIdOrReference: String, + use: Identifier.IdentifierUse = Identifier.IdentifierUse.SECONDARY, + typeCode: String = IDENTIFIER_TYPE_PI, +): Identifier { + val reference = + patientIdOrReference.patientReferenceFromIdentifierValue() + ?: "Patient/${patientIdOrReference.extractLogicalIdUuid()}" + return Identifier().apply { + this.use = use + type = + CodeableConcept() + .addCoding( + Coding(IDENTIFIER_TYPE_SYSTEM_V2_0203, typeCode, patientLinkTypeDisplay(typeCode)), + ) + system = RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM + value = reference + } +} + +private fun patientLinkTypeDisplay(typeCode: String): String = + when (typeCode.uppercase()) { + IDENTIFIER_TYPE_PI -> "Patient internal identifier" + IDENTIFIER_TYPE_PT -> "Patient external identifier" + else -> typeCode + } + +/** + * Groups [relatedPersons] by guardian Patient logical id using + * [RelatedPerson.guardianPatientReference]. + */ +fun List.groupByGuardianPatientId(): Map> { + return mapNotNull { rp -> + val guardianId = + rp.guardianPatientReference()?.extractLogicalIdUuid() ?: return@mapNotNull null + guardianId to rp + } + .groupBy({ it.first }, { it.second }) +} + +/** From a mixed list of [Resource], returns only [RelatedPerson] instances. */ +fun List.asRelatedPersons(): List = mapNotNull { it as? RelatedPerson } + +/** + * Parses [this] as a Patient reference or absolute Patient URL into normalized `Patient/{id}`. + * + * Accepts: + * - `Patient/marie` + * - `https://example.org/fhir/Patient/marie` + * - `https://example.org/fhir/Patient/marie/_history/1` + */ +fun String.patientReferenceFromIdentifierValue(): String? { + val trimmed = trim() + if (trimmed.isEmpty()) return null + + val afterPatient = + when { + trimmed.contains("Patient/", ignoreCase = true) -> + trimmed.substringAfter("Patient/", "").substringAfter("patient/", "") + else -> return null + } + val logicalId = + afterPatient + .substringBefore("/") + .substringBefore("?") + .substringBefore("#") + .trim() + .takeIf { it.isNotBlank() } + ?: return null + return "Patient/$logicalId" +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt new file mode 100644 index 00000000000..e93307a956c --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util.extension + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.RelatedPerson +import org.junit.Assert +import org.junit.Test + +class RelatedPersonAsPatientTest { + + @Test + fun guardianPatientReference_readsIdentifierPatientUrl() { + val rp = + RelatedPerson().apply { + patient = Reference("Patient/child-1") + addRelationship( + CodeableConcept() + .addCoding( + Coding( + "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "MTH", + "mother", + ), + ), + ) + addIdentifier(patientUrlIdentifier("Patient/mother-1")) + } + + val linkId = rp.identifierFirstRep + Assert.assertEquals(Identifier.IdentifierUse.SECONDARY, linkId.use) + Assert.assertEquals(IDENTIFIER_TYPE_PI, linkId.type.codingFirstRep.code) + Assert.assertEquals(RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM, linkId.system) + Assert.assertEquals("Patient/mother-1", rp.guardianPatientReference()) + Assert.assertTrue(rp.isDependentOfGuardian("mother-1")) + Assert.assertFalse(rp.isDependentOfGuardian("other")) + Assert.assertEquals("child-1", rp.childPatientId()) + } + + @Test + fun guardianPatientReference_prefersPiTypedIdentifier() { + val rp = + RelatedPerson().apply { + patient = Reference("Patient/child-1") + // National ID (not a Patient URL) — should not win over typed PI link + addIdentifier( + Identifier().apply { + use = Identifier.IdentifierUse.OFFICIAL + system = "http://example.org/national-id" + value = "123456789" + }, + ) + addIdentifier(patientUrlIdentifier("Patient/mother-1")) + } + + Assert.assertEquals("Patient/mother-1", rp.guardianPatientReference()) + } + + @Test + fun guardianPatientReference_acceptsAbsolutePatientUrl() { + val rp = + RelatedPerson().apply { + patient = Reference("Patient/child-1") + addIdentifier( + Identifier().apply { + use = Identifier.IdentifierUse.SECONDARY + type = + CodeableConcept() + .addCoding( + Coding(IDENTIFIER_TYPE_SYSTEM_V2_0203, IDENTIFIER_TYPE_PT, "Patient external identifier"), + ) + system = RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM + value = "https://fhir.example.org/Patient/mother-1" + }, + ) + } + + Assert.assertEquals("Patient/mother-1", rp.guardianPatientReference()) + } + + @Test + fun groupByGuardianPatientId_groupsRelatedPersons() { + val rp1 = + RelatedPerson().apply { + id = "rp1" + patient = Reference("Patient/child-a") + addIdentifier(patientUrlIdentifier("Patient/parent-1")) + } + val rp2 = + RelatedPerson().apply { + id = "rp2" + patient = Reference("Patient/child-b") + addIdentifier(patientUrlIdentifier("Patient/parent-1")) + } + val rpOther = + RelatedPerson().apply { + id = "rp3" + patient = Reference("Patient/child-c") + addIdentifier(patientUrlIdentifier("https://example.org/fhir/Patient/parent-2")) + } + + val grouped = listOf(rp1, rp2, rpOther).groupByGuardianPatientId() + Assert.assertEquals(2, grouped.size) + Assert.assertEquals(2, grouped["parent-1"]?.size) + Assert.assertEquals(1, grouped["parent-2"]?.size) + } + + @Test + fun patientReferenceFromIdentifierValue_parsesVariants() { + Assert.assertEquals( + "Patient/marie", + "Patient/marie".patientReferenceFromIdentifierValue(), + ) + Assert.assertEquals( + "Patient/marie", + "https://fhir.example.org/Patient/marie".patientReferenceFromIdentifierValue(), + ) + Assert.assertEquals( + "Patient/marie", + "https://fhir.example.org/Patient/marie/_history/2".patientReferenceFromIdentifierValue(), + ) + Assert.assertNull("not-a-patient".patientReferenceFromIdentifierValue()) + } +} diff --git a/android/feature/client-register-applicable-care.md b/android/feature/client-register-applicable-care.md new file mode 100644 index 00000000000..44a8fbfc8f3 --- /dev/null +++ b/android/feature/client-register-applicable-care.md @@ -0,0 +1,172 @@ +# Client Register — Applicable Care Discovery via PlanDefinition + +| Field | Value | +|-------|-------| +| **Status** | Implemented (initial) — pending on-device verification | +| **Repos** | openSRP FHIRCore Android (`android/`) | +| **Related** | `feature/register-tricc.md` Part IV (original design for this mechanism); `tricc_oo` `OpenSRPStrategy` (generates the PlanDefinitions this feature consumes) | +| **Named event (default)** | `available-care` | + +Valid status values: `Draft` → `Approved` → `Implemented` → `Superseded`. + +--- + +## Part I — Business spec + +### 1. Problem + +The client register and client profile need a "Start care" action that shows a patient only the +interventions that are actually **applicable to them** (age, sex, existing conditions, prior +encounters, …), without the app ever hardcoding which interventions exist or what makes them +applicable. Content authors (TRICC) publish that catalog and its eligibility logic as +`PlanDefinition` resources; the app's job is purely mechanical: find the PlanDefinitions tagged for +this button, evaluate them against this one patient, and let the user pick from whatever comes back +applicable. + +### 2. Goal + +Given a `namedEvent` string (from config, default `available-care`) and a patient, return the list of +currently-applicable interventions for that patient — driven entirely by synced FHIR content, with no +PlanDefinition/Questionnaire IDs known to the app binary. + +### 3. Decisions + +| Topic | Decision | +|-------|----------| +| Trigger | Any config action (register row `serviceButton`, profile button, …) with `workflow: APPLY_NAMED_EVENT` and a `namedEvent` param. Wired today on both `client_register_config.json` and `client_profile_config.json` ("Start care" button). | +| PlanDefinition discovery | All locally-stored `PlanDefinition`s are searched and filtered to those whose `action.trigger` (recursively, including nested actions) has `type = named-event` and a matching `name`. No separate index/config lists PD ids. | +| Applicability evaluation — fast path | When a matching PlanDefinition's applicable actions only use **FHIRPath** conditions, evaluate them in-process against the `Patient` resource directly (`FHIRPathEngine.evaluateToBoolean`) — no `$apply`, no side effects, no CarePlan generated. | +| Applicability evaluation — CQL path | When any condition uses a non-FHIRPath language (i.e. CQL), fall back to a full `PlanDefinition/$apply` via `WorkflowCarePlanGenerator` to get a correctly-evaluated `RequestGroup`. This path is **read-only** (`persist = false`, see §8): it never writes Task/RequestGroup/CarePlan resources to the local database just from browsing. | +| Strategy vs leaf PlanDefinitions | A "strategy" PD (nested `action.action` under the named-event action, per `register-tricc.md` §8.8) is supported: its child actions are evaluated as the candidate interventions rather than the parent wrapper action. | +| Where the picker lives | A native `AlertDialog` list of intervention titles, shown from the config-action handler — not a config-declared bottom sheet. | +| Selecting an option | If it resolves to a `Questionnaire` id, launch it directly. Otherwise (nested `PlanDefinition` with no direct Questionnaire) show a toast placeholder — full apply-on-select for nested PDs is not yet wired (see Part IV). | + +--- + +## Part II — Technical spec + +### 4. Where it lives + +| Area | Location | +|------|----------| +| Workflow enum value | `engine/.../configuration/workflow/ApplicationWorkflow.kt` — `APPLY_NAMED_EVENT` | +| Discovery + applicability + option-building | `engine/.../task/NamedEventInterventionService.kt` — `listInterventions()` | +| `$apply` execution (CQL path) | `engine/.../task/WorkflowCarePlanGenerator.kt` — `applyPlanDefinitionOnPatient()` | +| Config action → service call → picker UI | `quest/.../util/extensions/ConfigExtensions.kt` — `handleApplyNamedEvent()`, `launchInterventionOption()` | +| Hilt access from a non-injected extension function | `quest/.../di/NamedEventInterventionEntryPoint.kt` | +| Config wiring (button) | `quest/src/main/assets/configs/{app,cdss}/registers/client_register_config.json`, `.../profiles/client_profile_config.json` — `serviceButton`/button `actions` with `workflow: APPLY_NAMED_EVENT`, `params: [namedEvent, subjectId]` | + +### 5. End-to-end flow + +```text +Register/profile button (ON_CLICK, workflow=APPLY_NAMED_EVENT, +params: namedEvent="available-care", subjectId=@{patientLogicalId}) + │ + ▼ +ConfigExtensions.handleApplyNamedEvent + - resolves namedEvent (default "available-care") and subjectId from interpolated params + - requires the nav context to be a LifecycleOwner (bails with a toast otherwise) + - resolves NamedEventInterventionService via NamedEventInterventionEntryPoint (Hilt EntryPointAccessors) + │ + ▼ +NamedEventInterventionService.listInterventions(namedEvent, subjectId) + 1. fhirEngine.get(subjectId) -- bail (empty list) if missing + 2. loadPlanDefinitions() -- batchedSearch ALL local PlanDefinitions + 3. filter planDefinitions by hasNamedEventTrigger(namedEvent) -- recursive over action.trigger + 4. for each matching PlanDefinition, collectFromPlanDefinition: + actionsWithEvent = top-level actions carrying the trigger + actionsToEvaluate = actionsWithEvent's children if non-empty (strategy PD), else actionsWithEvent itself (leaf PD) + needsApply = any actionsToEvaluate condition uses a non-FHIRPath language + ├─ needsApply == true → collectFromWorkflowApply(planDefinition, patient, options) + └─ needsApply == false → evaluate each action.passesFhirPathConditions(patient) in-process; + build InterventionOption per passing action; + if still empty AND the PD has any nested actions, fall back to + collectFromWorkflowApply anyway + │ + ▼ +options: List (id, title, description, definitionCanonical, planDefinitionId?, questionnaireId?) + │ + ▼ +AlertDialog picker (titles) — empty list shows a "No care available for this client" toast instead + │ user picks one + ▼ +launchInterventionOption + - has questionnaireId → launchQuestionnaire(QuestionnaireConfig(id = questionnaireId, ...)) + - PlanDefinition only → toast placeholder + Timber log (not yet wired to launch) +``` + +### 6. `collectFromWorkflowApply` detail + +```text +collectFromWorkflowApply(planDefinition, patient, options) + - builds a throwaway CarePlan(status=DRAFT, intent=PROPOSAL, subject=patient) -- "output" scratch object + - workflowCarePlanGenerator.applyPlanDefinitionOnPatient(planDefinition, patient, data=Bundle(), + output=carePlan, persist=false) + → runs the real CQL $apply (FhirOperator / PlanDefinitionProcessor) + → acceptCarePlan(persist=false) classifies produced request resources + (Task, QuestionnaireResponse, OperationOutcome, MedicationRequest, CarePlan, RequestGroup) + WITHOUT writing them via DefaultRepository (see + WorkflowCarePlanGenerator.createProposedRequestResources's `persist` guard) + → links resources onto the scratch `output` CarePlan: CarePlan/RequestGroup are added to + `output.contained` (plus an activity reference); other types get an activity reference only + — see WorkflowCarePlanGenerator.addRequestResourcesToCarePlanOfRecord + - reads carePlan.contained.filterIsInstance() → action → InterventionOption (preferred) + - fallback: carePlan.activity entries → InterventionOption (when RequestGroup is empty/absent) +``` + +Actually starting an intervention (accepting a Questionnaire/PlanDefinition from the picker) is a +separate step from discovery and is expected to persist normally — call sites that generate the +CarePlan of record for real (e.g. `FhirCarePlanGenerator`) still use the `persist = true` default. + +### 7. `PlanDefinitionActionComponent.toInterventionOption` / `RequestGroupActionComponent.toInterventionOption` + +Both extract `title` (or `description`/PD title/name as fallback), and parse `definitionCanonical` / +`resource.reference` to pull out a `Questionnaire/{id}` or `PlanDefinition/{id}` substring into +`questionnaireId` / `planDefinitionId` respectively, via `extractLogicalIdUuid()`. + +### 8. Known behavior worth flagging + +- **Fixed:** the CQL/`$apply` path used to have side effects even though it's just a "browse" + operation — `WorkflowCarePlanGenerator.applyPlanDefinitionOnPatient` unconditionally persisted any + Task, QuestionnaireResponse, OperationOutcome, MedicationRequest, CarePlan, and RequestGroup + produced by `$apply`, so every "Start care" tap against a CQL-conditioned PlanDefinition wrote + duplicate resources to the local DB regardless of whether the user selected anything. This is now + gated behind a `persist: Boolean = true` parameter on `applyPlanDefinitionOnPatient` (threaded + through `acceptCarePlan`/`createProposedRequestResources`); `NamedEventInterventionService` calls it + with `persist = false`, so discovery is read-only. Real CarePlan-generation call sites (e.g. + `FhirCarePlanGenerator`) keep the `persist = true` default, so accepted/actioned CarePlans still + persist normally. +- **Also fixed as part of the same change:** `addRequestResourcesToCarePlanOfRecord`'s `when` had no + case for `"CarePlan"`/`"RequestGroup"`, so it fell through to `else -> TODO(...)` and threw + `NotImplementedError` whenever `$apply` produced a `RequestGroup` — which is the expected/primary + output for this feature. That exception was silently swallowed by the `runCatching` in + `collectFromWorkflowApply`, so the CQL path always appeared to return zero interventions (after + having already persisted the duplicate). `CarePlan`/`RequestGroup` resources are now added to + `carePlan.contained` (plus an activity reference), which is what lets + `carePlan.contained.filterIsInstance()` actually find anything. +- The fast FHIRPath path still has no side effects (pure read), which is why `needsApply` is + short-circuited to it whenever possible. +- Nested-PlanDefinition selections (no direct `Questionnaire`) are not fully wired to a launch action + yet — see the TODO in `launchInterventionOption` / Part IV of `register-tricc.md` §8.4. + +### 9. Success criteria + +1. Tapping "Start care" on a client register row or profile shows only interventions applicable to + that specific patient, computed from currently-synced PlanDefinitions. +2. Publishing a new `available-care`-triggered PlanDefinition (+ Questionnaire) and syncing makes it + appear for matching clients with no app release. +3. A patient with no applicable interventions sees a clear "No care available" message rather than an + empty/broken dialog. +4. Selecting an intervention backed by a Questionnaire launches that Questionnaire. + +### 10. Open items + +- Not yet verified on-device — confirm PlanDefinition discovery, FHIRPath fast-path evaluation, and + the CQL `$apply` fallback all behave correctly against real synced TRICC content on a + physical device/emulator (this mirrors the open item in `feature/cql-initial-expression.md`). In + particular, verify the `persist = false` read-only path (§8) against a real CQL-conditioned + PlanDefinition on-device, since it was only reasoned through statically, not yet exercised with a + running `FhirOperator`/`PlanDefinitionProcessor`. +- Wire "select a nested PlanDefinition option" to actually apply/launch it, instead of the current + toast placeholder — that step should call `applyPlanDefinitionOnPatient` with `persist = true` (or + an explicit accept step) so the chosen intervention's resources are actually saved. diff --git a/android/feature/register-tricc.md b/android/feature/register-tricc.md new file mode 100644 index 00000000000..18fce07dbfd --- /dev/null +++ b/android/feature/register-tricc.md @@ -0,0 +1,539 @@ +# Flexible Client Register + Dynamic Interventions (TRICC / openSRP) + +| Field | Value | +|-------|-------| +| **Status** | Approved | +| **Repos** | openSRP FHIRCore Android (`android/`), TRICC OpenSRPStrategy (`tricc_oo/`) | +| **Related** | `tricc_oo/feature/opensrp-register.md`, `tricc_oo/docs/desing/FHIRcore.md`, `tricc_oo/docs/open-srp-export.md` | +| **Named event (default)** | `available-care` | + +Valid status values: `Draft` → `Approved` → `Implemented` → `Superseded`. + +--- + +# Part I — Goals and product model + +## 1. Overview + +TRICC generates clinical decision support content (Questionnaires, PlanDefinitions, Libraries, StructureMaps) for openSRP / FHIRCore. The mobile app needs a **flexible client experience** that: + +1. Shows **all clients on a single register** (Patient-based). **No separate household TRICC register.** +2. On **selecting a client (profile)**, shows **related persons**: parents/guardians and/or children, via RelatedPerson. +3. Starts **interventions without hardcoding** form or PlanDefinition IDs: the app only knows a **named-event**; synced PlanDefinitions drive eligibility and launch targets. +4. Can **add a RelatedPerson** with **`RelatedPerson.patient` always = the child** (simplifies authoring and joins). + +Delivery order: **Android-first** (register UI + workflow). TRICC export updates follow a fixed content contract (Part IV). + +## 2. Clinical problem + +Current openSRP sample registers are **siloed** (child, ANC, household Group, …) and wire **fixed** questionnaire / planDefinition IDs in JSON. That works for static programs but not for TRICC, where: + +- New interventions appear when content is published and synced. +- Eligibility is **per client** (under-5, women of reproductive age, prior conditions, …). +- Family links are **person-centric**: mother, father, and guardian are themselves **clients**, not RelatedPerson-only shadows. + +## 3. Decisions + +| Topic | Decision | +|--------|----------| +| Registers | **One** primary TRICC register: **All clients**. Not “All clients” + “Household TRICC”. | +| Mother / father / guardian | **Always full clients (`Patient`)**. Never RelatedPerson-only people. | +| Where relations appear | **Client profile** after selection (parents/guardians and children). Optional light hint on register row. | +| Link model | **`RelatedPerson` only**; **`patient` always = child** (see Part II). | +| Add relation | Questionnaire / flow creates RelatedPerson with subject = **child**; parent/guardian is Patient + `identifier` PI link. | +| Legacy household Group | Optional for non-TRICC flavors only; not required for TRICC. | +| Intervention discovery | PlanDefinition **`$apply`** for PDs with a given **named-event**. | +| App hardcoding | Only the **named-event** string (and generic workflow). | + +--- + +# Part II — Client and relationship model + +## 4. Rules + +1. Every mother, father, or guardian is a **`Patient`** and appears on the client register. +2. Every dependent child is a **`Patient`**. +3. The relationship is a **`RelatedPerson`** resource: + - `RelatedPerson.patient` → **child** Patient + - `RelatedPerson.relationship` → mother | father | guardian (standard RoleCode / RoleClass, e.g. `MTH`, `FTH`, [`GUARD`](http://terminology.hl7.org/CodeSystem/v3-RoleClass#GUARD)) + - `RelatedPerson.identifier` → **Patient URL** of the mother/father/guardian **as a registered client** ([RelatedPerson.identifier](https://build.fhir.org/relatedperson-definitions.html#RelatedPerson.identifier)) + +No custom extension is required. **RelatedPerson is the source of truth** for mother / father / guardian links. + +Household `Group` is **not** required for the TRICC flexible register (may still exist for legacy apps). + +## 5. RelatedPerson shape (guardian is also Patient) + +```json +{ + "resourceType": "RelatedPerson", + "id": "rp-mother-of-jean", + "identifier": [{ + "use": "secondary", + "type": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PI", + "display": "Patient internal identifier" + }] + }, + "system": "urn:ietf:rfc:3986", + "value": "Patient/marie" + }], + "patient": { "reference": "Patient/jean" }, + "relationship": [{ + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MTH", + "display": "mother" + }] + }] +} +``` + +| Element | Meaning | +|---------|---------| +| `patient` | Child (subject of care the related person is related **to**) | +| `relationship` | Role toward the child (`MTH` / `FTH` / `GUARD`, etc.) | +| `identifier` | Who this related person **is** as a client: Patient relative ref or absolute URL | + +**Identifier convention (Patient client link)** + +| Field | Recommended | Notes | +|-------|-------------|--------| +| `type` | **`PI`** (v2-0203 Patient *internal* identifier) | Marks this id as “the Patient in *this* system”. Prefer **PI** over **PT** (external identifier) when `value` is our own `Patient/{id}`. **PT** is still accepted when reading. | +| `use` | **`secondary`** | Structural join key so **`official` / `usual`** stay free for national ID, openSRP ID, phone, etc. on the same RelatedPerson. | +| `system` | `urn:ietf:rfc:3986` | Value is a URI (relative `Patient/{id}` or absolute Patient URL). | +| `value` | `Patient/{id}` or absolute Patient URL | e.g. `Patient/marie` or `https://fhir.example.org/Patient/marie` | + +**Why not `use: official` or `usual` by default?** + +| `use` | Fit for Patient-link identifier | +|-------|----------------------------------| +| **`secondary`** (recommended) | Linkage / join only; does not compete with the person’s civil or clinical identifiers. | +| `usual` | Reasonable if this is *the* id UIs always show for “linked client” and there is no separate display id on RelatedPerson. | +| `official` | Better for national ID / legal identifier on the person — not for a FHIR resource URL. | +| `temp` / `old` | Avoid for the active Patient link. | + +`GUARD` / `MTH` / `FTH` answer *what role*; this `identifier` answers *which Patient client is that person*. They are complementary. + +### 5.1 Nesting under guardian Patient `P` + +1. Find RelatedPersons whose `identifier` resolves to `Patient/P` (Patient URL / ref). +2. For each, resolve `RelatedPerson.patient` as a nested child row. +3. Show relationship label from `RelatedPerson.relationship`. + +### 5.2 Forward view under child Patient `C` + +- RelatedPersons with `patient=C` (standard reverse-include `RelatedPerson?patient=`) for “has mother/father/guardian” labels on profile. + +### 5.3 Register vs profile + +| Screen | Behaviour | +|--------|-----------| +| **All clients register** | Flat list of all Patients. Tap → client profile. Optional “Has parent/guardian” hint. Start care may stay on the row. | +| **Client profile** | Selected person + **Children / dependents** (if parent) + **Parents / guardians** (if child) + **Add related person**. | + +No second “household” register: family context is the profile’s related-person sections. + +### 5.4 Add RelatedPerson (subject = always the child) + +Invariant for every new relationship row: + +```text +RelatedPerson.patient = Patient/{childId} // always the child +RelatedPerson.relationship = MTH | FTH | GUARD +RelatedPerson.identifier (PI, secondary) = Patient/{parentOrGuardianId} +``` + +| User is viewing | “Add related person” means | +|-----------------|----------------------------| +| **Child** profile | Link/create mother, father, or guardian Patient; RP.patient = this child. | +| **Adult** profile | Link/create a child Patient; RP.patient = **new/selected child**; identifier → this adult. | + +Questionnaire/StructureMap should never set `RelatedPerson.patient` to the parent. + +### 5.5 Relationship codes + +Prefer HL7 v3 RoleCode / RoleClass for interoperability: + +| Role | Code system | Code | +|------|-------------|------| +| Mother | `http://terminology.hl7.org/CodeSystem/v3-RoleCode` | `MTH` | +| Father | same | `FTH` | +| Guardian | `http://terminology.hl7.org/CodeSystem/v3-RoleClass` (or RoleCode) | `GUARD` | + +Config rules map codes → display strings / icons. + +--- + +# Part III — Register packaging and UI + +## 6. Packaging (single client register + profile) + +```text +configs/app/ + registers/ + client/ + client_register_config.json → id clientRegister + profiles/ + client/ + client_profile_config.json → id clientProfile +``` + +| Config | Role | +|--------|------| +| `clientRegister` | Flat list of all Patients | +| `clientProfile` | Relations (children + parents/guardians), Start care, Add related person | + +Legacy sample Group **household** register may remain for non-TRICC demos; it is **not** part of the TRICC product path. + +### 6.1 UX flow + +```text +All clients register + │ tap client + ▼ +Client profile + ├── Start care → APPLY_NAMED_EVENT (available-care) + ├── Children / dependents (if this Patient is mother/father/guardian) + ├── Parents / guardians (if RelatedPerson.patient = this Patient) + └── Add related person (RP.patient always = child) +``` + +## 7. Nested LIST on register rows + +Profile screens already populate nested lists via `RulesExecutor.processListResourceData` → `ResourceData.listResourceDataMap`. + +Register rows currently only run `processResourceData` (no `listResourceDataMap`). **Required engine change:** when register card views include `ViewType.LIST`, process list resources the same way as profile and attach `listResourceDataMap` so `List.kt` can render nested kids. + +### 7.1 FHIR resource config sketch + +```json +"fhirResource": { + "baseResource": { + "resource": "Patient", + "sortConfigs": [ + { "paramName": "_lastUpdated", "dataType": "DATE", "order": "DESCENDING" } + ] + }, + "relatedResources": [ + { + "id": "relatedPersons", + "resource": "RelatedPerson", + "searchParameter": "patient" + }, + { + "id": "tasks", + "resource": "Task", + "searchParameter": "subject" + }, + { + "id": "carePlans", + "resource": "CarePlan", + "searchParameter": "subject" + } + ] +} +``` + +Nesting under a guardian requires RelatedPersons whose `identifier` holds that guardian’s Patient URL (Part II). Repository currently loads RelatedPersons and joins in memory when Search cannot filter efficiently by identifier value. + +### 7.2 Nested LIST view sketch + +```json +{ + "viewType": "LIST", + "id": "dependentChildren", + "resources": [ + { + "id": "children", + "resourceType": "Patient", + "relatedResourceId": "dependentChildPatients", + "relatedResources": [ + { + "resourceType": "RelatedPerson", + "fhirPathExpression": "RelatedPerson.patient.reference" + } + ] + } + ], + "registerCard": { + "rules": [ /* child name, age, relationship label */ ], + "views": [ /* compact SERVICE_CARD + Start care button */ ] + } +} +``` + +Exact `relatedResourceId` / fact keys depend on how rules materialize “children of this guardian” into the facts map (filter RelatedPersons by Patient URL identifier, then load Patient resources). + +--- + +# Part IV — Dynamic interventions + +## 8. Architecture + +```text +Register / profile card button + │ + │ workflow APPLY_NAMED_EVENT + │ param namedEvent = "available-care" + ▼ +NamedEventInterventionService + │ 1. Discover PlanDefinition(s) with named-event trigger (synced) + │ 2. $apply for subject Patient (conditions evaluated here) + │ 3. Collect RequestGroup.action → InterventionOption list + ▼ +Bottom sheet / dialog (picker) + │ + ▼ +Selected definitionCanonical + ├── Questionnaire → LAUNCH_QUESTIONNAIRE + └── PlanDefinition → $apply / open resulting Tasks +``` + +### 8.1 Why `$apply` (not hand-rolled condition evaluation) + +Applicability is authored on PlanDefinition actions (FHIRPath and/or CQL). Reimplementing that in app rules would: + +- Duplicate TRICC / CPG logic. +- Drift from the engine used for CarePlan generation. +- Force app releases when eligibility rules change. + +`$apply` is the compute path that already exists (`FhirOperator` / `PlanDefinitionProcessor` / `WorkflowCarePlanGenerator`). The app stays ignorant of which interventions exist and of their conditions. + +### 8.2 Named-event contract + +| Item | Value | +|------|--------| +| Default event name | `available-care` | +| Where configured | Register/profile action `params` only | +| PlanDefinition trigger | `action.trigger[].type = "named-event"`, `name = "available-care"` | + +App binary does **not** list intervention IDs. + +### 8.3 ApplicationWorkflow + +```kotlin +APPLY_NAMED_EVENT +``` + +Example action: + +```json +{ + "trigger": "ON_CLICK", + "workflow": "APPLY_NAMED_EVENT", + "display": "Start care", + "params": [ + { "paramType": "PARAMDATA", "key": "namedEvent", "value": "available-care" }, + { "paramType": "PARAMDATA", "key": "subjectId", "value": "@{patientLogicalId}" } + ] +} +``` + +### 8.4 NamedEventInterventionService + +1. **Discover** active PlanDefinitions whose actions have `trigger.type=named-event` and `trigger.name` matching the param (local FHIR Engine / KnowledgeManager after sync). + - Prefer a single **strategy / clinical-protocol** PD that nests child recommendations if present. + - Else `$apply` each leaf PD with that named-event and merge applicable results. +2. **`$apply`** for subject Patient (+ context Bundle as needed). +3. **Parse recommendations** from apply output: + - Prefer `CarePlan.contained` **RequestGroup** → `action` (`title`, `description`, `resource` / definition canonicals). + - Fallback: CarePlan activities / Tasks if RequestGroup is empty (degraded UX). +4. Return `InterventionOption(id, title, description, definitionCanonical, planDefinitionId?, questionnaireId?)`. + +### 8.5 RequestGroup persistence + +`WorkflowCarePlanGenerator` currently ignores RequestGroup (`"RequestGroup" -> {}`). Implementation must **create/store** RequestGroup resources produced by `$apply` so recommendations can be displayed and audited. + +### 8.6 Performance + +- Run `$apply` **on button click**, never for every register row on scroll. +- Prefer one **strategy PlanDefinition** (single apply evaluates all nested conditions) when TRICC emits it. +- Optional short-lived in-memory cache for open picker only. + +### 8.7 Example leaf PlanDefinition (synced) + +```json +{ + "resourceType": "PlanDefinition", + "id": "etat-triage-PD", + "status": "active", + "action": [{ + "title": "ETAT Triage", + "trigger": [{ "type": "named-event", "name": "available-care" }], + "condition": [{ + "kind": "applicability", + "expression": { + "language": "text/fhirpath", + "expression": "Patient.birthDate >= today() - 5 years" + } + }], + "definitionCanonical": "https://fhir.tricc.io/Questionnaire/etat-triage" + }] +} +``` + +### 8.8 Example strategy PlanDefinition (preferred for compute) + +```json +{ + "resourceType": "PlanDefinition", + "id": "available-care-catalog", + "status": "active", + "type": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/plan-definition-type", + "code": "clinical-protocol" + }] + }, + "action": [{ + "title": "Available care", + "trigger": [{ "type": "named-event", "name": "available-care" }], + "selectionBehavior": "at-most-one", + "action": [ + { + "title": "ETAT Triage", + "condition": [{ + "kind": "applicability", + "expression": { + "language": "text/fhirpath", + "expression": "Patient.birthDate >= today() - 5 years" + } + }], + "definitionCanonical": "https://fhir.tricc.io/PlanDefinition/etat-triage-PD" + }, + { + "title": "Adult assessment", + "condition": [{ + "kind": "applicability", + "expression": { + "language": "text/fhirpath", + "expression": "Patient.birthDate < today() - 15 years" + } + }], + "definitionCanonical": "https://fhir.tricc.io/PlanDefinition/adult-assessment-PD" + } + ] + }] +} +``` + +`$apply` of the strategy PD yields a RequestGroup with only **applicable** child actions. + +--- + +# Part V — Hardcoded vs synced + +| Concern | Source | +|---------|--------| +| Named-event name | Register/profile config only | +| Intervention catalog | Synced PlanDefinitions | +| Eligibility (age, sex, conditions) | PD `action.condition` via `$apply` | +| Forms | Synced Questionnaires referenced by PD | +| Client list shape | Register config (Patient + RelatedPerson nest) | +| Mother/father/guardian as clients | Registration data model + StructureMaps | +| Relationship codes / labels | Data + display rules in config | + +**Success test:** publishing a new intervention PD + Questionnaire and syncing the device makes the intervention appear for matching clients **without an app release**. + +--- + +# Part VI — TRICC OpenSRPStrategy contract (WP5) + +Implemented in **tricc_oo** — see **`tricc_oo/feature/opensrp-register.md`**. + +| Item | Status | +|------|--------| +| Leaf PDs: process trigger **+** `available-care` | Yes (`OpenSRPStrategy.generate_plandefinition`) | +| Strategy PD `{form_id}-available-care-catalog` | Yes (`generate_available_care_catalog`) | +| RelatedPerson helpers (`PI`, patient=child) | Yes (`converters/fhir/related_person.py`) | +| Binary config: named_events, catalog id, contract | Yes | +| Contract JSON under `contract/` | Yes | +| Full StructureMap auto-extraction for add-related-person form | Future (hints emitted) | + +Key files: + +| Area | Path | +|------|------| +| Feature design | `tricc_oo/feature/opensrp-register.md` | +| OpenSRP export | `tricc_oo/strategies/output/opensrp.py` | +| RelatedPerson helpers | `tricc_oo/converters/fhir/related_person.py` | +| Specs | `tricc_oo/docs/desing/FHIRcore.md`, `tricc_oo/docs/open-srp-export.md` | +| Tests | `tricc_oo/tests/test_strategies/test_opensrp_strategy.py` | + +--- + +# Part VII — Android implementation work packages + +| WP | Scope | Status | +|----|--------|--------| +| **WP0** | This document (`feature/register-tricc.md`) | Done | +| **WP1** | Client (+ household TRICC) register config; `listResourceDataMap` on register path; RelatedPerson nesting | Done (initial) | +| **WP2** | `APPLY_NAMED_EVENT`, `NamedEventInterventionService`, RequestGroup persist, picker UI | Done (initial) | +| **WP3** | Wire Start care on top-level and nested cards; profile parity | Done (initial) | +| **WP4** | Sync / KnowledgeManager smoke path for PlanDefinitions offline `$apply` | Pending | +| **WP5** | TRICC export + registration RelatedPerson contract (`tricc_oo/feature/opensrp-register.md`) | Done (initial) | + +### Key Android files + +| Area | Paths | +|------|--------| +| Register configs | `quest/src/main/assets/configs/app/registers/` | +| Nav / composition | `navigation_config.json`, `composition_config.json` | +| Register data | `quest/.../register/RegisterViewModel.kt`, `RegisterPagingSource.kt` | +| List rules | `engine/.../rulesengine/RulesExecutor.kt`, `quest/.../shared/components/List.kt` | +| Workflows | `engine/.../workflow/ApplicationWorkflow.kt`, `quest/.../ConfigExtensions.kt` | +| Apply / RequestGroup | `engine/.../task/WorkflowCarePlanGenerator.kt`, `FhirCarePlanGenerator.kt` | + +--- + +# Part VIII — Migration from sample household Group register + +| Sample (today) | TRICC target | +|----------------|--------------| +| Base `Group` (household) + household register | **One** Patient register + **profile** for family links | +| Members via Group.member | RelatedPerson (`patient`=child, `identifier`=parent Patient URL) | +| Separate disease registers | Optional; not required for TRICC core path | +| Fixed questionnaire ids | `APPLY_NAMED_EVENT` + synced PDs | + +Do **not** add a second “Household TRICC” register that duplicates All clients. + +--- + +# Part IX — Open questions + +1. Whether Search DSL can filter RelatedPerson by `identifier` (Patient URL) efficiently offline; if not, in-memory join after loading candidates (current approach). +2. CQL vs FHIRPath for applicability on first TRICC content wave (engine support matrix). +3. Whether nested children should be **hidden** from top-level list (config flag) after field feedback. +4. Composition Binary folder layout vs flat ids for assets loader in this fork. + +--- + +# Part X — Success criteria + +1. **One** All clients register; selecting a client opens profile with parents/guardians and/or children. +2. RelatedPerson always has `patient` = child; parent/guardian linked via `identifier` (PI) + relationship codes. +3. Can add RelatedPerson from profile (subject = child). +4. Client Start care opens only **applicable** interventions without app knowing PD IDs. +5. New synced intervention PD appears when conditions match — no app release for listing. + +--- + +# Part XI — Implementation notes (landed in android) + +| Area | Location | +|------|----------| +| Design | `feature/register-tricc.md` | +| RelatedPerson helpers | `engine/.../util/extension/RelatedPersonAsPatient.kt` (guardian via `identifier` Patient URL) | +| Dependent enrichment | `RegisterRepository.enrichDependentChildrenFromRelatedPersons` | +| Register LIST processing | `RulesExecutor.processResourceDataWithLists`, `RegisterPagingSource` | +| Workflow | `ApplicationWorkflow.APPLY_NAMED_EVENT` | +| Intervention service | `NamedEventInterventionService` | +| Click handler / picker | `ConfigExtensions.handleApplyNamedEvent` | +| RequestGroup persist | `WorkflowCarePlanGenerator` | +| Configs | `registers/client/client_register_config.json`, `profiles/client/client_profile_config.json` | +| Nav | `navigation_config.json` — **All clients** (no separate Household TRICC) | + +**Config asset keys** come from the filename before `_config` (camelCase), not the folder name. Folders document packaging only. diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt index ff1f8eb2420..96731debc8b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt @@ -17,10 +17,13 @@ package org.smartregister.fhircore.quest.data.register.model import org.jeasy.rules.api.Rules +import org.smartregister.fhircore.engine.configuration.view.ListProperties data class RegisterPagingSourceState( val registerId: String, val currentPage: Int = 0, val loadAll: Boolean = false, val rules: Rules, + /** Nested LIST view configs from the register card (e.g. dependent children). */ + val listProperties: List = emptyList(), ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt new file mode 100644 index 00000000000..0800cc5b0e7 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.di + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.smartregister.fhircore.engine.task.NamedEventInterventionService + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface NamedEventInterventionEntryPoint { + fun namedEventInterventionService(): NamedEventInterventionService +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt index 13e0157bc27..3af370c00bd 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt @@ -65,6 +65,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterFilterField +import org.smartregister.fhircore.engine.configuration.view.retrieveListProperties import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.Code @@ -144,6 +145,7 @@ constructor( val currentRegisterConfig = retrieveRegisterConfiguration(registerId) val pageSize = currentRegisterConfig.pageSize val rules = rulesExecutor.rulesFactory.generateRules(currentRegisterConfig.registerCard.rules) + val listProperties = currentRegisterConfig.registerCard.views.retrieveListProperties() return Pager( config = PagingConfig(pageSize = pageSize, prefetchDistance = pageSize / 2), pagingSourceFactory = { @@ -157,6 +159,7 @@ constructor( loadAll = loadAll, currentPage = if (loadAll) 0 else currentPage.value, rules = rules, + listProperties = listProperties, ), rulesExecutor = rulesExecutor, ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index 02f79d6a1ef..ef0a3d2f8ec 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.util.extensions +import android.app.AlertDialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -27,11 +28,16 @@ import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.core.os.bundleOf +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavOptions import com.google.android.fhir.FhirEngine +import dagger.hilt.android.EntryPointAccessors import kotlin.collections.set +import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Binary +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.navigation.ICON_TYPE_REMOTE import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig import org.smartregister.fhircore.engine.configuration.view.CardViewProperties @@ -49,6 +55,7 @@ import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ViewType +import org.smartregister.fhircore.engine.task.NamedEventInterventionService import org.smartregister.fhircore.engine.util.extension.decodeJson import org.smartregister.fhircore.engine.util.extension.decodeToBitmap import org.smartregister.fhircore.engine.util.extension.encodeJson @@ -58,12 +65,14 @@ import org.smartregister.fhircore.engine.util.extension.isIn import org.smartregister.fhircore.engine.util.extension.loadResource import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.di.NamedEventInterventionEntryPoint import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.navigation.NavigationArg import org.smartregister.fhircore.quest.ui.pdf.PdfLauncherFragment import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler import org.smartregister.fhircore.quest.util.openExternalApp import org.smartregister.p2p.utils.startP2PScreen +import timber.log.Timber const val PRACTITIONER_ID = "practitionerId" @@ -256,10 +265,109 @@ fun ActionConfig.handleClickEvent( ) navController.navigate(MainNavigationScreen.AlertDialogFragment.route, args) } + ApplicationWorkflow.APPLY_NAMED_EVENT -> { + handleApplyNamedEvent( + navController = navController, + interpolatedParams = interpolatedParams, + resourceId = resourceId, + computedValuesMap = computedValuesMap, + ) + } else -> return } } +private fun handleApplyNamedEvent( + navController: NavController, + interpolatedParams: List, + resourceId: String?, + computedValuesMap: Map, +) { + val context = navController.context + val namedEvent = + interpolatedParams.find { it.key == "namedEvent" }?.value?.takeIf { it.isNotBlank() } + ?: "available-care" + val subjectId = + interpolatedParams.find { it.key == "subjectId" }?.value?.extractLogicalIdUuid() + ?: resourceId?.extractLogicalIdUuid() + if (subjectId.isNullOrBlank()) { + context.showToast("No client selected for care", Toast.LENGTH_SHORT) + return + } + + val lifecycleOwner = context as? LifecycleOwner + if (lifecycleOwner == null) { + Timber.e("APPLY_NAMED_EVENT requires a LifecycleOwner context") + context.showToast("Unable to start care", Toast.LENGTH_SHORT) + return + } + + val service = + EntryPointAccessors.fromApplication( + context.applicationContext, + NamedEventInterventionEntryPoint::class.java, + ) + .namedEventInterventionService() + + lifecycleOwner.lifecycleScope.launch { + val options = + runCatching { service.listInterventions(namedEvent, subjectId) } + .onFailure { Timber.e(it, "Failed to list interventions for event=$namedEvent") } + .getOrDefault(emptyList()) + + if (options.isEmpty()) { + context.showToast("No care available for this client", Toast.LENGTH_LONG) + return@launch + } + + val labels = options.map { it.title }.toTypedArray() + AlertDialog.Builder(context) + .setTitle(actionDisplayOrDefault(computedValuesMap, "Start care")) + .setItems(labels) { _, which -> + val selected = options.getOrNull(which) ?: return@setItems + launchInterventionOption(navController, selected) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} + +private fun actionDisplayOrDefault(computedValuesMap: Map, default: String): String { + val fromMap = computedValuesMap["actionDisplay"] as? String + return fromMap?.takeIf { it.isNotBlank() } ?: default +} + +private fun launchInterventionOption( + navController: NavController, + option: NamedEventInterventionService.InterventionOption, +) { + val questionnaireId = option.questionnaireId + if (!questionnaireId.isNullOrBlank() && navController.context is QuestionnaireHandler) { + (navController.context as QuestionnaireHandler).launchQuestionnaire( + context = navController.context, + questionnaireConfig = + QuestionnaireConfig( + id = questionnaireId, + title = option.title, + saveButtonText = "Save", + ), + actionParams = emptyList(), + ) + return + } + + // Nested PlanDefinition without a direct Questionnaire: surface title for now; full + // apply-on-select + // can be extended once TRICC emits strategy PDs consistently. + navController.context.showToast( + "Selected: ${option.title}" + (option.definitionCanonical?.let { " ($it)" } ?: ""), + Toast.LENGTH_LONG, + ) + Timber.i( + "APPLY_NAMED_EVENT selected option id=${option.id} definition=${option.definitionCanonical}", + ) +} + fun interpolateActionParamsValue(actionConfig: ActionConfig, resourceData: ResourceData?) = actionConfig.params .encodeJson() From 902bb33c29e3ca740a345cb59923871c0c58b44f Mon Sep 17 00:00:00 2001 From: delcroip Date: Fri, 7 Aug 2026 09:12:56 +0200 Subject: [PATCH 04/11] chore: feature file for the initial expression --- android/feature/cql-initial-expression.md | 116 ++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 android/feature/cql-initial-expression.md diff --git a/android/feature/cql-initial-expression.md b/android/feature/cql-initial-expression.md new file mode 100644 index 00000000000..0561727c122 --- /dev/null +++ b/android/feature/cql-initial-expression.md @@ -0,0 +1,116 @@ +# CQL `initialExpression` Population on Questionnaires + +| Field | Value | +|-------|-------| +| **Status** | Implemented (pending on-device verification) | +| **Repos** | openSRP FHIRCore Android (`android/`) | +| **Related** | `feature/register-tricc.md` (PlanDefinition `$apply` / CQL usage precedent) | + +Valid status values: `Draft` → `Approved` → `Implemented` → `Superseded`. + +--- + +## Part I — Business spec + +### 1. Problem + +SDC Questionnaires support `item.extension` `initialExpression` to pre-populate an answer from a +computed expression instead of a static `initial` value. TRICC-generated content expresses these +computations in **CQL** (`language = text/cql-identifier` or `text/cql`), referencing `define` +statements in a `Library` linked to the Questionnaire via a `cqf-library` extension. Before this +change, the app only evaluated FHIRPath-based `initialExpression`s (via the Android FHIR SDK's +built-in `ResourceMapper.populate`) — CQL-based ones were silently ignored, so any TRICC form relying +on a CQL default (e.g. a computed BMI, a derived risk flag, a value copied from an earlier +encounter) opened with that field blank. + +### 2. Goal + +When a Questionnaire declares one or more `cqf-library` extensions, evaluate any CQL +`initialExpression` items against that library before the SDC library populates the +`QuestionnaireResponse`, so CQL-derived defaults appear exactly like any other prepopulated answer. + +### 3. Decisions + +| Topic | Decision | +|-------|----------| +| Supported languages | `text/cql-identifier` and `text/cql` only (FHIRPath `initialExpression` continues to go through the SDC library unchanged). | +| `cqf-library` extension value types | Support `valueCanonical`, `valueUri`, and `valueString` (TRICC/authoring tools are inconsistent about which type they emit). | +| When to run | Only when opening a **new** response — skipped when reopening an editable/read-only/summary/draft response, so previously saved answers are never overwritten by a fresh CQL evaluation. | +| Evaluation context | `FhirOperator.evaluateLibrary` per distinct library URL, with the launch-context subject resource as the CQL context resource, and `patient`/`encounterid` passed as CQL parameters when those resources are present in the launch context. | +| Conflict with SDC library | `initial` and `initialExpression` cannot coexist on the same item per the SDC populate contract, so once a CQL value is resolved, the `initialExpression` extension is removed from the item and replaced with a plain `initial` value carrying the CQL result. | +| Failure handling | Per-library evaluation failures are caught and logged (`Timber.e`); they do not block loading the rest of the questionnaire or other libraries' expressions. | + +--- + +## Part II — Technical spec + +### 4. Where it lives + +| Area | Location | +|------|----------| +| `cqf-library` value extraction (multi-type) | `engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt` — `cqfLibraryIds()`, `cqfLibraryUrls()`, private `Extension.cqfLibraryValue()` | +| CQL initial-expression evaluation | `quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt` — `evaluateCqlInitialExpressions`, `collectCqlInitialExpressionItems`, `applyCqlExpressionResultsToInitial` | +| Call site | `QuestionnaireViewModel` questionnaire-loading path, invoked before `fetchRepositoryQuestionnaireResponse`/populate, gated by a `willLoadSavedResponse` check | +| Tests | `quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt` | + +### 5. Flow + +```text +Open questionnaire + │ + ▼ +questionnaire.cqfLibraryUrls() -- any cqf-library extensions? + │ none │ one or more + ▼ ▼ + (unchanged SDC populate path) collectCqlInitialExpressionItems() + -- walk item tree, gather distinct + CQL initialExpression strings + │ + ▼ + build data Bundle from launchContextResources + + Parameters (patient / encounterid, when present) + │ + ▼ + for each distinct library URL: + fhirOperator.evaluateLibrary(url, subjectRef, + inputParameters, dataBundle, expressionSet) + │ + ▼ + applyCqlExpressionResultsToInitial() + -- for each item whose initialExpression + matches a returned Parameters entry: + remove initialExpression extension, + set item.initial = [resultValue] + │ + ▼ + ResourceMapper.populate() runs normally, + now seeding these items from `initial` +``` + +### 6. Notable implementation details + +- The CQL subject resource is chosen by matching `questionnaire.subjectType` against the + `launchContextResources` list, falling back to the first launch-context resource if no type match + is found. +- Only items whose `initialExpression.language` is in `{text/cql-identifier, text/cql}` are + collected/evaluated; FHIRPath and other languages pass through untouched. +- `Parameters.getParameter(exprName)` results may come back as either `.value` or `.resource` + depending on the CQL return type — both are checked. +- Item tree walking is recursive (`item.item`) so nested groups are covered. + +### 7. Success criteria + +1. A Questionnaire with a CQL `initialExpression` referencing a linked `Library` opens with that + field pre-filled from the CQL evaluation result. +2. Reopening a previously saved (editable/read-only/summary/draft) response does not re-run CQL + evaluation or overwrite the saved answer. +3. A Questionnaire with no `cqf-library` extension is unaffected (no behavior change, no performance + cost). +4. A failure evaluating one library does not prevent the questionnaire from opening or other + libraries' expressions from being evaluated. + +### 8. Open items + +- Not yet verified on-device (unit-tested only) — confirm real TRICC-generated Library/CQL content + populates as expected on a physical device/emulator, including timing relative to CarePlan/Task + launch-context loading. From 9cb66314ed511736751c710b4dd46f27e35ee243 Mon Sep 17 00:00:00 2001 From: delcroip Date: Fri, 7 Aug 2026 18:48:02 +0200 Subject: [PATCH 05/11] feat: manage nested PD upon named event call + more Timber message --- .../task/NamedEventInterventionService.kt | 122 +++++++----- .../client-register-applicable-care.md | 2 +- android/feature/register-tricc.md | 94 +++++++++- .../assets/resources/test-questionnaire.json | 9 +- .../data/register/RegisterPagingSource.kt | 19 +- .../ui/questionnaire/QuestionnaireActivity.kt | 55 ++++++ .../questionnaire/QuestionnaireViewModel.kt | 59 +++++- .../quest/util/extensions/ConfigExtensions.kt | 20 +- android/quest/src/main/res/values/strings.xml | 2 + .../quest/CdssRegistrationDiagnosticTest.kt | 173 ++++++++++++++++++ 10 files changed, 486 insertions(+), 69 deletions(-) create mode 100644 android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt index ad93456770b..de7caae9091 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt @@ -38,8 +38,10 @@ import timber.log.Timber /** * Discovers synced PlanDefinitions by named-event trigger, evaluates applicability (trigger + - * conditions), and returns intervention options for the user to pick. Catalog is FHIR content only - * — see `feature/register-tricc.md`. + * conditions), and returns intervention options that can launch a **Questionnaire**. Catalog is + * FHIR content only — see `feature/register-tricc.md`. + * + * Options without a resolvable Questionnaire id are **omitted** (no empty toast entries). */ @Singleton class NamedEventInterventionService @@ -64,10 +66,11 @@ constructor( * named-event [namedEvent] (default `available-care`). * * Prefers evaluating action.condition with FHIRPath (no side effects). Falls back to workflow - * `$apply` when RequestGroup recommendations are needed or conditions use non-FHIRPath languages. - * This is a read-only discovery/preview operation: the `$apply` fallback runs with - * `persist = false`, so browsing for applicable care never writes Task/RequestGroup/CarePlan - * resources to the local database — only actually starting an intervention should do that. + * `$apply` when conditions use non-FHIRPath languages. Browse path is read-only (`persist = + * false`). + * + * Only options with a **Questionnaire** launch target are returned. A PD that matches the + * named-event but has no applicable action that points at a Questionnaire contributes nothing. */ suspend fun listInterventions( namedEvent: String, @@ -102,7 +105,15 @@ constructor( ) } - return options.values.toList() + // Only launchable questionnaires — drop Task/AD/#fragment or empty apply results + val launchable = + options.values.filter { !it.questionnaireId.isNullOrBlank() }.also { list -> + Timber.i( + "Named-event '$namedEvent': ${list.size} launchable intervention(s) " + + "(${options.size} raw option(s) before Questionnaire filter)", + ) + } + return launchable } private suspend fun loadPlanDefinitions(): List { @@ -124,7 +135,7 @@ constructor( val actionsWithEvent = planDefinition.action.filter { it.hasNamedEventTrigger(namedEvent) } val actionsToEvaluate = if (actionsWithEvent.isNotEmpty()) { - // Include nested actions under matching top-level actions (strategy PD) + // Strategy PD: evaluate nested children; leaf PD: evaluate the matching action itself actionsWithEvent.flatMap { parent -> if (parent.action.isNullOrEmpty()) listOf(parent) else parent.action } @@ -132,6 +143,13 @@ constructor( emptyList() } + if (actionsToEvaluate.isEmpty()) { + Timber.d( + "PlanDefinition/${planDefinition.logicalId} matched named-event but has no actions to evaluate", + ) + return + } + val needsApply = actionsToEvaluate.any { action -> action.condition.any { @@ -146,17 +164,13 @@ constructor( } actionsToEvaluate.forEach { action -> - if (!action.passesFhirPathConditions(patient)) return@forEach - val option = action.toInterventionOption(planDefinition) - if (option != null) { - options.putIfAbsent(option.id, option) + if (!action.passesFhirPathConditions(patient)) { + Timber.d( + "Skipping non-applicable action '${action.title}' on PlanDefinition/${planDefinition.logicalId}", + ) + return@forEach } - } - - // Nested strategy actions may not carry the named-event themselves; if top-level matched and - // we only evaluated children via flatMap above, also try apply when no options yet. - if (options.isEmpty() && planDefinition.action.any { it.action.isNotEmpty() }) { - collectFromWorkflowApply(planDefinition, patient, options) + addResolvedOption(action.toInterventionOption(planDefinition), options) } } @@ -181,35 +195,50 @@ constructor( ) carePlan.contained.filterIsInstance().forEach { requestGroup -> requestGroup.action.forEach { rgAction -> - val option = rgAction.toInterventionOption(planDefinition) - if (option != null) options.putIfAbsent(option.id, option) + addResolvedOption(rgAction.toInterventionOption(planDefinition), options) } } - // Fallback: activities with descriptions - if (options.isEmpty()) { - carePlan.activity.forEachIndexed { index, activity -> - val title = - activity.detail?.description - ?: activity.detail?.code?.codingFirstRep?.display - ?: planDefinition.title - ?: planDefinition.name - ?: "Intervention ${index + 1}" - val id = "${planDefinition.logicalId}-activity-$index" + // Do not invent toast-only options from CarePlan.activity without a Questionnaire. + } + .onFailure { + Timber.e(it, "Workflow \$apply failed for PlanDefinition/${planDefinition.logicalId}") + } + } + + /** + * Prefer options that already resolve to a Questionnaire. If the action points at another + * PlanDefinition, follow one level to its first applicable Questionnaire action. + */ + private suspend fun addResolvedOption( + option: InterventionOption?, + options: MutableMap, + ) { + if (option == null) return + if (!option.questionnaireId.isNullOrBlank()) { + // Dedupe by questionnaire id so catalog + leaf do not double-list + options.putIfAbsent(option.questionnaireId!!, option.copy(id = option.questionnaireId!!)) + return + } + val nestedPlanId = option.planDefinitionId + if (!nestedPlanId.isNullOrBlank() && nestedPlanId != option.id) { + val nested = + runCatching { fhirEngine.get(nestedPlanId) } + .onFailure { + Timber.w(it, "Could not load nested PlanDefinition/$nestedPlanId for intervention") + } + .getOrNull() + if (nested != null) { + nested.action.forEach { nestedAction -> + val nestedOption = nestedAction.toInterventionOption(nested) + if (nestedOption != null && !nestedOption.questionnaireId.isNullOrBlank()) { options.putIfAbsent( - id, - InterventionOption( - id = id, - title = title, - description = activity.detail?.description, - planDefinitionId = planDefinition.logicalId, - ), + nestedOption.questionnaireId!!, + nestedOption.copy(id = nestedOption.questionnaireId!!), ) } } } - .onFailure { - Timber.e(it, "Workflow \$apply failed for PlanDefinition/${planDefinition.logicalId}") - } + } } private fun PlanDefinition.PlanDefinitionActionComponent.passesFhirPathConditions( @@ -223,6 +252,7 @@ constructor( if (!conditionComponent.hasExpression()) return@all true val language = conditionComponent.expression.language if (language != Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode()) { + // Non-FHIRPath handled by needsApply branch; treat as not applicable on this path Timber.w("Skipping non-FHIRPath condition language=$language") return@all false } @@ -261,7 +291,11 @@ constructor( ?.substringAfter("PlanDefinition/", missingDelimiterValue = "") ?.takeIf { definition.contains("PlanDefinition/") && it.isNotBlank() } ?.extractLogicalIdUuid() - val id = id ?: definition ?: "${planDefinition.logicalId}-${title.hashCode()}" + val id = + questionnaireId + ?: id + ?: definition + ?: "${planDefinition.logicalId}-${title.hashCode()}" return InterventionOption( id = id, title = title, @@ -288,7 +322,11 @@ constructor( ?.substringAfter("PlanDefinition/", missingDelimiterValue = "") ?.takeIf { resourceRef.contains("PlanDefinition/") && it.isNotBlank() } ?.extractLogicalIdUuid() - val id = this.id ?: definition ?: "${planDefinition.logicalId}-${title.hashCode()}" + val id = + questionnaireId + ?: this.id + ?: definition + ?: "${planDefinition.logicalId}-${title.hashCode()}" return InterventionOption( id = id, title = title, diff --git a/android/feature/client-register-applicable-care.md b/android/feature/client-register-applicable-care.md index 44a8fbfc8f3..7400281e82a 100644 --- a/android/feature/client-register-applicable-care.md +++ b/android/feature/client-register-applicable-care.md @@ -39,7 +39,7 @@ PlanDefinition/Questionnaire IDs known to the app binary. | Applicability evaluation — CQL path | When any condition uses a non-FHIRPath language (i.e. CQL), fall back to a full `PlanDefinition/$apply` via `WorkflowCarePlanGenerator` to get a correctly-evaluated `RequestGroup`. This path is **read-only** (`persist = false`, see §8): it never writes Task/RequestGroup/CarePlan resources to the local database just from browsing. | | Strategy vs leaf PlanDefinitions | A "strategy" PD (nested `action.action` under the named-event action, per `register-tricc.md` §8.8) is supported: its child actions are evaluated as the candidate interventions rather than the parent wrapper action. | | Where the picker lives | A native `AlertDialog` list of intervention titles, shown from the config-action handler — not a config-declared bottom sheet. | -| Selecting an option | If it resolves to a `Questionnaire` id, launch it directly. Otherwise (nested `PlanDefinition` with no direct Questionnaire) show a toast placeholder — full apply-on-select for nested PDs is not yet wired (see Part IV). | +| Selecting an option | **Only options with a resolvable Questionnaire id** are listed. Select launches the Questionnaire directly. Leaf/catalog PDs must set `action.definitionCanonical` to a Questionnaire URL (not Task ActivityDefinition). | --- diff --git a/android/feature/register-tricc.md b/android/feature/register-tricc.md index 18fce07dbfd..86e1cd727b8 100644 --- a/android/feature/register-tricc.md +++ b/android/feature/register-tricc.md @@ -209,7 +209,7 @@ Register rows currently only run `processResourceData` (no `listResourceDataMap` ### 7.1 FHIR resource config sketch -```json +```jsonc "fhirResource": { "baseResource": { "resource": "Patient", @@ -241,7 +241,7 @@ Nesting under a guardian requires RelatedPersons whose `identifier` holds that g ### 7.2 Nested LIST view sketch -```json +```jsonc { "viewType": "LIST", "id": "dependentChildren", @@ -375,6 +375,14 @@ Example action: } ``` +**Important:** openSRP `APPLY_NAMED_EVENT` launches **Questionnaires** when the form is +**due now**. Leaf and catalog child actions must use `definitionCanonical` → Questionnaire URL. + +**Task-wrapped Questionnaire** (ActivityDefinition `kind: Task` + transform → Task with +`reasonReference` → Questionnaire) is reserved for the **upcoming planning** feature when +the questionnaire is **not due now** (worklist / task register). It is **not** the Start care +launch path. See tricc_oo `feature/opensrp-register.md` §2.1. + ### 8.8 Example strategy PlanDefinition (preferred for compute) ```json @@ -440,6 +448,86 @@ Example action: --- +# Part V-bis — Shell Composition vs TRICC content packages + +## 9. Problem + +OpenSRP loads **one** app Composition for app id `cdss`: + +- `Composition?identifier=cdss` → **`entryFirstRep` only**, or +- `ImplementationGuide` → first definition resource as that Composition. + +That Composition is both: + +1. **Shell** — Binary configs (application, navigation, register, profile, sync). +2. **Content pin list** — section focuses for `Questionnaire` / `StructureMap` / … that `fetchNonWorkflowConfigResources` downloads by id. + +TRICC already emits **one Composition per form package** (`identifier.system = https://fhir.tricc.io`, value `{form}-composition`). That is a **package manifest**, not the app-id shell. + +**There is no built-in “load all Compositions matching a search key.”** +You do **not** need (and must not use) one app-id Composition per TRICC output. + +## 10. Recommended model (Option A — sync-driven content) + +```text +Shell Composition (identifier.system = smartregister app-id, value = cdss) + Binary application / navigation / register / profile / sync + Questionnaire + StructureMap for client registration only + +Content packages (0..N TRICC exports) — NOT app-id Composition + Questionnaire, StructureMap, PlanDefinition, Library, ValueSet + + package Composition (export/audit/push checklist only) + meta.tag: system = https://smartregister.org/app-id, code = cdss +``` + +| Layer | Role | +|-------|------| +| Shell Composition | Generic app behaviour; rarely rewritten | +| Sync SearchParameters (shell Binary) | Pull clinical content by **tag / type**, not by enumerating ids on shell | +| TRICC package Composition | Per-export manifest; **never** `identifier.value = cdss` | +| Runtime `NamedEventInterventionService` | All **local** PlanDefinitions with named-event `available-care` | +| Runtime questionnaire save | Local `StructureMap/{id}` from `targetStructureMap` canonical | + +**Composition search key is not required** for multi-package delivery. The delivery key is **resource tags + sync config**. + +### Why not one Composition per TRICC output as app id? + +| Approach | Result | +|----------|--------| +| Package Composition uses `identifier=cdss` | Collides with shell; `entryFirstRep` is non-deterministic | +| Replace shell with last published form package | Loses generic register/nav; only last form’s pin list | +| Merge every package into shell Composition (Option D) | Works without app change but every publish rewrites shell | + +### Optional follow-on (Option B — multi-Composition) + +If gateway policy needs explicit manifests: shell Binary documents e.g. +`contentCompositionSearch = Composition?_tag=https://smartregister.org/app-id|cdss-content` +and the app **unions** all matching package Composition sections before `fetchNonWorkflowConfigResources`. **Not implemented today** — product change in `ConfigurationRegistry` / `AppSettingViewModel`. + +### Optional List indirection (Option C) + +Shell can reference `List` resources; `processCompositionListResources` expands entries. Shared Lists become a publish-time merge point. + +## 11. TRICC export rules (content contract) + +1. **Never** set package Composition `identifier` to app id `cdss`. +2. Tag all clinical resources with app id (`https://smartregister.org/app-id` \| `cdss`) and optional form id. +3. StructureMap **logical id** = last path segment of questionnaire `sdc-questionnaire-targetStructureMap` (app loads by id after `/`). +4. Registration Questionnaire/StructureMap stay on the **shell** (platform), not on every clinical form package. +5. Package Binary “app config” from TRICC must **not** override shell navigation when multiple forms coexist. + +## 12. Implication for StructureMaps + +| Path | How StructureMap reaches the device | +|------|-------------------------------------| +| Shell registration SM | Listed on shell Composition (current POC) **or** shipped in `cdss/debug` assets | +| TRICC form SM | **Preferred:** tagged sync (Option A). Alternative: package Composition multi-load (Option B) or List (Option C) | +| Save time | Always local FHIR Engine `StructureMap/{id}` — no remote fetch mid-save | + +OpenSRP **does** support StructureMap extraction (`ResourceMapper.extract`); packaging must ensure the map is local before save. + +--- + # Part VI — TRICC OpenSRPStrategy contract (WP5) Implemented in **tricc_oo** — see **`tricc_oo/feature/opensrp-register.md`**. @@ -508,6 +596,8 @@ Do **not** add a second “Household TRICC” register that duplicates All clien 2. CQL vs FHIRPath for applicability on first TRICC content wave (engine support matrix). 3. Whether nested children should be **hidden** from top-level list (config flag) after field feedback. 4. Composition Binary folder layout vs flat ids for assets loader in this fork. +5. **Content delivery:** Option A wired for `cdss` (`sync_config.json` `_tag=https://smartregister.org/app-id|cdss`); TRICC must stamp the same `meta.tag` on published resources. +6. **Gateway:** POC uses `conf/gateway/hapi_sync_filter_ignored_queries.json` with explicit `_tag` skip entries (and ValueSet). Confirm production gateway deploys the same skip list. --- diff --git a/android/quest/src/main/assets/resources/test-questionnaire.json b/android/quest/src/main/assets/resources/test-questionnaire.json index a02236fec16..68b23d15ca5 100644 --- a/android/quest/src/main/assets/resources/test-questionnaire.json +++ b/android/quest/src/main/assets/resources/test-questionnaire.json @@ -18,6 +18,11 @@ "system": "urn:ietf:bcp:47", "code": "en-GB", "display": "English" + }, + { + "system": "https://smartregister.org/app-id", + "code": "cdss", + "display": "CDSS application" } ] }, @@ -363,7 +368,7 @@ "extension": [ { "url": "http://hl7.org/fhir/StructureDefinition/regex", - "valueString": "^[æøåÆØÅa-zA-Z\\- ]*$" + "valueString": "^[\u00e6\u00f8\u00e5\u00c6\u00d8\u00c5a-zA-Z\\- ]*$" } ], "linkId": "aa9b9809-0353-44a8-864c-5c18240c0d64", @@ -906,4 +911,4 @@ "readOnly": true } ] -} \ No newline at end of file +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt index 0d5377fdb3b..db1b29ff0e6 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt @@ -62,11 +62,20 @@ class RegisterPagingSource( paramsMap = actionParameters, ) .map { - rulesExecutor.processResourceData( - repositoryResourceData = it, - params = actionParameters, - rules = registerPagingSourceState.rules, - ) + if (registerPagingSourceState.listProperties.isEmpty()) { + rulesExecutor.processResourceData( + repositoryResourceData = it, + params = actionParameters, + rules = registerPagingSourceState.rules, + ) + } else { + rulesExecutor.processResourceDataWithLists( + repositoryResourceData = it, + params = actionParameters, + rules = registerPagingSourceState.rules, + listProperties = registerPagingSourceState.listProperties, + ) + } } val prevKey = diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index 46ad2c99c06..d75a26c743d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -84,6 +84,7 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { private lateinit var actionParameters: ArrayList private lateinit var viewBinding: QuestionnaireActivityBinding private var alertDialog: AlertDialog? = null + private var previousUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null private lateinit var fusedLocationClient: FusedLocationProviderClient private var currentLocation: Location? = null private val locationPermissionLauncher: ActivityResultLauncher> = @@ -142,6 +143,13 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { return } + // The FHIR SDK's QuestionnaireFragment/QuestionnaireViewModel evaluates config-driven + // FHIRPath expressions (enableWhen, calculatedExpression, etc.) inside its own internal + // coroutine scope. A malformed expression throws there, outside any try/catch we control, + // and would otherwise crash the whole app. Guard against that while this activity is alive. + previousUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this::handleUncaughtQuestionnaireException) + viewBinding.questionnaireToolbar.setNavigationIcon(R.drawable.ic_cancel) viewBinding.questionnaireToolbar.setNavigationOnClickListener { handleBackPress() } viewBinding.questionnaireTitle.text = questionnaireConfig.title @@ -171,6 +179,41 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { ) } + override fun onDestroy() { + Thread.setDefaultUncaughtExceptionHandler(previousUncaughtExceptionHandler) + super.onDestroy() + } + + /** + * Handles exceptions thrown from the FHIR SDK's questionnaire rendering internals (e.g. a + * malformed FHIRPath expression in the Questionnaire config) that would otherwise crash the + * whole app. Anything unrelated to questionnaire rendering is passed on to the previous + * handler so normal crash reporting/behaviour is preserved. + */ + private fun handleUncaughtQuestionnaireException(thread: Thread, throwable: Throwable) { + if (!isQuestionnaireRenderingException(throwable)) { + previousUncaughtExceptionHandler?.uncaughtException(thread, throwable) + return + } + + Timber.e(throwable, "Failed to render questionnaire ${questionnaireConfig.id}") + + runOnUiThread { + AlertDialogue.showAlert( + context = this, + alertIntent = AlertIntent.ERROR, + message = getString(R.string.error_loading_questionnaire_form), + title = getString(R.string.error_loading_questionnaire_form_title), + confirmButton = AlertDialogButton(listener = { finish() }), + ) + } + } + + private fun isQuestionnaireRenderingException(throwable: Throwable): Boolean = + generateSequence(throwable) { it.cause } + .flatMap { it.stackTrace.asSequence() } + .any { it.className.startsWith("com.google.android.fhir.datacapture") } + private fun reviewRecordAudioPermissionToLaunchSpeechToText() { when { PermissionUtils.checkPermissions( @@ -529,6 +572,18 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { // Dismiss progress indicator dialog, submit result then finish activity // TODO Ensure this dialog is dismissed even when an exception is encountered showProgressDialog(QuestionnaireProgressState.ExtractionInProgress(false)) + // StructureMap path must create resources; empty id list means extraction failed. + if ( + idTypes.isEmpty() && + questionnaire.extension.any { + it.url.contains("sdc-questionnaire-targetStructureMap") + } + ) { + Timber.e( + "Not finishing QuestionnaireActivity: StructureMap extraction returned no resources", + ) + return@handleQuestionnaireSubmission + } setResult( Activity.RESULT_OK, Intent().apply { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index 02cf141d894..ff460335936 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -206,14 +206,34 @@ constructor( context = context, ) + val useStructureMap = questionnaire.extractByStructureMap() val bundle = performExtraction( - extractByStructureMap = questionnaire.extractByStructureMap(), + extractByStructureMap = useStructureMap, questionnaire = questionnaire, questionnaireResponse = currentQuestionnaireResponse, context = context, ) + val extractedCount = bundle.entry?.size ?: 0 + Timber.d( + "StructureMap extraction start for Questionnaire/${questionnaire.logicalId}", + ) + // StructureMap extraction must produce at least one resource (e.g. Patient). + // Empty bundle means transform failed or StructureMap missing — do not pretend success. + if (useStructureMap && extractedCount == 0) { + Timber.e( + "StructureMap extraction produced 0 resources for Questionnaire/${questionnaire.logicalId}", + ) + withContext(dispatcherProvider.main()) { + context.showToast( + context.getString(R.string.structuremap_failed, questionnaire.name ?: questionnaire.logicalId), + Toast.LENGTH_LONG, + ) + } + return@async emptyList() + } + defaultRepository.applyDbTransaction { performSave( bundle, @@ -229,8 +249,10 @@ constructor( ?: emptyList() } + val extractedIds = idTypes.await() + // Still invoke callback so the Activity can dismiss progress; it should not finish on empty SM extract. onSuccessfulSubmission( - idTypes.await(), + extractedIds, currentQuestionnaireResponse, ) } @@ -763,16 +785,36 @@ constructor( ): Bundle = runCatching { if (extractByStructureMap) { + val targetUrl = + questionnaire + .getExtensionByUrl( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap", + ) + ?.value + ?.toString() + val structureMapId = targetUrl?.substringAfterLast("/")?.substringBefore("|") + Timber.d( + "StructureMap extraction: target=$targetUrl id=$structureMapId answers=${questionnaireResponse.item.size}", + ) ResourceMapper.extract( questionnaire = questionnaire, questionnaireResponse = questionnaireResponse, structureMapExtractionContext = StructureMapExtractionContext( transformSupportServices = transformSupportServices, - structureMapProvider = { structureMapUrl: String?, _: IWorkerContext -> - structureMapUrl?.substringAfterLast("/")?.let { structureMapId -> - defaultRepository.loadResourceFromCache(structureMapId) + // Reuse the same worker as TransformSupportServices (terminology-safe). + workerContext = transformSupportServices.simpleWorkerContext, + structureMapProvider = { structureMapUrl: String, _: IWorkerContext -> + val id = structureMapUrl.substringAfterLast("/").substringBefore("|") + val sm = defaultRepository.loadResourceFromCache(id) + if (sm == null) { + Timber.e("StructureMap not found in local store for id=$id url=$structureMapUrl") + } else { + Timber.w( + "Loaded StructureMap/$id groups=${sm.group?.size} rules=${sm.group?.firstOrNull()?.rule?.size}", + ) } + sm }, ), ) @@ -784,16 +826,17 @@ constructor( } } .onFailure { exception -> - Timber.e(exception) + Timber.e(exception, "Questionnaire extraction failed") viewModelScope.launch(dispatcherProvider.main()) { - if (exception is NullPointerException && exception.message!!.contains("StructureMap")) { + val msg = exception.message.orEmpty() + if (exception is NullPointerException && msg.contains("StructureMap")) { context.showToast( context.getString(R.string.structure_map_missing_message), Toast.LENGTH_LONG, ) } else { context.showToast( - context.getString(R.string.structuremap_failed, questionnaire.name), + context.getString(R.string.structuremap_failed, questionnaire.name ?: questionnaire.logicalId), Toast.LENGTH_LONG, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index ef0a3d2f8ec..ea715752ed8 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -322,7 +322,7 @@ private fun handleApplyNamedEvent( val labels = options.map { it.title }.toTypedArray() AlertDialog.Builder(context) - .setTitle(actionDisplayOrDefault(computedValuesMap, "Start care")) + .setTitle(actionDisplayOrDefault(computedValuesMap)) .setItems(labels) { _, which -> val selected = options.getOrNull(which) ?: return@setItems launchInterventionOption(navController, selected) @@ -332,9 +332,9 @@ private fun handleApplyNamedEvent( } } -private fun actionDisplayOrDefault(computedValuesMap: Map, default: String): String { +private fun actionDisplayOrDefault(computedValuesMap: Map): String { val fromMap = computedValuesMap["actionDisplay"] as? String - return fromMap?.takeIf { it.isNotBlank() } ?: default + return fromMap?.takeIf { it.isNotBlank() } ?: "Start care" } private fun launchInterventionOption( @@ -343,12 +343,16 @@ private fun launchInterventionOption( ) { val questionnaireId = option.questionnaireId if (!questionnaireId.isNullOrBlank() && navController.context is QuestionnaireHandler) { + Timber.i( + "APPLY_NAMED_EVENT launching Questionnaire/$questionnaireId title=${option.title}", + ) (navController.context as QuestionnaireHandler).launchQuestionnaire( context = navController.context, questionnaireConfig = QuestionnaireConfig( id = questionnaireId, title = option.title, + resourceType = org.hl7.fhir.r4.model.ResourceType.Patient, saveButtonText = "Save", ), actionParams = emptyList(), @@ -356,15 +360,13 @@ private fun launchInterventionOption( return } - // Nested PlanDefinition without a direct Questionnaire: surface title for now; full - // apply-on-select - // can be extended once TRICC emits strategy PDs consistently. + // Should not happen once listInterventions filters to Questionnaire-only options. navController.context.showToast( - "Selected: ${option.title}" + (option.definitionCanonical?.let { " ($it)" } ?: ""), + "No questionnaire for: ${option.title}", Toast.LENGTH_LONG, ) - Timber.i( - "APPLY_NAMED_EVENT selected option id=${option.id} definition=${option.definitionCanonical}", + Timber.e( + "APPLY_NAMED_EVENT option has no Questionnaire id=${option.id} definition=${option.definitionCanonical}", ) } diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 81fe8a3b53d..fd3537e7536 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -111,6 +111,8 @@ Application Version Missing subject type on questionnaire. Provide Questionnaire.subjectType to resolve. QuestionnaireConfig is required but missing. + Unable to load form + This form could not be loaded due to a configuration error. The issue has been reported. Please try again later or contact support if the problem persists. Error populating some questionnaire fields. Invalid QuestionnaireResponse. Processing questionnaire data… Loading questionnaire… diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt new file mode 100644 index 00000000000..706464ff123 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest + +import com.google.android.fhir.datacapture.mapping.ResourceMapper +import com.google.android.fhir.datacapture.mapping.StructureMapExtractionContext +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.context.IWorkerContext +import org.hl7.fhir.r4.context.SimpleWorkerContext +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.StructureMap +import org.hl7.fhir.r4.utils.StructureMapUtilities +import org.junit.Test +import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString +import org.smartregister.fhircore.engine.util.extension.encodeResourceToString +import org.smartregister.fhircore.engine.util.helper.TransformSupportServices +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +class CdssRegistrationDiagnosticTest : RobolectricTest() { + + private val worker = + SimpleWorkerContext().apply { + this.setExpansionProfile(Parameters()) + this.isCanRunWithoutTerminology = true + } + private val transformSupportServices = TransformSupportServices(worker) + + private fun basePath() = + "${System.getProperty("user.dir")}/src/main/assets/configs/cdss/resources" + + private suspend fun extract( + questionnaire: Questionnaire, + structureMap: StructureMap, + questionnaireResponse: QuestionnaireResponse, + ) = + ResourceMapper.extract( + questionnaire = questionnaire, + questionnaireResponse = questionnaireResponse, + structureMapExtractionContext = + StructureMapExtractionContext( + transformSupportServices = transformSupportServices, + structureMapProvider = { _: String?, _: IWorkerContext -> structureMap }, + ), + ) + + @Test + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun compileMapAndExtractAdultAndMinorWithGuardian() = + runTest(timeout = kotlin.time.Duration.parse("90s")) { + val questionnaireJson = + java.io.File("${basePath()}/questionnaire/cdss-client-registration.json").readText() + val mapText = + java.io.File("${basePath()}/structuremap/cdss-client-registration.map").readText() + + val questionnaire = questionnaireJson.decodeResourceFromString() + + val compiled = + StructureMapUtilities(worker, transformSupportServices) + .parse(mapText, "CdssClientRegistration") + .apply { + id = "cdss-client-registration" + url = "https://fhir.opensrp.io/cdss/StructureMap/cdss-client-registration" + name = "CdssClientRegistration" + title = "CDSS client registration" + status = org.hl7.fhir.r4.model.Enumerations.PublicationStatus.ACTIVE + } + + // Persist compiled StructureMap JSON next to the FML so assets/seed stay in sync. + val compiledJson = compiled.encodeResourceToString() + val outFile = java.io.File("${basePath()}/structuremap/cdss-client-registration.json") + outFile.writeText(compiledJson + "\n") + // Mirror under conf/fhir-seed and loose assets copy when present + listOf( + java.io.File( + "${System.getProperty("user.dir")}/../../conf/fhir-seed/resources/StructureMap-cdss-client-registration.json", + ), + java.io.File( + "${System.getProperty("user.dir")}/src/main/assets/resources/structuremap/cdss-client-registration.json", + ), + ) + .forEach { dest -> + dest.parentFile?.mkdirs() + if (dest.parentFile?.exists() == true) { + dest.writeText(compiledJson + "\n") + } + } + + println("COMPILED STRUCTUREMAP written to ${outFile.absolutePath}") + println("groups=${compiled.group.size} topRules=${compiled.groupFirstRep.rule.size}") + + // Adult, no guardian — must extract at least Patient. + val adultResponse = + """ + { + "resourceType": "QuestionnaireResponse", + "questionnaire": "https://fhir.opensrp.io/cdss/Questionnaire/cdss-client-registration", + "status": "completed", + "item": [ + {"linkId": "Ccc.A.DE01", "answer": [{"valueString": "ID-12345"}]}, + {"linkId": "Ccc.A.DE04", "answer": [{"valueString": "Jane"}]}, + {"linkId": "Ccc.A.DE05", "answer": [{"valueString": "M"}]}, + {"linkId": "Ccc.A.DE06", "answer": [{"valueString": "Doe"}]}, + {"linkId": "Ccc.A.DE08", "answer": [{"valueDate": "1990-01-15"}]}, + {"linkId": "Ccc.A.DE16", "answer": [{"valueCoding": {"code": "Ccc.A.DE17"}}]} + ] + } + """ + .trimIndent() + .decodeResourceFromString() + + val adultBundle = extract(questionnaire, compiled, adultResponse) + println("ADULT ENTRY COUNT = ${adultBundle.entry.size}") + println(adultBundle.encodeResourceToString()) + org.junit.Assert.assertTrue( + "Adult registration must extract at least one Patient", + adultBundle.entry.any { it.resource is org.hl7.fhir.r4.model.Patient }, + ) + + // Minor with guardian info + new related person. + val minorResponse = + """ + { + "resourceType": "QuestionnaireResponse", + "questionnaire": "https://fhir.opensrp.io/cdss/Questionnaire/cdss-client-registration", + "status": "completed", + "item": [ + {"linkId": "Ccc.A.DE01", "answer": [{"valueString": "ID-99999"}]}, + {"linkId": "Ccc.A.DE04", "answer": [{"valueString": "Timmy"}]}, + {"linkId": "Ccc.A.DE06", "answer": [{"valueString": "Doe"}]}, + {"linkId": "Ccc.A.DE08", "answer": [{"valueDate": "2020-05-01"}]}, + {"linkId": "Ccc.A.DE16", "answer": [{"valueCoding": {"code": "Ccc.A.DE18"}}]}, + {"linkId": "newrelatedperson", "answer": [{"valueCoding": {"code": "newrelatedperson"}}]}, + {"linkId": "relatedpersonuuid", "answer": [{"valueString": "guardian-uuid-1"}]}, + { + "linkId": "Ccc.A.DE21.1", + "item": [ + {"linkId": "Ccc.A.DE21", "answer": [{"valueString": "Jill"}]}, + {"linkId": "Ccc.A.DE23", "answer": [{"valueString": "Smith"}]}, + {"linkId": "Ccc.A.DE35", "answer": [{"valueString": "+123456789"}]}, + {"linkId": "Ccc.A.DE24", "answer": [{"valueCoding": {"code": "Ccc.A.DE25"}}]} + ] + } + ] + } + """ + .trimIndent() + .decodeResourceFromString() + + val minorBundle = extract(questionnaire, compiled, minorResponse) + println("MINOR ENTRY COUNT = ${minorBundle.entry.size}") + println(minorBundle.encodeResourceToString()) + org.junit.Assert.assertTrue( + "Minor registration must extract Patient resources", + minorBundle.entry.count { it.resource is org.hl7.fhir.r4.model.Patient } >= 1, + ) + } +} From 9b04a6d3258b3d6d87e85db8d384afb9f0f3ab62 Mon Sep 17 00:00:00 2001 From: delcroip Date: Wed, 12 Aug 2026 15:07:02 +0200 Subject: [PATCH 06/11] chore: better logging approach, covers more possible crashes --- .../engine/task/WorkflowCarePlanGenerator.kt | 13 +- .../util/extension/RelatedPersonAsPatient.kt | 15 +- .../ui/questionnaire/QuestionnaireActivity.kt | 132 +++++++++++------- .../quest/ui/register/RegisterViewModel.kt | 4 +- 4 files changed, 93 insertions(+), 71 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt index 00029163097..519c16c07e2 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt @@ -24,8 +24,8 @@ import com.google.android.fhir.workflow.FhirOperator import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton -import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.jvm.isAccessible + import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.hl7.fhir.instance.model.api.IBaseResource @@ -211,8 +211,7 @@ constructor( } /** - * Invokes the respective [RequestResourceManager] to create new request resources as per the - * proposed [CarePlan] + * Classifies and creates new request resources as per the proposed [CarePlan] * * @param resourceList List of request resources to be created * @param persist Whether matched resources should be persisted via [DefaultRepository]. When @@ -291,12 +290,4 @@ constructor( else -> CarePlan.CarePlanActivityStatus.NULL } } - - private inline fun getPrivateProperty(property: String, obj: T): Any? { - return T::class - .declaredMemberProperties - .find { it.name == property }!! - .apply { isAccessible = true } - .get(obj) - } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt index 1bff4a7e553..702d0ba6807 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt @@ -20,7 +20,6 @@ import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.RelatedPerson -import org.hl7.fhir.r4.model.Resource /** * TRICC / flexible client register convention: mother, father and guardian always exist as @@ -160,7 +159,7 @@ private fun patientLinkTypeDisplay(typeCode: String): String = } /** - * Groups [relatedPersons] by guardian Patient logical id using + * Groups this list of [RelatedPerson]s by guardian Patient logical id using * [RelatedPerson.guardianPatientReference]. */ fun List.groupByGuardianPatientId(): Map> { @@ -172,9 +171,6 @@ fun List.groupByGuardianPatientId(): Map.asRelatedPersons(): List = mapNotNull { it as? RelatedPerson } - /** * Parses [this] as a Patient reference or absolute Patient URL into normalized `Patient/{id}`. * @@ -187,12 +183,9 @@ fun String.patientReferenceFromIdentifierValue(): String? { val trimmed = trim() if (trimmed.isEmpty()) return null - val afterPatient = - when { - trimmed.contains("Patient/", ignoreCase = true) -> - trimmed.substringAfter("Patient/", "").substringAfter("patient/", "") - else -> return null - } + val delimiterIndex = trimmed.indexOf("Patient/", ignoreCase = true) + if (delimiterIndex == -1) return null + val afterPatient = trimmed.substring(delimiterIndex + "Patient/".length) val logicalId = afterPatient .substringBefore("/") diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index d75a26c743d..c10ac55a024 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -39,6 +39,7 @@ import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.QuestionnaireFragment +import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import dagger.hilt.android.AndroidEntryPoint @@ -73,6 +74,9 @@ import org.smartregister.fhircore.quest.ui.shared.ON_RESULT_TYPE import org.smartregister.fhircore.quest.util.ResourceUtils import timber.log.Timber +/** Wraps any failure from the questionnaire rendering pipeline so it can be handled uniformly. */ +class QuestionnaireRenderException(message: String, cause: Throwable) : Exception(message, cause) + @AndroidEntryPoint class QuestionnaireActivity : BaseMultiLanguageActivity() { @@ -164,7 +168,13 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { } if (savedInstanceState == null) { - lifecycleScope.launch { launchQuestionnaire() } + lifecycleScope.launch { + try { + launchQuestionnaire() + } catch (e: Exception) { + handleQuestionnaireRenderingFailure(e) + } + } } setupLocationServices() @@ -195,9 +205,22 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { previousUncaughtExceptionHandler?.uncaughtException(thread, throwable) return } + // This runs after the SDK Fragment's exception has already unwound Looper.loop(), so the + // main thread's message queue is dead: Timber.e below still fires (it's a direct call, not + // posted), but the recovery UI below is a best-effort fallback only, not a guarantee - the + // process is typically killed right after this handler returns. Prefer catching failures + // earlier (see launchQuestionnaire/getQuestionnaireFragmentBuilder) whenever possible. + handleQuestionnaireRenderingFailure(throwable) + } - Timber.e(throwable, "Failed to render questionnaire ${questionnaireConfig.id}") + private fun isQuestionnaireRenderingException(throwable: Throwable): Boolean = + generateSequence(throwable) { it.cause } + .flatMap { it.stackTrace.asSequence() } + .any { it.className.startsWith("com.google.android.fhir.datacapture") } + /** Single choke point for questionnaire rendering failures: log to Timber, tell the user. */ + private fun handleQuestionnaireRenderingFailure(throwable: Throwable) { + Timber.e(throwable, "Failed to render questionnaire ${questionnaireConfig.id}") runOnUiThread { AlertDialogue.showAlert( context = this, @@ -209,11 +232,6 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { } } - private fun isQuestionnaireRenderingException(throwable: Throwable): Boolean = - generateSequence(throwable) { it.cause } - .flatMap { it.stackTrace.asSequence() } - .any { it.className.startsWith("com.google.android.fhir.datacapture") } - private fun reviewRecordAudioPermissionToLaunchSpeechToText() { when { PermissionUtils.checkPermissions( @@ -376,44 +394,48 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { viewModel.questionnaireFormUpdateStateflow.collect { when (it) { is QuestionnaireFormUpdate.ShowSpeechToTextSubView -> { - viewBinding.recordSpeechActionButton.visibility = View.GONE - viewBinding.editFormActionButton.visibility = View.VISIBLE - viewBinding.speechToTextContainer.visibility = View.VISIBLE - renderSpeechToTextFragment() - val disabledQuestionnaire = - questionnaire.copy().apply { item.forEach(viewModel::disableQuestionnaireItem) } - - renderQuestionnaire( - disabledQuestionnaire, - it.currentQuestionnaireResponse, - launchContextResources, - ) + try { + viewBinding.recordSpeechActionButton.visibility = View.GONE + viewBinding.editFormActionButton.visibility = View.VISIBLE + viewBinding.speechToTextContainer.visibility = View.VISIBLE + renderSpeechToTextFragment() + val disabledQuestionnaire = + questionnaire.copy().apply { item.forEach(viewModel::disableQuestionnaireItem) } - // Disable form buttons - very hacky - handler.postDelayed( - { - supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG)?.view?.let { - fragmentView -> - fragmentView - .findViewById(com.google.android.fhir.datacapture.R.id.submit_questionnaire) - ?.isEnabled = false - fragmentView - .findViewById(com.google.android.fhir.datacapture.R.id.cancel_questionnaire) - ?.isEnabled = false - fragmentView - .findViewById( - com.google.android.fhir.datacapture.R.id.pagination_previous_button, - ) - ?.isEnabled = false - fragmentView - .findViewById( - com.google.android.fhir.datacapture.R.id.pagination_next_button, - ) - ?.isEnabled = false - } - }, - 200, - ) + renderQuestionnaire( + disabledQuestionnaire, + it.currentQuestionnaireResponse, + launchContextResources, + ) + + // Disable form buttons - very hacky + handler.postDelayed( + { + supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG)?.view?.let { + fragmentView -> + fragmentView + .findViewById(com.google.android.fhir.datacapture.R.id.submit_questionnaire) + ?.isEnabled = false + fragmentView + .findViewById(com.google.android.fhir.datacapture.R.id.cancel_questionnaire) + ?.isEnabled = false + fragmentView + .findViewById( + com.google.android.fhir.datacapture.R.id.pagination_previous_button, + ) + ?.isEnabled = false + fragmentView + .findViewById( + com.google.android.fhir.datacapture.R.id.pagination_next_button, + ) + ?.isEnabled = false + } + }, + 200, + ) + } catch (e: Exception) { + handleQuestionnaireRenderingFailure(e) + } } is QuestionnaireFormUpdate.ShowQuestionnaireResponse -> { viewBinding.speechToTextContainer.visibility = View.GONE @@ -431,9 +453,8 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { 200, ) } - } catch (e: IllegalArgumentException) { - Timber.e(e) - showToast(e.message.toString()) + } catch (e: Exception) { + handleQuestionnaireRenderingFailure(e) } finally { removeSpeechToTextFragment() } @@ -486,6 +507,23 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { .setShowSubmitAnywayButton(questionnaireConfig.showSubmitAnywayButton.toBooleanStrict()) .apply { if (questionnaireResponse != null) { + // com.google.android.fhir.datacapture.QuestionnaireViewModel runs this same + // structural check in its constructor, on the SDK Fragment's own lazy-viewModel + // path - an exception there escapes outside any try/catch we control and crashes + // the app. Run it ourselves first, on our own stack, so a mismatch is a normal + // catchable QuestionnaireRenderException instead. + try { + QuestionnaireResponseValidator.checkQuestionnaireResponse( + questionnaire, + questionnaireResponse, + ) + } catch (e: Exception) { + throw QuestionnaireRenderException( + "QuestionnaireResponse is structurally inconsistent with Questionnaire ${questionnaire.id}", + e, + ) + } + questionnaireResponse .takeIf { viewModel.validateQuestionnaireResponse( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt index 3af370c00bd..c94f24dad85 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt @@ -237,13 +237,13 @@ constructor( val searchBar = registerUiState.value.registerConfiguration?.searchBar val registerId = registerUiState.value.registerId if (!searchBar?.dataFilterFields.isNullOrEmpty()) { - val dataFilterFields = searchBar?.dataFilterFields + val dataFilterFields = searchBar.dataFilterFields updateRegisterFilterState( registerId = registerId, questionnaireResponse = constructSearchQuestionnaireResponse( searchText = searchText, - dataFilterFields = searchBar?.dataFilterFields ?: emptyList(), + dataFilterFields = searchBar.dataFilterFields ?: emptyList(), ), dataFilterFields = dataFilterFields, ) From 58c54c137066c4c1fd0a5b08e52c3b827558f50b Mon Sep 17 00:00:00 2001 From: delcroip Date: Thu, 13 Aug 2026 12:06:43 +0200 Subject: [PATCH 07/11] feat: change the start care approach to be able to run several care in // --- .../task/NamedEventInterventionService.kt | 105 ++++++++- .../di/NamedEventInterventionEntryPoint.kt | 8 + .../quest/ui/profile/ProfileFragment.kt | 16 +- .../quest/util/extensions/ConfigExtensions.kt | 217 ++++++++++++++++-- 4 files changed, 316 insertions(+), 30 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt index de7caae9091..d9fcce526b4 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt @@ -25,10 +25,12 @@ import javax.inject.Singleton import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.PlanDefinition import org.hl7.fhir.r4.model.RequestGroup import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.TriggerDefinition import org.hl7.fhir.r4.utils.FHIRPathEngine import org.smartregister.fhircore.engine.util.extension.asReference @@ -36,6 +38,14 @@ import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import timber.log.Timber +/** + * Extension URL suffixes stamped by TRICC on each Intervention PD's per-process action — see + * `feature/20260812-intervention-order-and-dedup.md` (tricc). Matched by suffix, not exact URL, + * since the base URL is per-project configurable at export time. + */ +private const val TRICC_PROCESS_EXT_SUFFIX = "tricc-process" +private const val TRICC_PROCESS_ORDER_EXT_SUFFIX = "tricc-process-order" + /** * Discovers synced PlanDefinitions by named-event trigger, evaluates applicability (trigger + * conditions), and returns intervention options that can launch a **Questionnaire**. Catalog is @@ -59,8 +69,70 @@ constructor( val definitionCanonical: String? = null, val planDefinitionId: String? = null, val questionnaireId: String? = null, + /** + * cpg-common-process order (10, 20, 30…), read from the `tricc-process-order` extension — + * see `feature/20260812-intervention-order-and-dedup.md` (tricc). Fixed/canonical, so it is + * comparable across options originating from different PlanDefinitions. Options without the + * extension (e.g. resolved via workflow `$apply`, which may not propagate custom action + * extensions onto the RequestGroup) sort last. + */ + val order: Int = Int.MAX_VALUE, + /** cpg-common-process name, read from the `tricc-process` extension, for display/debug. */ + val process: String? = null, ) + /** + * One row of the "select available care" picker: a PlanDefinition carrying the named-event + * trigger, together with its own already-resolved (`$apply`/valid-action) options. Grouping by + * originating PlanDefinition — rather than the flat per-questionnaire list [listInterventions] + * returns — lets the picker show one checkbox per care option, and lets the caller consolidate + * several *selected* PDs' options together afterwards without re-running `$apply`. + */ + data class AvailableCarePlan( + val planDefinitionId: String, + val title: String, + val options: List, + ) + + /** + * Returns one [AvailableCarePlan] per PlanDefinition matching [namedEvent] that has at least + * one launchable (Questionnaire-resolvable) option for [subjectPatientId] — i.e. exactly the + * set of rows the "select available care" picker should list, each with its own checkbox. + * PlanDefinitions with no launchable option are omitted (nothing to check). + */ + suspend fun listAvailableCarePlans( + namedEvent: String, + subjectPatientId: String, + ): List { + val patientId = subjectPatientId.extractLogicalIdUuid() + val patient = + runCatching { fhirEngine.get(patientId) } + .onFailure { Timber.e(it, "Patient/$patientId not found for named-event apply") } + .getOrNull() ?: return emptyList() + + val matching = loadPlanDefinitions().filter { it.hasNamedEventTrigger(namedEvent) } + if (matching.isEmpty()) { + Timber.i("No PlanDefinitions with named-event '$namedEvent'") + return emptyList() + } + + return matching.mapNotNull { planDefinition -> + val options = linkedMapOf() + collectFromPlanDefinition(planDefinition, namedEvent, patient, options) + val launchable = + options.values.filter { !it.questionnaireId.isNullOrBlank() }.sortedBy { it.order } + if (launchable.isEmpty()) { + null + } else { + AvailableCarePlan( + planDefinitionId = planDefinition.logicalId, + title = planDefinition.title ?: planDefinition.name ?: "Care", + options = launchable, + ) + } + } + } + /** * Returns applicable interventions for [subjectPatientId] whose PlanDefinition actions declare * named-event [namedEvent] (default `available-care`). @@ -105,14 +177,19 @@ constructor( ) } - // Only launchable questionnaires — drop Task/AD/#fragment or empty apply results + // Only launchable questionnaires — drop Task/AD/#fragment or empty apply results. + // Sorted by cpg-common-process order (stable — ties keep discovery/insertion order) so a + // caller juggling several selected PlanDefinitions can pick the lowest-order option overall. val launchable = - options.values.filter { !it.questionnaireId.isNullOrBlank() }.also { list -> - Timber.i( - "Named-event '$namedEvent': ${list.size} launchable intervention(s) " + - "(${options.size} raw option(s) before Questionnaire filter)", - ) - } + options.values + .filter { !it.questionnaireId.isNullOrBlank() } + .sortedBy { it.order } + .also { list -> + Timber.i( + "Named-event '$namedEvent': ${list.size} launchable intervention(s) " + + "(${options.size} raw option(s) before Questionnaire filter)", + ) + } return launchable } @@ -303,9 +380,23 @@ constructor( definitionCanonical = definition, planDefinitionId = nestedPlanId ?: planDefinition.logicalId, questionnaireId = questionnaireId, + order = triccProcessOrder() ?: Int.MAX_VALUE, + process = triccProcessName(), ) } + private fun PlanDefinition.PlanDefinitionActionComponent.triccProcessOrder(): Int? = + extension + .firstOrNull { it.url?.endsWith(TRICC_PROCESS_ORDER_EXT_SUFFIX) == true } + ?.value + ?.let { (it as? IntegerType)?.value } + + private fun PlanDefinition.PlanDefinitionActionComponent.triccProcessName(): String? = + extension + .firstOrNull { it.url?.endsWith(TRICC_PROCESS_EXT_SUFFIX) == true } + ?.value + ?.let { (it as? StringType)?.value } + private fun RequestGroup.RequestGroupActionComponent.toInterventionOption( planDefinition: PlanDefinition, ): InterventionOption? { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt index 0800cc5b0e7..50a9c555907 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt @@ -20,9 +20,17 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.smartregister.fhircore.engine.task.NamedEventInterventionService +import org.smartregister.fhircore.quest.event.EventBus @EntryPoint @InstallIn(SingletonComponent::class) interface NamedEventInterventionEntryPoint { fun namedEventInterventionService(): NamedEventInterventionService + + /** + * Used by the "select available care" picker to react to questionnaire submissions (advance + * to the next lowest-order due action, learn the current-visit Encounter id) — see + * `feature/20260812-intervention-order-and-dedup.md` (tricc) and the companion Android spec. + */ + fun eventBus(): EventBus } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt index 25756100884..5595490bd8f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt @@ -108,8 +108,20 @@ class ProfileFragment : Fragment() { eventBus.events .getFor(MainNavigationScreen.Profile.eventId(profileFragmentArgs.profileId)) .onEach { appEvent -> - if (appEvent is AppEvent.OnSubmitQuestionnaire) { - handleQuestionnaireSubmission(appEvent.questionnaireSubmission) + when (appEvent) { + is AppEvent.OnSubmitQuestionnaire -> + handleQuestionnaireSubmission(appEvent.questionnaireSubmission) + is AppEvent.RefreshData -> { + with(profileFragmentArgs) { + profileViewModel.retrieveProfileUiState( + requireContext(), + profileId, + resourceId, + resourceConfig, + params, + ) + } + } } } .launchIn(viewLifecycleOwner.lifecycleScope) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index ea715752ed8..38ab2c591bf 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -34,8 +34,12 @@ import androidx.navigation.NavController import androidx.navigation.NavOptions import com.google.android.fhir.FhirEngine import dagger.hilt.android.EntryPointAccessors +import java.util.UUID import kotlin.collections.set +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import org.hl7.fhir.r4.model.Binary import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.navigation.ICON_TYPE_REMOTE @@ -66,6 +70,8 @@ import org.smartregister.fhircore.engine.util.extension.loadResource import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.di.NamedEventInterventionEntryPoint +import org.smartregister.fhircore.quest.event.AppEvent +import org.smartregister.fhircore.quest.event.EventBus import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.navigation.NavigationArg import org.smartregister.fhircore.quest.ui.pdf.PdfLauncherFragment @@ -302,33 +308,34 @@ private fun handleApplyNamedEvent( return } - val service = + val entryPoint = EntryPointAccessors.fromApplication( - context.applicationContext, - NamedEventInterventionEntryPoint::class.java, - ) - .namedEventInterventionService() + context.applicationContext, + NamedEventInterventionEntryPoint::class.java, + ) + val service = entryPoint.namedEventInterventionService() lifecycleOwner.lifecycleScope.launch { - val options = - runCatching { service.listInterventions(namedEvent, subjectId) } - .onFailure { Timber.e(it, "Failed to list interventions for event=$namedEvent") } + val plans = + runCatching { service.listAvailableCarePlans(namedEvent, subjectId) } + .onFailure { Timber.e(it, "Failed to list available care plans for event=$namedEvent") } .getOrDefault(emptyList()) - if (options.isEmpty()) { + if (plans.isEmpty()) { context.showToast("No care available for this client", Toast.LENGTH_LONG) return@launch } - val labels = options.map { it.title }.toTypedArray() - AlertDialog.Builder(context) - .setTitle(actionDisplayOrDefault(computedValuesMap)) - .setItems(labels) { _, which -> - val selected = options.getOrNull(which) ?: return@setItems - launchInterventionOption(navController, selected) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + showAvailableCarePicker( + context = context, + navController = navController, + service = service, + eventBus = entryPoint.eventBus(), + namedEvent = namedEvent, + subjectId = subjectId, + plans = plans, + title = actionDisplayOrDefault(computedValuesMap), + ) } } @@ -337,15 +344,183 @@ private fun actionDisplayOrDefault(computedValuesMap: Map): String return fromMap?.takeIf { it.isNotBlank() } ?: "Start care" } +/** + * One checkbox per PlanDefinition that carries the named-event trigger and has at least one + * valid ($apply-resolved) action, plus a Start button — per + * `feature/20260812-intervention-order-and-dedup.md`'s companion Android spec. All rows default + * checked (everything listed is already eligible); Start begins the ordered launch sequence for + * whichever rows remain checked. + */ +private fun showAvailableCarePicker( + context: Context, + navController: NavController, + service: NamedEventInterventionService, + eventBus: EventBus, + namedEvent: String, + subjectId: String, + plans: List, + title: String, +) { + val labels = plans.map { it.title }.toTypedArray() + val checked = BooleanArray(plans.size) { true } + val selectedIndices = plans.indices.toMutableSet() + AlertDialog.Builder(context) + .setTitle(title) + .setMultiChoiceItems(labels, checked) { _, which, isChecked -> + if (isChecked) selectedIndices.add(which) else selectedIndices.remove(which) + } + .setPositiveButton("Start") { _, _ -> + val selectedPlans = selectedIndices.mapNotNull { plans.getOrNull(it) } + if (selectedPlans.isEmpty()) return@setPositiveButton + val session = + AvailableCareSession( + namedEvent = namedEvent, + subjectId = subjectId, + selectedPlanIds = selectedPlans.map { it.planDefinitionId }.toSet(), + initialPlans = selectedPlans, + ) + advanceAvailableCareSession(context, navController, service, eventBus, session) + } + .setNegativeButton(android.R.string.cancel, null) + .show() +} + +/** + * Tracks one "select available care" run across its whole sequence of launches: which PDs the + * user checked, which questionnaires are already submitted this session (so a re-`$apply` never + * re-shows something just completed — no PD-level applicability condition exists yet, see + * `feature/careplan-intervention-plandefinition.md` §26), and the current-visit Encounter id + * once known (learned from the first submission that produced one). + */ +private class AvailableCareSession( + val namedEvent: String, + val subjectId: String, + val selectedPlanIds: Set, + initialPlans: List, +) { + /** Unique per session so [EventBus]'s one-time-per-consumer delivery doesn't cross sessions. */ + val consumerId: String = UUID.randomUUID().toString() + val submittedQuestionnaireIds: MutableSet = mutableSetOf() + var encounterId: String? = null + + /** + * The picker's own already-computed `$apply` result, reused for exactly the first batch + * ("should have been saved, no need to run it again") — cleared after first use so every + * subsequent batch re-runs `$apply` for real (an earlier submission may have unlocked a + * lower-order action). + */ + var cachedPlans: List? = initialPlans +} + +/** Consolidates the selected PDs' current options, finds the lowest order still due. */ +private suspend fun resolveNextBatch( + service: NamedEventInterventionService, + session: AvailableCareSession, +): List { + val plans = + session.cachedPlans + ?: runCatching { service.listAvailableCarePlans(session.namedEvent, session.subjectId) } + .onFailure { Timber.e(it, "Failed to re-apply available-care PlanDefinitions") } + .getOrDefault(emptyList()) + session.cachedPlans = null + + val consolidated = + plans + .filter { it.planDefinitionId in session.selectedPlanIds } + .flatMap { it.options } + .filterNot { session.submittedQuestionnaireIds.contains(it.questionnaireId) } + .distinctBy { it.questionnaireId } + val lowestOrder = consolidated.minOfOrNull { it.order } ?: return emptyList() + return consolidated.filter { it.order == lowestOrder } +} + +/** + * Launches the next lowest-order due questionnaire(s) from [session]'s selected PDs, then waits + * for its submission (via [EventBus]) to advance again. Ends silently once nothing is left due. + * + * Note: if the user backs out of the launched Questionnaire without submitting, no event fires + * (matches today's `AppMainActivity.onSubmitQuestionnaire`, which only triggers on `RESULT_OK`) + * and this session simply stops advancing — bounded by [LifecycleOwner]'s own scope cancellation, + * not a leak, but the user would need to re-open the picker to resume. + */ +private fun advanceAvailableCareSession( + context: Context, + navController: NavController, + service: NamedEventInterventionService, + eventBus: EventBus, + session: AvailableCareSession, +) { + val lifecycleOwner = context as? LifecycleOwner ?: return + lifecycleOwner.lifecycleScope.launch { + val batch = resolveNextBatch(service, session) + if (batch.isEmpty()) { + Timber.i("Available-care session for subject=${session.subjectId} complete") + return@launch + } + val chosen = if (batch.size == 1) batch.first() else awaitTieBreakChoice(context, batch) + val chosenQuestionnaireId = chosen?.questionnaireId + if (chosen == null || chosenQuestionnaireId.isNullOrBlank()) return@launch + + launchInterventionOption(navController, chosen, session.encounterId) + + val submission = + eventBus.events + .getFor(session.consumerId) + .filterIsInstance() + .first { it.questionnaireSubmission.questionnaireConfig.id == chosenQuestionnaireId } + .questionnaireSubmission + + session.submittedQuestionnaireIds.add(chosenQuestionnaireId) + if (session.encounterId == null && submission.questionnaireResponse.hasEncounter()) { + session.encounterId = + submission.questionnaireResponse.encounter.reference?.extractLogicalIdUuid() + } + advanceAvailableCareSession(context, navController, service, eventBus, session) + } +} + +/** Single-choice fallback when more than one option ties at the lowest order. */ +private suspend fun awaitTieBreakChoice( + context: Context, + batch: List, +): NamedEventInterventionService.InterventionOption? = suspendCancellableCoroutine { cont -> + val labels = batch.map { it.title }.toTypedArray() + val dialog = + AlertDialog.Builder(context) + .setTitle("Choose one") + .setItems(labels) { _, which -> cont.resume(batch.getOrNull(which)) {} } + .setOnCancelListener { cont.resume(null) {} } + .show() + cont.invokeOnCancellation { dialog.dismiss() } +} + private fun launchInterventionOption( navController: NavController, option: NamedEventInterventionService.InterventionOption, + encounterId: String? = null, ) { val questionnaireId = option.questionnaireId if (!questionnaireId.isNullOrBlank() && navController.context is QuestionnaireHandler) { Timber.i( - "APPLY_NAMED_EVENT launching Questionnaire/$questionnaireId title=${option.title}", + "APPLY_NAMED_EVENT launching Questionnaire/$questionnaireId title=${option.title} " + + "order=${option.order} encounter=$encounterId", ) + val actionParams = + if (encounterId.isNullOrBlank()) { + emptyList() + } else { + // Makes the current-visit Encounter id available to CQL as the `encounterid` library + // parameter (see `feature/20260812-intervention-order-and-dedup.md`, tricc) so the + // dedup `initialExpression`s it wires up can actually resolve. + listOf( + ActionParameter( + key = "encounter", + paramType = ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE, + value = encounterId, + resourceType = org.hl7.fhir.r4.model.ResourceType.Encounter, + ), + ) + } (navController.context as QuestionnaireHandler).launchQuestionnaire( context = navController.context, questionnaireConfig = @@ -355,12 +530,12 @@ private fun launchInterventionOption( resourceType = org.hl7.fhir.r4.model.ResourceType.Patient, saveButtonText = "Save", ), - actionParams = emptyList(), + actionParams = actionParams, ) return } - // Should not happen once listInterventions filters to Questionnaire-only options. + // Should not happen once listAvailableCarePlans filters to Questionnaire-only options. navController.context.showToast( "No questionnaire for: ${option.title}", Toast.LENGTH_LONG, From 245fe2812e7015a4cf02fcbddffcff1c2db0e394 Mon Sep 17 00:00:00 2001 From: delcroip Date: Mon, 17 Aug 2026 16:55:31 +0200 Subject: [PATCH 08/11] feat: add config update in settings --- .../configuration/ConfigurationRegistry.kt | 37 ++++++++--- .../app/ApplicationConfiguration.kt | 2 + .../ConfigurationRegistryTest.kt | 25 +++++++ .../ui/usersetting/UserSettingScreenTest.kt | 19 ++++++ .../ui/usersetting/UserSettingFragment.kt | 8 +++ .../quest/ui/usersetting/UserSettingScreen.kt | 66 +++++++++++++++++++ .../ui/usersetting/UserSettingViewModel.kt | 43 ++++++++++++ .../quest/ui/usersetting/UserSettingsEvent.kt | 4 ++ .../usersetting/UserSettingViewModelTest.kt | 53 +++++++++++++++ 9 files changed, 247 insertions(+), 10 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index f691e300520..09ee2d70371 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -438,7 +438,7 @@ constructor( * Type'?_id='comma,separated,list,of,ids' */ @Throws(UnknownHostException::class, HttpException::class) - suspend fun fetchNonWorkflowConfigResources() { + suspend fun fetchNonWorkflowConfigResources(forceRefresh: Boolean = false) { Timber.d("Triggered fetching application configurations remotely") configCacheMap.clear() sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)?.let { appId -> @@ -468,7 +468,7 @@ constructor( } } - processCompositionSectionComponent(sectionComponentMap) + processCompositionSectionComponent(sectionComponentMap, forceRefresh) // Save composition after fetching all the referenced section resources addOrUpdate(compositionResource) @@ -480,12 +480,13 @@ constructor( private suspend fun processCompositionSectionComponent( sectionComponentMap: Map>, + forceRefresh: Boolean = false, ) { sectionComponentMap .filter { entry -> entry.key in FILTER_RESOURCE_LIST } .forEach { entry: Map.Entry> -> if (entry.key == ResourceType.List.name) { - processCompositionListResources(entry) + processCompositionListResources(entry, forceRefresh) } else { val chunkedResourceIdList = entry.value.chunked(MANIFEST_PROCESSOR_BATCH_SIZE) @@ -501,6 +502,7 @@ constructor( resourceType = entry.key, resourceIdList = sectionComponents.map { sectionComponent -> sectionComponent.focus.extractId() }, + forceRefresh = forceRefresh, ) } } @@ -557,14 +559,15 @@ constructor( private suspend fun fetchResources( resourceType: String, resourceIdList: List, + forceRefresh: Boolean = false, ): Bundle { val resultBundle = if (isNonProxy()) { - fhirResourceDataSourceGetBundle(resourceType, resourceIdList) + fhirResourceDataSourceGetBundle(resourceType, resourceIdList, forceRefresh) } else { fhirResourceDataSource.post( requestBody = - generateRequestBundle(resourceType, resourceIdList) + generateRequestBundle(resourceType, resourceIdList, forceRefresh) .encodeResourceToString() .toRequestBody(NetworkModule.JSON_MEDIA_TYPE), ) @@ -705,7 +708,11 @@ constructor( } @VisibleForTesting - fun generateRequestBundle(resourceType: String, idList: List): Bundle { + fun generateRequestBundle( + resourceType: String, + idList: List, + forceRefresh: Boolean = false, + ): Bundle { val bundleEntryComponents = mutableListOf() idList.forEach { @@ -713,7 +720,7 @@ constructor( Bundle.BundleEntryComponent().apply { request = Bundle.BundleEntryRequestComponent().apply { - url = "$resourceType?$ID=$it${getLastConfigUpdatedTimestampParam(resourceType, it)}" + url = "$resourceType?$ID=$it${lastUpdatedQuery(resourceType, it, forceRefresh)}" method = Bundle.HTTPVerb.GET } }, @@ -735,9 +742,16 @@ constructor( return if (timestamp.isNotEmpty()) "&$LAST_UPDATED_KEY=$GREATER_THAN_PREFIX$timestamp" else "" } + private fun lastUpdatedQuery( + resourceType: String, + resourceId: String, + forceRefresh: Boolean, + ): String = if (forceRefresh) "" else getLastConfigUpdatedTimestampParam(resourceType, resourceId) + private suspend fun fhirResourceDataSourceGetBundle( resourceType: String, resourceIds: List, + forceRefresh: Boolean = false, ): Bundle = Bundle().apply { type = Bundle.BundleType.COLLECTION @@ -746,7 +760,7 @@ constructor( .map { fhirResourceDataSource .getResource( - "$resourceType?${Composition.SP_RES_ID}=$it${getLastConfigUpdatedTimestampParam(resourceType, it)}", + "$resourceType?${Composition.SP_RES_ID}=$it${lastUpdatedQuery(resourceType, it, forceRefresh)}", ) .entry } @@ -755,6 +769,7 @@ constructor( private suspend fun processCompositionListResources( sectionComponentEntry: Map.Entry>, + forceRefresh: Boolean = false, ) { if (isNonProxy()) { val chunkedResourceIdList = sectionComponentEntry.value.chunked(MANIFEST_PROCESSOR_BATCH_SIZE) @@ -762,6 +777,7 @@ constructor( fetchResources( resourceType = sectionComponentEntry.key, resourceIdList = it.map { sectionComponent -> sectionComponent.focus.extractId() }, + forceRefresh = forceRefresh, ) .entry .forEach { bundleEntryComponent -> @@ -903,7 +919,8 @@ constructor( * These are hardcoded as they are not meant to be easily configurable to avoid config vs data * sync issues */ - private val FILTER_RESOURCE_LIST = + @VisibleForTesting + internal val FILTER_RESOURCE_LIST = listOf( ResourceType.Questionnaire.name, ResourceType.StructureMap.name, @@ -913,7 +930,7 @@ constructor( ResourceType.Measure.name, ResourceType.Basic.name, ResourceType.Binary.name, - ResourceType.Parameters, + ResourceType.Parameters.name, ) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index 8ea762fc995..9ec6f5b1d9b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -47,6 +47,7 @@ data class ApplicationConfiguration( val settingsScreenMenuOptions: List = listOf( SettingsOptions.MANUAL_SYNC, + SettingsOptions.SYNC_CONFIGURATION, SettingsOptions.SWITCH_LANGUAGES, SettingsOptions.RESET_DATA, SettingsOptions.INSIGHTS, @@ -77,6 +78,7 @@ enum class LocationLogOptions { enum class SettingsOptions { MANUAL_SYNC, + SYNC_CONFIGURATION, OFFLINE_MAPS, SWITCH_LANGUAGES, RESET_DATA, diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt index 8f506e763a7..c6ce9964a9c 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt @@ -1291,6 +1291,31 @@ class ConfigurationRegistryTest : RobolectricTest() { ) } + @Test + fun testGenerateRequestBundleForceRefreshOmitsLastUpdated() { + val resourceType = "BINARY" + val resourceId = "test-binary-id" + val timestamp = "2024-01-15T10:00:00Z" + val expectedKey = "${resourceType}_${resourceId}_LAST_CONFIG_SYNC_TIMESTAMP" + + configRegistry.sharedPreferencesHelper.write(expectedKey, timestamp) + + val resultBundle = + configRegistry.generateRequestBundle(resourceType, listOf(resourceId), forceRefresh = true) + + assertEquals( + "$resourceType?_id=$resourceId", + resultBundle.entry.first().request.url, + ) + } + + @Test + fun testFilterResourceListIncludesParametersName() { + assertTrue( + ConfigurationRegistry.FILTER_RESOURCE_LIST.contains(ResourceType.Parameters.name), + ) + } + @Test fun testCreateOrUpdateRemoteUpdatesTimestamp() = runTest { val resource = diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt index dc18ce5ef2b..fc9bbf3e939 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt @@ -66,10 +66,25 @@ class UserSettingScreenTest { .assertDoesNotExist() composeRule.onNodeWithText("Manual sync").assertExists() + composeRule.onNodeWithText("Sync configuration").assertExists() composeRule.onNodeWithText("Log out").assertExists() } + @Test + fun testSyncConfigurationRowIsNotShownWhenDisabled() { + initComposable(enableSyncConfiguration = false) + composeRule.onNodeWithText("Sync configuration").assertDoesNotExist() + } + + @Test + fun testSyncConfigurationConfirmationDialogIsShown() { + initComposable(showSyncConfigurationConfirmation = true) + composeRule + .onNodeWithText(activity.getString(R.string.sync_configuration_message)) + .assertExists() + } + // TODO temporary disabled the sync functionality and will be enabled in future /*@Test fun testSyncRowClickShouldInitiateSync() { @@ -188,6 +203,8 @@ class UserSettingScreenTest { isDebugVariant: Boolean = false, isP2PAvailable: Boolean = false, showManualSync: Boolean = true, + enableSyncConfiguration: Boolean = true, + showSyncConfigurationConfirmation: Boolean = false, showAppInsights: Boolean = true, hasOfflineMaps: Boolean = true, showContactHelp: Boolean = true, @@ -210,8 +227,10 @@ class UserSettingScreenTest { lastSyncTime = "05:30 PM, Mar 3", showProgressIndicatorFlow = MutableStateFlow(false), enableManualSync = showManualSync, + enableSyncConfiguration = enableSyncConfiguration, allowSwitchingLanguages = allowSwitchingLanguages, showDatabaseResetConfirmation = isShowDatabaseResetConfirmation, + showSyncConfigurationConfirmation = showSyncConfigurationConfirmation, allowP2PSync = isP2PAvailable, enableAppInsights = showAppInsights, showOfflineMaps = hasOfflineMaps, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt index d91af60cb82..64dc5238de7 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt @@ -108,10 +108,18 @@ class UserSettingFragment : Fragment(), OnSyncListener { enableManualSync = !org.smartregister.fhircore.quest.BuildConfig.SKIP_AUTHENTICATION && userSettingViewModel.enableMenuOption(SettingsOptions.MANUAL_SYNC), + enableSyncConfiguration = + !org.smartregister.fhircore.quest.BuildConfig.SKIP_AUTHENTICATION && + userSettingViewModel.enableMenuOption(SettingsOptions.SYNC_CONFIGURATION), allowSwitchingLanguages = userSettingViewModel.allowSwitchingLanguages(), showDatabaseResetConfirmation = userSettingViewModel.enableMenuOption(SettingsOptions.RESET_DATA) && userSettingViewModel.showDBResetConfirmationDialog.observeAsState(false).value, + showSyncConfigurationConfirmation = + userSettingViewModel.enableMenuOption(SettingsOptions.SYNC_CONFIGURATION) && + userSettingViewModel.showSyncConfigurationConfirmationDialog + .observeAsState(false) + .value, enableAppInsights = userSettingViewModel.enableMenuOption(SettingsOptions.INSIGHTS), showOfflineMaps = userSettingViewModel.enableMenuOption(SettingsOptions.OFFLINE_MAPS), diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt index efa579983d3..b860e935470 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.Insights import androidx.compose.material.icons.rounded.IosShare @@ -103,6 +104,8 @@ const val USER_SETTING_ROW_INSIGHTS = "userSettingRowInsights" const val USER_SETTING_ROW_CONTACT_HELP = "userSettingRowContactHelp" const val USER_SETTING_ROW_OFFLINE_MAP = "userSettingRowOfflineMap" const val USER_SETTING_ROW_SYNC = "userSettingRowSync" +const val USER_SETTING_ROW_SYNC_CONFIGURATION = "userSettingRowSyncConfiguration" +const val SYNC_CONFIGURATION_DIALOG = "syncConfigurationDialog" const val OPENSRP_LOGO_TEST_TAG = "opensrpLogoTestTag" @SuppressLint("UnusedMaterialScaffoldPaddingParameter") @@ -124,8 +127,10 @@ fun UserSettingScreen( lastSyncTime: String?, showProgressIndicatorFlow: MutableStateFlow, enableManualSync: Boolean, + enableSyncConfiguration: Boolean = false, allowSwitchingLanguages: Boolean, showDatabaseResetConfirmation: Boolean, + showSyncConfigurationConfirmation: Boolean = false, enableAppInsights: Boolean, showOfflineMaps: Boolean = false, allowP2PSync: Boolean = false, @@ -228,6 +233,17 @@ fun UserSettingScreen( ) } + if (enableSyncConfiguration) { + UserSettingRow( + icon = Icons.Rounded.CloudDownload, + text = stringResource(id = R.string.sync_configuration), + clickListener = { + onEvent(UserSettingsEvent.ShowSyncConfigurationConfirmationDialog(true)) + }, + modifier = modifier.testTag(USER_SETTING_ROW_SYNC_CONFIGURATION), + ) + } + if (showOfflineMaps) { UserSettingRow( icon = Icons.Rounded.Map, @@ -325,6 +341,18 @@ fun UserSettingScreen( ) } + if (showSyncConfigurationConfirmation) { + ConfirmSyncConfigurationDialog( + onConfirm = { + onEvent(UserSettingsEvent.ShowSyncConfigurationConfirmationDialog(false)) + onEvent(UserSettingsEvent.SyncConfiguration(context)) + }, + onDismissDialog = { + onEvent(UserSettingsEvent.ShowSyncConfigurationConfirmationDialog(false)) + }, + ) + } + if (isDebugVariant) { UserSettingRow( icon = Icons.Rounded.DeleteForever, @@ -511,6 +539,42 @@ fun ConfirmClearDatabaseDialog( ) } +@Composable +fun ConfirmSyncConfigurationDialog( + onConfirm: () -> Unit, + onDismissDialog: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = onDismissDialog, + title = { + Text( + text = stringResource(R.string.sync_configuration_title), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + }, + text = { Text(text = stringResource(R.string.sync_configuration_message), fontSize = 16.sp) }, + buttons = { + Row( + modifier = modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalArrangement = Arrangement.End, + ) { + Text( + text = stringResource(R.string.cancel), + modifier = modifier.padding(horizontal = 10.dp).clickable { onDismissDialog() }, + ) + Text( + color = MaterialTheme.colors.primary, + text = stringResource(R.string.sync_configuration).uppercase(), + modifier = modifier.padding(horizontal = 10.dp).clickable { onConfirm() }, + ) + } + }, + modifier = Modifier.testTag(SYNC_CONFIGURATION_DIALOG), + ) +} + @Composable @PreviewWithBackgroundExcludeGenerated fun UserSettingPreview() { @@ -530,8 +594,10 @@ fun UserSettingPreview() { lastSyncTime = "05:30 PM, Mar 3", showProgressIndicatorFlow = MutableStateFlow(false), enableManualSync = true, + enableSyncConfiguration = true, allowSwitchingLanguages = true, showDatabaseResetConfirmation = false, + showSyncConfigurationConfirmation = false, enableAppInsights = true, showOfflineMaps = true, allowP2PSync = true, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt index 4dcf180010b..239337790a3 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt @@ -66,6 +66,7 @@ import org.smartregister.fhircore.engine.util.extension.setAppLocale import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.extension.today import org.smartregister.fhircore.quest.BuildConfig +import org.smartregister.fhircore.quest.data.DataMigration import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.ui.appsetting.AppSettingActivity import org.smartregister.fhircore.quest.ui.login.AccountAuthenticator @@ -90,10 +91,12 @@ constructor( val workManager: WorkManager, val dispatcherProvider: DispatcherProvider, private val preferenceDataStore: PreferenceDataStore, + private val dataMigration: DataMigration, ) : ViewModel() { val languages by lazy { configurationRegistry.fetchLanguages() } val showDBResetConfirmationDialog = MutableLiveData(false) + val showSyncConfigurationConfirmationDialog = MutableLiveData(false) val progressBarState = MutableLiveData(Pair(false, 0)) val showProgressIndicatorFlow = MutableStateFlow(false) val unsyncedResourcesMutableSharedFlow = MutableSharedFlow>>() @@ -167,6 +170,9 @@ constructor( ) } } + is UserSettingsEvent.ShowSyncConfigurationConfirmationDialog -> + showSyncConfigurationConfirmationDialog.postValue(event.isShow) + is UserSettingsEvent.SyncConfiguration -> syncConfiguration(event.context) is UserSettingsEvent.SwitchLanguage -> { sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, event.language.tag) event.context.run { @@ -221,6 +227,43 @@ constructor( } } + /** + * Re-download Composition-referenced configuration resources (PlanDefinition, Questionnaire, + * StructureMap, Binary, …) without wiping patient data. Force-refresh omits the incremental + * lastUpdated filter used by the silent login worker. + */ + fun syncConfiguration(context: Context) { + if (!context.isDeviceOnline()) { + context.showToast(context.getString(R.string.sync_failed), Toast.LENGTH_LONG) + return + } + viewModelScope.launch { + updateProgressBarState(true, R.string.syncing_configuration) + try { + withContext(dispatcherProvider.io()) { + configurationRegistry.fetchNonWorkflowConfigResources(forceRefresh = true) + val appId = sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null) + if (!appId.isNullOrEmpty()) { + configurationRegistry.loadConfigurations(appId, context) + } + dataMigration.migrate() + } + context.showToast( + context.getString(R.string.sync_configuration_completed), + Toast.LENGTH_LONG, + ) + } catch (exception: Exception) { + Timber.e(exception, "Failed to sync configuration resources") + context.showToast( + context.getString(R.string.sync_configuration_failed), + Toast.LENGTH_LONG, + ) + } finally { + updateProgressBarState(false, R.string.syncing_configuration) + } + } + } + fun enabledDeviceToDeviceSync(): Boolean = applicationConfiguration.deviceToDeviceSync != null fun getDateFormat() = applicationConfiguration.dateFormat diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingsEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingsEvent.kt index dd6649c61f6..93e1453aebd 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingsEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingsEvent.kt @@ -35,6 +35,10 @@ sealed class UserSettingsEvent { data class SyncData(val context: Context) : UserSettingsEvent() + data class ShowSyncConfigurationConfirmationDialog(val isShow: Boolean) : UserSettingsEvent() + + data class SyncConfiguration(val context: Context) : UserSettingsEvent() + data class ShowContactView(val isShow: Boolean, val context: Context) : UserSettingsEvent() data class OnLaunchOfflineMap(val isShow: Boolean, val context: Context) : UserSettingsEvent() diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModelTest.kt index 6c106a4c5e1..bccb6317cca 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModelTest.kt @@ -65,6 +65,7 @@ import org.smartregister.fhircore.engine.util.extension.spaceByUppercase import org.smartregister.fhircore.engine.util.test.HiltActivityForTest import org.smartregister.fhircore.quest.app.AppConfigService import org.smartregister.fhircore.quest.app.fakes.Faker +import org.smartregister.fhircore.quest.data.DataMigration import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.robolectric.RobolectricTest import org.smartregister.fhircore.quest.ui.login.AccountAuthenticator @@ -93,6 +94,7 @@ class UserSettingViewModelTest : RobolectricTest() { private var fhirResourceDataSource: FhirResourceDataSource private val sync = mockk(relaxed = true) private val navController = mockk(relaxUnitFun = true) + private val dataMigration = mockk(relaxUnitFun = true) init { sharedPreferencesHelper = SharedPreferencesHelper(context = context, gson = mockk()) @@ -132,6 +134,7 @@ class UserSettingViewModelTest : RobolectricTest() { configurationRegistry = configurationRegistry, workManager = workManager, dispatcherProvider = dispatcherProvider, + dataMigration = dataMigration, ), ) } @@ -309,6 +312,56 @@ class UserSettingViewModelTest : RobolectricTest() { verify { navController.navigate(MainNavigationScreen.Insight.route) } } + @Test + fun testShowSyncConfigurationConfirmationDialogShouldUpdateFlagCorrectly() { + Assert.assertEquals(false, userSettingViewModel.showSyncConfigurationConfirmationDialog.value) + + userSettingViewModel.onEvent(UserSettingsEvent.ShowSyncConfigurationConfirmationDialog(true)) + + ShadowLooper.idleMainLooper() + Assert.assertEquals(true, userSettingViewModel.showSyncConfigurationConfirmationDialog.value) + } + + @Test + fun testSyncConfigurationWhenDeviceIsOnline() { + mockkStatic(Context::isDeviceOnline) + + val context = mockk(relaxed = true) { every { isDeviceOnline() } returns true } + + coEvery { configurationRegistry.fetchNonWorkflowConfigResources(forceRefresh = true) } just runs + coEvery { configurationRegistry.loadConfigurations(any(), any(), any()) } just runs + every { sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null) } returns "app" + coEvery { dataMigration.migrate() } just runs + + userSettingViewModel.onEvent(UserSettingsEvent.SyncConfiguration(context)) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + coVerify(exactly = 1) { + configurationRegistry.fetchNonWorkflowConfigResources(forceRefresh = true) + } + coVerify(exactly = 1) { configurationRegistry.loadConfigurations("app", context, any()) } + coVerify(exactly = 1) { dataMigration.migrate() } + + unmockkStatic(Context::isDeviceOnline) + } + + @Test + fun testDoNotSyncConfigurationWhenDeviceIsOffline() { + mockkStatic(Context::isDeviceOnline) + + val context = mockk(relaxed = true) { every { isDeviceOnline() } returns false } + + userSettingViewModel.onEvent(UserSettingsEvent.SyncConfiguration(context)) + + coVerify(exactly = 0) { configurationRegistry.fetchNonWorkflowConfigResources(any()) } + + val errorMessage = context.getString(R.string.sync_failed) + coVerify { context.showToast(errorMessage, Toast.LENGTH_LONG) } + + unmockkStatic(Context::isDeviceOnline) + } + @Test @kotlinx.coroutines.ExperimentalCoroutinesApi fun testFetchUnsyncedResources() = runTest { From 9c9df7428f121114868ad2b8a43886d08260dd97 Mon Sep 17 00:00:00 2001 From: delcroip Date: Mon, 17 Aug 2026 16:57:15 +0200 Subject: [PATCH 09/11] feat: support relatedPerson link in the All patient register --- .../workflow/ApplicationWorkflow.kt | 9 + .../data/local/register/RegisterRepository.kt | 38 +++- .../engine/rulesengine/RulesFactory.kt | 10 ++ .../util/extension/RelatedPersonAsPatient.kt | 163 ++++++++++++++++++ .../engine/rulesengine/RulesFactoryTest.kt | 17 ++ .../extension/RelatedPersonAsPatientTest.kt | 63 +++++++ .../quest/util/extensions/ConfigExtensions.kt | 18 ++ .../quest/CdssRegistrationDiagnosticTest.kt | 35 ++-- 8 files changed, 333 insertions(+), 20 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt index cb26464994e..5719f5f56b6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt @@ -79,4 +79,13 @@ enum class ApplicationWorkflow { * hardcoded in the app. See `feature/register-tricc.md`. */ APPLY_NAMED_EVENT, + + /** + * Adds a RelatedPerson from a client profile: ask child / mother / father / guardian, whether + * they are the primary caregiver, then search an existing client or register one with the + * standard client questionnaire. Persists RelatedPerson (`patient` = child, `identifier` = + * guardian Patient URL, optional primary-caregiver extension). See + * `feature/20260813-related-person-picker.md`. + */ + ADD_RELATED_PERSON, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt index befe159ca55..80638442dee 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt @@ -44,9 +44,12 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.DEPENDENT_CHILDREN_RESOURCE_KEY import org.smartregister.fhircore.engine.util.extension.DEPENDENT_RELATED_PERSONS_RESOURCE_KEY +import org.smartregister.fhircore.engine.util.extension.GUARDIAN_PATIENTS_RESOURCE_KEY import org.smartregister.fhircore.engine.util.extension.childPatientId import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid +import org.smartregister.fhircore.engine.util.extension.guardianPatientReference import org.smartregister.fhircore.engine.util.extension.groupByGuardianPatientId +import org.smartregister.fhircore.engine.util.extension.hydrateFromGuardianPatient import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import timber.log.Timber @@ -110,6 +113,7 @@ constructor( ) enrichDependentChildrenFromRelatedPersons(repositoryResourceDataList) + enrichGuardianPatientsFromRelatedPersons(repositoryResourceDataList) return repositoryResourceDataList } @@ -164,6 +168,37 @@ constructor( } } + /** + * Resolves guardian / mother / father Patients from RelatedPersons on a child row + * (`RelatedPerson.patient` = this child, `identifier` = guardian Patient URL). Hydrates + * RelatedPerson.name from the guardian Patient when it is empty so profile LISTs can render. + */ + suspend fun enrichGuardianPatientsFromRelatedPersons( + repositoryResourceDataList: List, + ) { + repositoryResourceDataList.forEach { row -> + if (row.resource !is Patient) return@forEach + val relatedPersons = + row.relatedResourcesMap["relatedPersons"]?.filterIsInstance().orEmpty() + if (relatedPersons.isEmpty()) return@forEach + + val guardians = + relatedPersons.mapNotNull { rp -> + val guardianId = + rp.guardianPatientReference()?.extractLogicalIdUuid() ?: return@mapNotNull null + runCatching { fhirEngine.get(guardianId) } + .onFailure { + Timber.w(it, "Guardian Patient/$guardianId not found for profile nest") + } + .getOrNull() + ?.also { guardian -> rp.hydrateFromGuardianPatient(guardian) } + } + if (guardians.isNotEmpty()) { + row.relatedResourcesMap[GUARDIAN_PATIENTS_RESOURCE_KEY] = guardians + } + } + } + /** Count register data for the provided [registerId]. Use the configured base resource filters */ override suspend fun countRegisterData( registerId: String, @@ -236,8 +271,9 @@ constructor( configComputedRuleValues = configComputedRuleValues, repositoryResourceDataList = repositoryResourceDataList, ) - // Same RelatedPerson → dependent children join as registers (parent profile shows kids) + // Same RelatedPerson → dependent children / guardian Patients join as registers enrichDependentChildrenFromRelatedPersons(repositoryResourceDataList) + enrichGuardianPatientsFromRelatedPersons(repositoryResourceDataList) return repositoryResourceDataList.firstOrNull() } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt index 96216a28e71..b70b20f2ecb 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt @@ -38,6 +38,7 @@ import org.apache.commons.jexl3.JexlEngine import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Enumerations.DataType import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.Task @@ -69,6 +70,7 @@ import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.formatDate import org.smartregister.fhircore.engine.util.extension.generateRules import org.smartregister.fhircore.engine.util.extension.isOverDue +import org.smartregister.fhircore.engine.util.extension.isPrimaryCaregiver import org.smartregister.fhircore.engine.util.extension.parseDate import org.smartregister.fhircore.engine.util.extension.prettifyDate import org.smartregister.fhircore.engine.util.extension.translationPropertyKey @@ -366,6 +368,14 @@ constructor( } ?: "" } + /** Logical id from a FHIR reference or identifier value such as `Patient/marie`. */ + fun extractLogicalId(value: Any?): String = + value?.toString()?.takeIf { it.isNotBlank() && it != "null" }?.extractLogicalIdUuid().orEmpty() + + /** True when [relatedPerson] is the child's primary caregiver (extension flag). */ + fun isPrimaryCaregiver(relatedPerson: Any?): Boolean = + (relatedPerson as? RelatedPerson)?.isPrimaryCaregiver() == true + /** * This function takes [inputDate] and returns a difference (for examples 7 hours, 2 day, 5 * months, 3 years etc) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt index 702d0ba6807..f2da2e5cf92 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt @@ -16,9 +16,19 @@ package org.smartregister.fhircore.engine.util.extension +import com.google.android.fhir.datacapture.extensions.logicalId +import java.time.LocalDate +import java.time.Period +import java.time.ZoneId +import java.util.Date +import java.util.UUID +import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.RelatedPerson /** @@ -65,6 +75,74 @@ const val DEPENDENT_CHILDREN_RESOURCE_KEY = "dependentChildren" */ const val DEPENDENT_RELATED_PERSONS_RESOURCE_KEY = "dependentRelatedPersons" +/** + * Map key for guardian / mother / father [Patient]s resolved from RelatedPerson PI identifiers on + * a child profile. + */ +const val GUARDIAN_PATIENTS_RESOURCE_KEY = "guardianPatients" + +/** HL7 v3 RoleCode — used for mother / father. */ +const val ROLE_CODE_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-RoleCode" + +/** HL7 v3 RoleClass — used for guardian. */ +const val ROLE_CLASS_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-RoleClass" + +const val RELATIONSHIP_MOTHER = "MTH" +const val RELATIONSHIP_FATHER = "FTH" +const val RELATIONSHIP_GUARDIAN = "GUARD" + +/** + * Boolean extension on [RelatedPerson]: this join is the child's main caregiver / primary + * contact. Kinship stays `MTH` / `FTH` / `GUARD` on [RelatedPerson.relationship]. At most one + * RelatedPerson per child should have this set. + */ +const val PRIMARY_CAREGIVER_EXTENSION_URL = + "https://fhir.opensrp.io/cdss/StructureDefinition/primary-caregiver" + +/** Who the user is adding relative to the open client (direction of the join). */ +enum class RelatedPersonRole { + CHILD, + GUARDIAN, +} + +/** Adult's kinship toward the child. Stored as the single [RelatedPerson.relationship] coding. */ +enum class RelatedPersonKinship { + MOTHER, + FATHER, + GUARDIAN, +} + +/** Age band used when searching for a client to link. */ +enum class RelatedPersonAgeFilter { + UNDER_18, + AGE_18_OR_OVER, +} + +fun RelatedPersonRole.defaultAgeFilter(): RelatedPersonAgeFilter = + when (this) { + RelatedPersonRole.CHILD -> RelatedPersonAgeFilter.UNDER_18 + RelatedPersonRole.GUARDIAN -> RelatedPersonAgeFilter.AGE_18_OR_OVER + } + +fun RelatedPersonKinship.toCoding(): Coding = + when (this) { + RelatedPersonKinship.MOTHER -> Coding(ROLE_CODE_SYSTEM, RELATIONSHIP_MOTHER, "mother") + RelatedPersonKinship.FATHER -> Coding(ROLE_CODE_SYSTEM, RELATIONSHIP_FATHER, "father") + RelatedPersonKinship.GUARDIAN -> Coding(ROLE_CLASS_SYSTEM, RELATIONSHIP_GUARDIAN, "guardian") + } + +fun RelatedPerson.isPrimaryCaregiver(): Boolean { + val value = getExtensionByUrl(PRIMARY_CAREGIVER_EXTENSION_URL)?.value + return (value as? BooleanType)?.booleanValue() == true +} + +fun RelatedPerson.setPrimaryCaregiver(enabled: Boolean) { + extension.removeAll { it.url == PRIMARY_CAREGIVER_EXTENSION_URL } + if (enabled) { + extension.add(Extension(PRIMARY_CAREGIVER_EXTENSION_URL, BooleanType(true))) + } +} + /** * Returns the guardian / mother / father Patient reference (`Patient/{id}`) from * [RelatedPerson.identifier], or null if none is present. @@ -196,3 +274,88 @@ fun String.patientReferenceFromIdentifierValue(): String? { ?: return null return "Patient/$logicalId" } + +/** + * Infers MTH / FTH / GUARD from the guardian Patient's gender. Used when the add-related flow + * only asked "child or guardian" and did not collect a more specific role. + */ +fun inferGuardianRelationship(guardian: Patient): Coding = + when (guardian.gender) { + Enumerations.AdministrativeGender.FEMALE -> + Coding(ROLE_CODE_SYSTEM, RELATIONSHIP_MOTHER, "mother") + Enumerations.AdministrativeGender.MALE -> + Coding(ROLE_CODE_SYSTEM, RELATIONSHIP_FATHER, "father") + else -> Coding(ROLE_CLASS_SYSTEM, RELATIONSHIP_GUARDIAN, "guardian") + } + +/** + * Builds the TRICC RelatedPerson: [RelatedPerson.patient] is always the child, + * [RelatedPerson.identifier] is the guardian Patient URL, relationship is the guardian's role + * toward the child. Copies name / gender / birthDate / telecom from [guardian] so profile + * lists can render without a second fetch. + */ +fun buildRelatedPersonLink( + child: Patient, + guardian: Patient, + relationship: Coding = inferGuardianRelationship(guardian), + isPrimaryCaregiver: Boolean = false, +): RelatedPerson { + return RelatedPerson().apply { + id = UUID.randomUUID().toString() + active = true + patient = child.asReference() + addIdentifier(patientUrlIdentifier(guardian.logicalId)) + addRelationship(CodeableConcept().addCoding(relationship)) + setPrimaryCaregiver(isPrimaryCaregiver) + guardian.name.firstOrNull()?.let { addName(it.copy()) } + if (guardian.hasGender()) gender = guardian.gender + if (guardian.hasBirthDate()) birthDate = guardian.birthDate + guardian.telecom.forEach { addTelecom(it.copy()) } + } +} + +/** Whole years since [Patient.birthDate], or null when DOB is missing. */ +fun Patient.ageInYears(now: LocalDate = LocalDate.now()): Int? { + val dob: Date = birthDate ?: return null + val born = dob.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + return Period.between(born, now).years +} + +fun Patient.matchesAgeFilter( + filter: RelatedPersonAgeFilter, + now: LocalDate = LocalDate.now(), +): Boolean { + val years = ageInYears(now) ?: return true + return when (filter) { + RelatedPersonAgeFilter.UNDER_18 -> years < 18 + RelatedPersonAgeFilter.AGE_18_OR_OVER -> years >= 18 + } +} + +fun Patient.matchesNameQuery(query: String): Boolean { + val needle = query.trim() + if (needle.isEmpty()) return true + val haystack = + buildString { + name.forEach { humanName -> + append(humanName.nameAsSingleString) + append(' ') + append(humanName.text.orEmpty()) + append(' ') + humanName.given.forEach { append(it.value.orEmpty()).append(' ') } + append(humanName.family.orEmpty()) + append(' ') + } + } + .lowercase() + return haystack.contains(needle.lowercase()) +} + +/** Copies guardian demographics onto [this] when they are missing (in-memory display only). */ +fun RelatedPerson.hydrateFromGuardianPatient(guardian: Patient) { + if (name.isEmpty() && guardian.hasName()) { + guardian.name.forEach { addName(it.copy()) } + } + if (!hasGender() && guardian.hasGender()) gender = guardian.gender + if (!hasBirthDate() && guardian.hasBirthDate()) birthDate = guardian.birthDate +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt index 90eb014168c..79eee9b56c3 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt @@ -77,6 +77,7 @@ import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.plusYears +import org.smartregister.fhircore.engine.util.extension.setPrimaryCaregiver import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor @HiltAndroidTest @@ -390,6 +391,22 @@ class RulesFactoryTest : RobolectricTest() { Assert.assertEquals("", rulesEngineService.extractGender(Patient())) } + @Test + fun extractLogicalId_stripsResourceType() { + Assert.assertEquals("marie", rulesEngineService.extractLogicalId("Patient/marie")) + Assert.assertEquals("marie", rulesEngineService.extractLogicalId("marie")) + Assert.assertEquals("", rulesEngineService.extractLogicalId(null)) + } + + @Test + fun isPrimaryCaregiver_readsRelatedPersonExtension() { + val flagged = org.hl7.fhir.r4.model.RelatedPerson().apply { setPrimaryCaregiver(true) } + Assert.assertTrue(rulesEngineService.isPrimaryCaregiver(flagged)) + Assert.assertFalse(rulesEngineService.isPrimaryCaregiver(org.hl7.fhir.r4.model.RelatedPerson())) + Assert.assertFalse(rulesEngineService.isPrimaryCaregiver(null)) + Assert.assertFalse(rulesEngineService.isPrimaryCaregiver(Patient())) + } + @Test fun extractDOBReturnsCorrectDate() { Assert.assertEquals( diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt index e93307a956c..2fc358a2634 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt @@ -18,7 +18,9 @@ package org.smartregister.fhircore.engine.util.extension import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.RelatedPerson import org.junit.Assert @@ -138,4 +140,65 @@ class RelatedPersonAsPatientTest { ) Assert.assertNull("not-a-patient".patientReferenceFromIdentifierValue()) } + + @Test + fun defaultAgeFilter_matchesRole() { + Assert.assertEquals(RelatedPersonAgeFilter.UNDER_18, RelatedPersonRole.CHILD.defaultAgeFilter()) + Assert.assertEquals( + RelatedPersonAgeFilter.AGE_18_OR_OVER, + RelatedPersonRole.GUARDIAN.defaultAgeFilter(), + ) + } + + @Test + fun inferGuardianRelationship_usesGender() { + val mother = Patient().apply { gender = Enumerations.AdministrativeGender.FEMALE } + val father = Patient().apply { gender = Enumerations.AdministrativeGender.MALE } + Assert.assertEquals(RELATIONSHIP_MOTHER, inferGuardianRelationship(mother).code) + Assert.assertEquals(RELATIONSHIP_FATHER, inferGuardianRelationship(father).code) + Assert.assertEquals(RELATIONSHIP_GUARDIAN, inferGuardianRelationship(Patient()).code) + } + + @Test + fun buildRelatedPersonLink_copiesGuardianDemographics() { + val child = Patient().apply { id = "child-1" } + val mother = + Patient().apply { + id = "mother-1" + gender = Enumerations.AdministrativeGender.FEMALE + addName( + org.hl7.fhir.r4.model.HumanName().apply { + family = "Doe" + addGiven("Marie") + }, + ) + } + + val rp = buildRelatedPersonLink(child, mother) + Assert.assertEquals("child-1", rp.childPatientId()) + Assert.assertEquals("Patient/mother-1", rp.guardianPatientReference()) + Assert.assertEquals(RELATIONSHIP_MOTHER, rp.relationshipFirstRep.codingFirstRep.code) + Assert.assertFalse(rp.isPrimaryCaregiver()) + Assert.assertEquals("Marie Doe", rp.nameFirstRep.nameAsSingleString) + Assert.assertEquals(Enumerations.AdministrativeGender.FEMALE, rp.gender) + } + + @Test + fun buildRelatedPersonLink_setsPrimaryCaregiverExtension() { + val child = Patient().apply { id = "child-1" } + val mother = Patient().apply { id = "mother-1" } + val rp = + buildRelatedPersonLink( + child = child, + guardian = mother, + relationship = RelatedPersonKinship.MOTHER.toCoding(), + isPrimaryCaregiver = true, + ) + Assert.assertTrue(rp.isPrimaryCaregiver()) + Assert.assertEquals(RELATIONSHIP_MOTHER, rp.relationshipFirstRep.codingFirstRep.code) + Assert.assertEquals(1, rp.relationship.size) + + rp.setPrimaryCaregiver(false) + Assert.assertFalse(rp.isPrimaryCaregiver()) + } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index 38ab2c591bf..7871215135c 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -59,6 +59,7 @@ import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ViewType +import org.smartregister.fhircore.engine.data.local.RelatedPersonLinkService import org.smartregister.fhircore.engine.task.NamedEventInterventionService import org.smartregister.fhircore.engine.util.extension.decodeJson import org.smartregister.fhircore.engine.util.extension.decodeToBitmap @@ -75,6 +76,7 @@ import org.smartregister.fhircore.quest.event.EventBus import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.navigation.NavigationArg import org.smartregister.fhircore.quest.ui.pdf.PdfLauncherFragment +import org.smartregister.fhircore.quest.ui.relatedperson.RelatedPersonAddCoordinator import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler import org.smartregister.fhircore.quest.util.openExternalApp import org.smartregister.p2p.utils.startP2PScreen @@ -279,6 +281,22 @@ fun ActionConfig.handleClickEvent( computedValuesMap = computedValuesMap, ) } + ApplicationWorkflow.ADD_RELATED_PERSON -> { + val subjectId = + interpolatedParams.find { it.key == "subjectId" }?.value?.extractLogicalIdUuid() + ?: resourceId?.extractLogicalIdUuid() + val registrationQuestionnaireId = + interpolatedParams + .find { it.key == "registrationQuestionnaireId" } + ?.value + ?.takeIf { it.isNotBlank() } + ?: RelatedPersonLinkService.DEFAULT_REGISTRATION_QUESTIONNAIRE_ID + RelatedPersonAddCoordinator.start( + navController = navController, + subjectId = subjectId, + registrationQuestionnaireId = registrationQuestionnaireId, + ) + } else -> return } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt index 706464ff123..5ef43623507 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt @@ -127,12 +127,16 @@ class CdssRegistrationDiagnosticTest : RobolectricTest() { val adultBundle = extract(questionnaire, compiled, adultResponse) println("ADULT ENTRY COUNT = ${adultBundle.entry.size}") println(adultBundle.encodeResourceToString()) - org.junit.Assert.assertTrue( - "Adult registration must extract at least one Patient", - adultBundle.entry.any { it.resource is org.hl7.fhir.r4.model.Patient }, + org.junit.Assert.assertEquals( + 1, + adultBundle.entry.count { it.resource is org.hl7.fhir.r4.model.Patient }, + ) + org.junit.Assert.assertEquals( + 0, + adultBundle.entry.count { it.resource is org.hl7.fhir.r4.model.RelatedPerson }, ) - // Minor with guardian info + new related person. + // Minor — registration extracts the child Patient only (no inline guardian). val minorResponse = """ { @@ -144,18 +148,7 @@ class CdssRegistrationDiagnosticTest : RobolectricTest() { {"linkId": "Ccc.A.DE04", "answer": [{"valueString": "Timmy"}]}, {"linkId": "Ccc.A.DE06", "answer": [{"valueString": "Doe"}]}, {"linkId": "Ccc.A.DE08", "answer": [{"valueDate": "2020-05-01"}]}, - {"linkId": "Ccc.A.DE16", "answer": [{"valueCoding": {"code": "Ccc.A.DE18"}}]}, - {"linkId": "newrelatedperson", "answer": [{"valueCoding": {"code": "newrelatedperson"}}]}, - {"linkId": "relatedpersonuuid", "answer": [{"valueString": "guardian-uuid-1"}]}, - { - "linkId": "Ccc.A.DE21.1", - "item": [ - {"linkId": "Ccc.A.DE21", "answer": [{"valueString": "Jill"}]}, - {"linkId": "Ccc.A.DE23", "answer": [{"valueString": "Smith"}]}, - {"linkId": "Ccc.A.DE35", "answer": [{"valueString": "+123456789"}]}, - {"linkId": "Ccc.A.DE24", "answer": [{"valueCoding": {"code": "Ccc.A.DE25"}}]} - ] - } + {"linkId": "Ccc.A.DE16", "answer": [{"valueCoding": {"code": "Ccc.A.DE18"}}]} ] } """ @@ -165,9 +158,13 @@ class CdssRegistrationDiagnosticTest : RobolectricTest() { val minorBundle = extract(questionnaire, compiled, minorResponse) println("MINOR ENTRY COUNT = ${minorBundle.entry.size}") println(minorBundle.encodeResourceToString()) - org.junit.Assert.assertTrue( - "Minor registration must extract Patient resources", - minorBundle.entry.count { it.resource is org.hl7.fhir.r4.model.Patient } >= 1, + org.junit.Assert.assertEquals( + 1, + minorBundle.entry.count { it.resource is org.hl7.fhir.r4.model.Patient }, + ) + org.junit.Assert.assertEquals( + 0, + minorBundle.entry.count { it.resource is org.hl7.fhir.r4.model.RelatedPerson }, ) } } From a65484bf776e9484c55e8a04bb471c23d6df7497 Mon Sep 17 00:00:00 2001 From: delcroip Date: Tue, 18 Aug 2026 11:09:30 +0200 Subject: [PATCH 10/11] chore: misc --- .../data/local/register/RegisterRepository.kt | 6 +-- .../engine/rulesengine/RulesFactory.kt | 6 ++- .../task/NamedEventInterventionService.kt | 20 ++++------ .../engine/task/WorkflowCarePlanGenerator.kt | 1 - .../util/extension/RelatedPersonAsPatient.kt | 39 ++++++++----------- .../engine/src/main/res/values/strings.xml | 6 +++ .../extension/RelatedPersonAsPatientTest.kt | 6 ++- .../di/NamedEventInterventionEntryPoint.kt | 4 +- .../ui/questionnaire/QuestionnaireActivity.kt | 28 ++++++++++--- android/quest/src/main/res/values/strings.xml | 27 +++++++++++++ .../QuestionnaireActivityTest.kt | 22 +++++++++++ 11 files changed, 115 insertions(+), 50 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt index 80638442dee..551100b7036 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt @@ -47,8 +47,8 @@ import org.smartregister.fhircore.engine.util.extension.DEPENDENT_RELATED_PERSON import org.smartregister.fhircore.engine.util.extension.GUARDIAN_PATIENTS_RESOURCE_KEY import org.smartregister.fhircore.engine.util.extension.childPatientId import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid -import org.smartregister.fhircore.engine.util.extension.guardianPatientReference import org.smartregister.fhircore.engine.util.extension.groupByGuardianPatientId +import org.smartregister.fhircore.engine.util.extension.guardianPatientReference import org.smartregister.fhircore.engine.util.extension.hydrateFromGuardianPatient import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import timber.log.Timber @@ -187,9 +187,7 @@ constructor( val guardianId = rp.guardianPatientReference()?.extractLogicalIdUuid() ?: return@mapNotNull null runCatching { fhirEngine.get(guardianId) } - .onFailure { - Timber.w(it, "Guardian Patient/$guardianId not found for profile nest") - } + .onFailure { Timber.w(it, "Guardian Patient/$guardianId not found for profile nest") } .getOrNull() ?.also { guardian -> rp.hydrateFromGuardianPatient(guardian) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt index b70b20f2ecb..e350cd0fbd0 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt @@ -370,7 +370,11 @@ constructor( /** Logical id from a FHIR reference or identifier value such as `Patient/marie`. */ fun extractLogicalId(value: Any?): String = - value?.toString()?.takeIf { it.isNotBlank() && it != "null" }?.extractLogicalIdUuid().orEmpty() + value + ?.toString() + ?.takeIf { it.isNotBlank() && it != "null" } + ?.extractLogicalIdUuid() + .orEmpty() /** True when [relatedPerson] is the child's primary caregiver (extension flag). */ fun isPrimaryCaregiver(relatedPerson: Any?): Boolean = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt index d9fcce526b4..535bbd038e8 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt @@ -70,8 +70,8 @@ constructor( val planDefinitionId: String? = null, val questionnaireId: String? = null, /** - * cpg-common-process order (10, 20, 30…), read from the `tricc-process-order` extension — - * see `feature/20260812-intervention-order-and-dedup.md` (tricc). Fixed/canonical, so it is + * cpg-common-process order (10, 20, 30…), read from the `tricc-process-order` extension — see + * `feature/20260812-intervention-order-and-dedup.md` (tricc). Fixed/canonical, so it is * comparable across options originating from different PlanDefinitions. Options without the * extension (e.g. resolved via workflow `$apply`, which may not propagate custom action * extensions onto the RequestGroup) sort last. @@ -95,9 +95,9 @@ constructor( ) /** - * Returns one [AvailableCarePlan] per PlanDefinition matching [namedEvent] that has at least - * one launchable (Questionnaire-resolvable) option for [subjectPatientId] — i.e. exactly the - * set of rows the "select available care" picker should list, each with its own checkbox. + * Returns one [AvailableCarePlan] per PlanDefinition matching [namedEvent] that has at least one + * launchable (Questionnaire-resolvable) option for [subjectPatientId] — i.e. exactly the set of + * rows the "select available care" picker should list, each with its own checkbox. * PlanDefinitions with no launchable option are omitted (nothing to check). */ suspend fun listAvailableCarePlans( @@ -369,10 +369,7 @@ constructor( ?.takeIf { definition.contains("PlanDefinition/") && it.isNotBlank() } ?.extractLogicalIdUuid() val id = - questionnaireId - ?: id - ?: definition - ?: "${planDefinition.logicalId}-${title.hashCode()}" + questionnaireId ?: id ?: definition ?: "${planDefinition.logicalId}-${title.hashCode()}" return InterventionOption( id = id, title = title, @@ -414,10 +411,7 @@ constructor( ?.takeIf { resourceRef.contains("PlanDefinition/") && it.isNotBlank() } ?.extractLogicalIdUuid() val id = - questionnaireId - ?: this.id - ?: definition - ?: "${planDefinition.logicalId}-${title.hashCode()}" + questionnaireId ?: this.id ?: definition ?: "${planDefinition.logicalId}-${title.hashCode()}" return InterventionOption( id = id, title = title, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt index 519c16c07e2..f627a6671be 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WorkflowCarePlanGenerator.kt @@ -25,7 +25,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlin.reflect.jvm.isAccessible - import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.hl7.fhir.instance.model.api.IBaseResource diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt index f2da2e5cf92..1a18539a891 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt @@ -25,8 +25,8 @@ import java.util.UUID import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.RelatedPerson @@ -45,9 +45,9 @@ import org.hl7.fhir.r4.model.RelatedPerson */ /** - * Prefer absolute URI identifiers ([FHIR Identifier with URI value](https://hl7.org/fhir/datatypes.html#Identifier)). - * Value holds a Patient reference or absolute Patient URL, e.g. `Patient/marie` or - * `https://fhir.example.org/Patient/marie`. + * Prefer absolute URI identifiers ([FHIR Identifier with URI + * value](https://hl7.org/fhir/datatypes.html#Identifier)). Value holds a Patient reference or + * absolute Patient URL, e.g. `Patient/marie` or `https://fhir.example.org/Patient/marie`. */ const val RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM = "urn:ietf:rfc:3986" @@ -76,8 +76,8 @@ const val DEPENDENT_CHILDREN_RESOURCE_KEY = "dependentChildren" const val DEPENDENT_RELATED_PERSONS_RESOURCE_KEY = "dependentRelatedPersons" /** - * Map key for guardian / mother / father [Patient]s resolved from RelatedPerson PI identifiers on - * a child profile. + * Map key for guardian / mother / father [Patient]s resolved from RelatedPerson PI identifiers on a + * child profile. */ const val GUARDIAN_PATIENTS_RESOURCE_KEY = "guardianPatients" @@ -92,9 +92,9 @@ const val RELATIONSHIP_FATHER = "FTH" const val RELATIONSHIP_GUARDIAN = "GUARD" /** - * Boolean extension on [RelatedPerson]: this join is the child's main caregiver / primary - * contact. Kinship stays `MTH` / `FTH` / `GUARD` on [RelatedPerson.relationship]. At most one - * RelatedPerson per child should have this set. + * Boolean extension on [RelatedPerson]: this join is the child's main caregiver / primary contact. + * Kinship stays `MTH` / `FTH` / `GUARD` on [RelatedPerson.relationship]. At most one RelatedPerson + * per child should have this set. */ const val PRIMARY_CAREGIVER_EXTENSION_URL = "https://fhir.opensrp.io/cdss/StructureDefinition/primary-caregiver" @@ -156,8 +156,7 @@ fun RelatedPerson.guardianPatientReference(): String? { val typed = identifier .firstOrNull { - it.hasPatientLinkIdentifierType() && - it.value.patientReferenceFromIdentifierValue() != null + it.hasPatientLinkIdentifierType() && it.value.patientReferenceFromIdentifierValue() != null } ?.value ?.patientReferenceFromIdentifierValue() @@ -265,19 +264,15 @@ fun String.patientReferenceFromIdentifierValue(): String? { if (delimiterIndex == -1) return null val afterPatient = trimmed.substring(delimiterIndex + "Patient/".length) val logicalId = - afterPatient - .substringBefore("/") - .substringBefore("?") - .substringBefore("#") - .trim() - .takeIf { it.isNotBlank() } - ?: return null + afterPatient.substringBefore("/").substringBefore("?").substringBefore("#").trim().takeIf { + it.isNotBlank() + } ?: return null return "Patient/$logicalId" } /** - * Infers MTH / FTH / GUARD from the guardian Patient's gender. Used when the add-related flow - * only asked "child or guardian" and did not collect a more specific role. + * Infers MTH / FTH / GUARD from the guardian Patient's gender. Used when the add-related flow only + * asked "child or guardian" and did not collect a more specific role. */ fun inferGuardianRelationship(guardian: Patient): Coding = when (guardian.gender) { @@ -291,8 +286,8 @@ fun inferGuardianRelationship(guardian: Patient): Coding = /** * Builds the TRICC RelatedPerson: [RelatedPerson.patient] is always the child, * [RelatedPerson.identifier] is the guardian Patient URL, relationship is the guardian's role - * toward the child. Copies name / gender / birthDate / telecom from [guardian] so profile - * lists can render without a second fetch. + * toward the child. Copies name / gender / birthDate / telecom from [guardian] so profile lists can + * render without a second fetch. */ fun buildRelatedPersonLink( child: Patient, diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index ec06e3d0b9f..9e57c048127 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -1,5 +1,11 @@ Manual sync + Sync configuration + Sync configuration? + This may break compatibility with existing data if a questionnaire or care plan has changed version or been removed. Sync configuration anyway? + Syncing configuration… + Configuration sync complete + Configuration sync failed. Check internet connection or try again later Sync Language Log out as diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt index 2fc358a2634..34324abbdcc 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt @@ -86,7 +86,11 @@ class RelatedPersonAsPatientTest { type = CodeableConcept() .addCoding( - Coding(IDENTIFIER_TYPE_SYSTEM_V2_0203, IDENTIFIER_TYPE_PT, "Patient external identifier"), + Coding( + IDENTIFIER_TYPE_SYSTEM_V2_0203, + IDENTIFIER_TYPE_PT, + "Patient external identifier" + ), ) system = RELATED_PERSON_PATIENT_IDENTIFIER_SYSTEM value = "https://fhir.example.org/Patient/mother-1" diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt index 50a9c555907..06e845d815f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt @@ -28,8 +28,8 @@ interface NamedEventInterventionEntryPoint { fun namedEventInterventionService(): NamedEventInterventionService /** - * Used by the "select available care" picker to react to questionnaire submissions (advance - * to the next lowest-order due action, learn the current-visit Encounter id) — see + * Used by the "select available care" picker to react to questionnaire submissions (advance to + * the next lowest-order due action, learn the current-visit Encounter id) — see * `feature/20260812-intervention-order-and-dedup.md` (tricc) and the companion Android spec. */ fun eventBus(): EventBus diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index c10ac55a024..c2c28c390b4 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -191,14 +191,20 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { override fun onDestroy() { Thread.setDefaultUncaughtExceptionHandler(previousUncaughtExceptionHandler) + // The progress dialog is shown from a coroutine that can still be mid-flight (or its + // continuation already queued) when the activity is torn down - e.g. the user backs out + // while retrieveQuestionnaire/populateQuestionnaire is running. Nothing else dismisses it in + // that case, so the dialog's window outlives the activity and Android reports it as leaked. + alertDialog?.dismiss() + alertDialog = null super.onDestroy() } /** * Handles exceptions thrown from the FHIR SDK's questionnaire rendering internals (e.g. a - * malformed FHIRPath expression in the Questionnaire config) that would otherwise crash the - * whole app. Anything unrelated to questionnaire rendering is passed on to the previous - * handler so normal crash reporting/behaviour is preserved. + * malformed FHIRPath expression in the Questionnaire config) that would otherwise crash the whole + * app. Anything unrelated to questionnaire rendering is passed on to the previous handler so + * normal crash reporting/behaviour is preserved. */ private fun handleUncaughtQuestionnaireException(thread: Thread, throwable: Throwable) { if (!isQuestionnaireRenderingException(throwable)) { @@ -222,6 +228,8 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { private fun handleQuestionnaireRenderingFailure(throwable: Throwable) { Timber.e(throwable, "Failed to render questionnaire ${questionnaireConfig.id}") runOnUiThread { + alertDialog?.dismiss() + alertDialog = null AlertDialogue.showAlert( context = this, alertIntent = AlertIntent.ERROR, @@ -358,10 +366,12 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { val questionnaire = viewModel.retrieveQuestionnaire(questionnaireConfig) when { questionnaire == null -> { + showProgressDialog(QuestionnaireProgressState.QuestionnaireLaunch(false)) showToast(getString(R.string.questionnaire_not_found)) finish() } questionnaire.subjectType.isNullOrEmpty() -> { + showProgressDialog(QuestionnaireProgressState.QuestionnaireLaunch(false)) val subjectRequiredMessage = getString(R.string.missing_subject_type) showToast(subjectRequiredMessage) Timber.e(subjectRequiredMessage) @@ -414,10 +424,14 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG)?.view?.let { fragmentView -> fragmentView - .findViewById(com.google.android.fhir.datacapture.R.id.submit_questionnaire) + .findViewById( + com.google.android.fhir.datacapture.R.id.submit_questionnaire + ) ?.isEnabled = false fragmentView - .findViewById(com.google.android.fhir.datacapture.R.id.cancel_questionnaire) + .findViewById( + com.google.android.fhir.datacapture.R.id.cancel_questionnaire + ) ?.isEnabled = false fragmentView .findViewById( @@ -717,7 +731,9 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { ): Bundle = bundleOf( Pair(QUESTIONNAIRE_CONFIG, questionnaireConfig), - Pair(QUESTIONNAIRE_ACTION_PARAMETERS, actionParams), + // Must be ArrayList: parcelableArrayList() cannot read a ListBuilder / emptyList extra, + // which would drop generateEncounter and the carried Encounter id on start-care launches. + Pair(QUESTIONNAIRE_ACTION_PARAMETERS, ArrayList(actionParams)), ) } } diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index fd3537e7536..5d0f0c25051 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -144,6 +144,33 @@ %1$d matching location(s) rendered successfully" Cancel adding location Error rendering profile + Who are you adding? + Child + Mother + Father + Guardian + Your relationship to this child + Is this the child\'s caregiver / primary contact? + Yes, main caregiver + No + Find or create the client + Search existing client + Create patient + Search clients + Close + Search name + No matching clients + Age + Under 18 + 18 and over + Register client + Save + Related person saved + This client is already linked + Could not save related person + No client selected + Unable to add related person + Client was saved but no Patient was extracted Are you sure you want to submit? You are about to submit Pause diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt index 2c80d743971..7b6d820dae2 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt @@ -238,6 +238,28 @@ class QuestionnaireActivityTest : RobolectricTest() { assertEquals(expectedIntent.component, startedIntent.component) } + @Test + fun testIntentBundleKeepsBuildListActionParametersAsParcelableArrayList() { + val params = + buildList { + add( + ActionParameter( + key = "generateEncounter", + paramType = ActionParameterType.PARAMDATA, + value = "true", + ), + ) + } + val bundle = QuestionnaireActivity.intentBundle(questionnaireConfig, params) + val restored = + bundle.getParcelableArrayList( + QuestionnaireActivity.QUESTIONNAIRE_ACTION_PARAMETERS, + ) + Assert.assertEquals(1, restored?.size) + Assert.assertEquals("generateEncounter", restored?.first()?.key) + Assert.assertEquals("true", restored?.first()?.value) + } + private fun setupActivity() { val bundle = QuestionnaireActivity.intentBundle(questionnaireConfig, emptyList()) questionnaireActivityController = From d38ab45c4a85b646452d868ceb0bc366a4879717 Mon Sep 17 00:00:00 2001 From: delcroip Date: Tue, 18 Aug 2026 11:10:09 +0200 Subject: [PATCH 11/11] feat: add encounter and remove tags on observation --- .../fhir/resource/ReferenceUrlResolver.kt | 30 +++ .../util/extension/ResourceExtension.kt | 24 ++ .../fhir/resource/ReferenceUrlResolverTest.kt | 40 ++++ .../util/extension/ResourceExtensionTest.kt | 48 ++++ .../questionnaire/QuestionnaireViewModel.kt | 207 +++++++++++------- .../quest/util/extensions/ConfigExtensions.kt | 78 +++++-- .../QuestionnaireViewModelTest.kt | 126 +++++++++++ .../util/extensions/ConfigExtensionsKtTest.kt | 51 +++++ 8 files changed, 509 insertions(+), 95 deletions(-) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolver.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolver.kt index d9b14ca081f..a86902121f3 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolver.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolver.kt @@ -24,6 +24,7 @@ import com.google.android.fhir.get import javax.inject.Inject import javax.inject.Singleton import org.hl7.fhir.r4.model.Binary +import timber.log.Timber @Singleton class ReferenceUrlResolver @@ -36,6 +37,35 @@ constructor(val fhirEngine: FhirEngine, val fhirResourceService: FhirResourceSer } override suspend fun resolveBitmapUrl(url: String): Bitmap? { + if (url.contains("Binary/")) { + return try { + decodeBinaryToBitmap(resolveBinaryResource(url)) + ?: fetchRemoteBitmapIfAbsolute(url) + } catch (exception: Exception) { + Timber.e(exception, "Failed to resolve Binary image from $url") + fetchRemoteBitmapIfAbsolute(url) + } + } + return fetchRemoteBitmap(url) + } + + private fun decodeBinaryToBitmap(binary: Binary): Bitmap? { + val bytes = binary.content + if (bytes == null || bytes.isEmpty()) { + return null + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + + private suspend fun fetchRemoteBitmapIfAbsolute(url: String): Bitmap? { + return if (url.startsWith("http://") || url.startsWith("https://")) { + fetchRemoteBitmap(url) + } else { + null + } + } + + private suspend fun fetchRemoteBitmap(url: String): Bitmap? { val response = fhirResourceService.fetchImage(url) return if (response != null) { BitmapFactory.decodeStream(response.byteStream()) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index 78b790e751a..07b6719c086 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt @@ -36,6 +36,7 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.Consent +import org.hl7.fhir.r4.model.DiagnosticReport import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Extension @@ -45,10 +46,13 @@ import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Immunization import org.hl7.fhir.r4.model.ImplementationGuide import org.hl7.fhir.r4.model.Location +import org.hl7.fhir.r4.model.MedicationAdministration +import org.hl7.fhir.r4.model.MedicationRequest import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Practitioner import org.hl7.fhir.r4.model.PrimitiveType +import org.hl7.fhir.r4.model.Procedure import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -56,6 +60,7 @@ import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.ServiceRequest import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.StructureMap import org.hl7.fhir.r4.model.Task @@ -320,6 +325,25 @@ fun Resource.appendPractitionerInfo(practitionerId: String?) { } } +/** + * Sets the `.encounter` reference (or `.context` for [MedicationAdministration]) on resource types + * that carry one, unless already set by the StructureMap that produced the resource. Used to tie + * clinical resources extracted alongside an Encounter (generated or otherwise resolved) back to it, + * see `feature/20260817-encounter-scoped-sync-tags.md`. + */ +fun Resource.appendEncounterReference(encounterReference: Reference) { + when (this) { + is Observation -> encounter = updateReference(encounter, encounterReference) + is Condition -> encounter = updateReference(encounter, encounterReference) + is Procedure -> encounter = updateReference(encounter, encounterReference) + is MedicationRequest -> encounter = updateReference(encounter, encounterReference) + is MedicationAdministration -> context = updateReference(context, encounterReference) + is ServiceRequest -> encounter = updateReference(encounter, encounterReference) + is DiagnosticReport -> encounter = updateReference(encounter, encounterReference) + else -> {} + } +} + fun Resource.appendRelatedEntityLocation( questionnaireResponse: QuestionnaireResponse, questionnaireConfig: QuestionnaireConfig, diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt index 43d1766935d..4952c24097a 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt @@ -68,6 +68,46 @@ class ReferenceUrlResolverTest : RobolectricTest() { } } + @Test + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun testResolveBitmapUrlWithRelativeBinaryReferenceReturnsBitmap() { + runTest { + val binary = + Binary().apply { + id = "0eadaee6-1965-5862-ba94-fab36b971abf" + contentType = "image/png" + content = byteArrayOf(1, 2, 3, 4) + } + coEvery { fhirEngine.get(ResourceType.Binary, binary.idPart) } returns binary + + val bitmap = + referenceUrlResolver.resolveBitmapUrl("Binary/${binary.idPart}") + + Assert.assertNotNull(bitmap) + } + } + + @Test + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun testResolveBitmapUrlWithAbsoluteBinaryUrlUsesFhirEngine() { + runTest { + val binary = + Binary().apply { + id = "sample-binary-image" + contentType = "image/png" + content = byteArrayOf(1, 2, 3, 4) + } + coEvery { fhirEngine.get(ResourceType.Binary, "sample-binary-image") } returns binary + + val bitmap = + referenceUrlResolver.resolveBitmapUrl( + "https://fhir-server.org/Binary/sample-binary-image", + ) + + Assert.assertNotNull(bitmap) + } + } + @Test @kotlinx.coroutines.ExperimentalCoroutinesApi fun testResolveImageUrlWithNullBodyShouldReturnNull() { diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt index 5520c4ccbee..47b06a7c5c2 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt @@ -44,6 +44,8 @@ import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Location +import org.hl7.fhir.r4.model.MedicationAdministration +import org.hl7.fhir.r4.model.MedicationRequest import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Period @@ -885,6 +887,52 @@ class ResourceExtensionTest : RobolectricTest() { Assert.assertEquals("Organization/12345", consent.organization.first().reference) } + @Test + fun `test appendEncounterReference sets encounter on Observation Resource`() { + val observation = Observation().apply { this.id = "obs-1" } + observation.appendEncounterReference("enc-1".asReference(ResourceType.Encounter)) + Assert.assertEquals("Encounter/enc-1", observation.encounter.reference) + } + + @Test + fun `test appendEncounterReference sets encounter on Condition Resource`() { + val condition = Condition().apply { this.id = "condition-1" } + condition.appendEncounterReference("enc-1".asReference(ResourceType.Encounter)) + Assert.assertEquals("Encounter/enc-1", condition.encounter.reference) + } + + @Test + fun `test appendEncounterReference sets encounter on MedicationRequest Resource`() { + val medicationRequest = MedicationRequest().apply { this.id = "med-req-1" } + medicationRequest.appendEncounterReference("enc-1".asReference(ResourceType.Encounter)) + Assert.assertEquals("Encounter/enc-1", medicationRequest.encounter.reference) + } + + @Test + fun `test appendEncounterReference sets context on MedicationAdministration Resource`() { + val medicationAdministration = MedicationAdministration().apply { this.id = "med-admin-1" } + medicationAdministration.appendEncounterReference("enc-1".asReference(ResourceType.Encounter)) + Assert.assertEquals("Encounter/enc-1", medicationAdministration.context.reference) + } + + @Test + fun `test appendEncounterReference does not overwrite an existing encounter reference`() { + val observation = + Observation().apply { + this.id = "obs-1" + encounter = "already-set".asReference(ResourceType.Encounter) + } + observation.appendEncounterReference("enc-1".asReference(ResourceType.Encounter)) + Assert.assertEquals("Encounter/already-set", observation.encounter.reference) + } + + @Test + fun `test appendEncounterReference is a no-op for unsupported Resource types`() { + val patient = Patient().apply { this.id = "patient-1" } + // Should not throw for a Resource type with no .encounter/.context field + patient.appendEncounterReference("enc-1".asReference(ResourceType.Encounter)) + } + @Test fun `prepareQuestionsForEditing should set readOnly correctly when readOnlyLinkIds passed`() { val questionnaire = Questionnaire() diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index ff460335936..6098ebcc5f4 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -47,6 +47,7 @@ import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Basic import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Library @@ -55,9 +56,11 @@ import org.hl7.fhir.r4.model.ListResource.ListEntryComponent import org.hl7.fhir.r4.model.MedicationRequest import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Period import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent +import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -83,6 +86,7 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.allItems +import org.smartregister.fhircore.engine.util.extension.appendEncounterReference import org.smartregister.fhircore.engine.util.extension.appendOrganizationInfo import org.smartregister.fhircore.engine.util.extension.appendPractitionerInfo import org.smartregister.fhircore.engine.util.extension.appendRelatedEntityLocation @@ -108,6 +112,7 @@ import org.smartregister.fhircore.engine.util.validation.ResourceValidationReque import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequestHandler import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.util.QuestionnaireResponseUtils +import org.smartregister.fhircore.quest.util.extensions.GENERATE_ENCOUNTER_PARAM_KEY import timber.log.Timber @HiltViewModel @@ -272,6 +277,7 @@ constructor( questionnaireConfig = questionnaireConfig, questionnaireResponse = currentQuestionnaireResponse, context = context, + actionParameters = actionParameters, ) updateResourcesLastUpdatedProperty(actionParameters) @@ -457,6 +463,7 @@ constructor( questionnaireConfig: QuestionnaireConfig, questionnaireResponse: QuestionnaireResponse, context: Context, + actionParameters: List = emptyList(), ) { val extractionDate = Date() @@ -484,95 +491,143 @@ constructor( it.resourceType } ?: emptyMap() - bundle.entry - ?.mapNotNull { it.resource } - ?.forEach { entryResource -> - entryResource.applyResourceMetadata(questionnaireConfig, questionnaireResponse, context) - if ( - questionnaireResponse.subject.reference.isNullOrEmpty() && - subjectType != null && - entryResource.resourceType == subjectType && - entryResource.logicalId.isNotEmpty() - ) { - questionnaireResponse.subject = entryResource.logicalId.asReference(subjectType) + val bundleResources = bundle.entry?.mapNotNull { it.resource } ?: emptyList() + + // See feature/20260817-encounter-scoped-sync-tags.md: resolve which Encounter (if any) this + // submission's resources belong to — already in the bundle, carried over from an earlier step + // of the same start-care session, or generated here when generateEncounter=true. + val bundleEncounter = bundleResources.filterIsInstance().firstOrNull() + val carriedEncounterReference = + actionParameters + .find { + it.paramType == ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE && + it.resourceType == ResourceType.Encounter + } + ?.value + ?.takeIf { it.isNotBlank() } + ?.asReference(ResourceType.Encounter) + val generateEncounter = + actionParameters.any { + it.key == GENERATE_ENCOUNTER_PARAM_KEY && it.value.equals("true", ignoreCase = true) + } + val generatedEncounter = + if (bundleEncounter == null && carriedEncounterReference == null && generateEncounter) { + Encounter().apply { + id = UUID.randomUUID().toString() + status = Encounter.EncounterStatus.INPROGRESS + period = Period().apply { start = extractionDate } + val subjectReference = + questionnaireResponse.subject.takeIf { it.hasReference() } + ?: bundleResources.firstOrNull { it.resourceType == subjectType }?.asReference() + if (subjectReference != null) subject = subjectReference } + } else { + null + } + if (generatedEncounter != null) { + bundle.addEntry(Bundle.BundleEntryComponent().apply { resource = generatedEncounter }) + } + val resolvedEncounterReference: Reference? = + bundleEncounter?.asReference() + ?: carriedEncounterReference + ?: generatedEncounter?.asReference() + + (listOfNotNull(generatedEncounter) + bundleResources).forEach { entryResource -> + entryResource.applyResourceMetadata(questionnaireConfig, questionnaireResponse, context) + if ( + questionnaireResponse.subject.reference.isNullOrEmpty() && + subjectType != null && + entryResource.resourceType == subjectType && + entryResource.logicalId.isNotEmpty() + ) { + questionnaireResponse.subject = entryResource.logicalId.asReference(subjectType) + } - if (questionnaireConfig.isEditable()) { - if (entryResource.resourceType == subjectType) { - entryResource.id = questionnaireResponse.subject.extractId() - } else if ( - extractedResourceUniquePropertyExpressionsMap.containsKey(entryResource.resourceType) && - previouslyExtractedResources.containsKey( - entryResource.resourceType, + if (questionnaireConfig.isEditable()) { + if (entryResource.resourceType == subjectType) { + entryResource.id = questionnaireResponse.subject.extractId() + } else if ( + extractedResourceUniquePropertyExpressionsMap.containsKey(entryResource.resourceType) && + previouslyExtractedResources.containsKey( + entryResource.resourceType, + ) + ) { + val fhirPathExpression = + extractedResourceUniquePropertyExpressionsMap + .getValue(entryResource.resourceType) + .fhirPathExpression + + val currentResourceIdentifier = + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = entryResource, + expression = fhirPathExpression, ) - ) { - val fhirPathExpression = - extractedResourceUniquePropertyExpressionsMap - .getValue(entryResource.resourceType) - .fhirPathExpression - - val currentResourceIdentifier = - withContext(dispatcherProvider.default()) { - fhirPathDataExtractor.extractValue( - base = entryResource, - expression = fhirPathExpression, - ) - } + } - // Search for resource with property value matching extracted value - val resource = - previouslyExtractedResources.getValue(entryResource.resourceType).find { - val extractedValue = - withContext(dispatcherProvider.default()) { - fhirPathDataExtractor.extractValue( - base = it, - expression = fhirPathExpression, - ) - } - extractedValue.isNotEmpty() && - extractedValue.equals(currentResourceIdentifier, true) - } + // Search for resource with property value matching extracted value + val resource = + previouslyExtractedResources.getValue(entryResource.resourceType).find { + val extractedValue = + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = it, + expression = fhirPathExpression, + ) + } + extractedValue.isNotEmpty() && extractedValue.equals(currentResourceIdentifier, true) + } - // Found match use the id on current resource; override identifiers for RelatedPerson - if (resource != null) { - entryResource.id = resource.logicalId - if (entryResource is RelatedPerson && resource is RelatedPerson) { - entryResource.identifier = resource.identifier - } + // Found match use the id on current resource; override identifiers for RelatedPerson + if (resource != null) { + entryResource.id = resource.logicalId + if (entryResource is RelatedPerson && resource is RelatedPerson) { + entryResource.identifier = resource.identifier } } } + } - // Set Encounter on QR if the ResourceType is Encounter - if (entryResource.resourceType == ResourceType.Encounter) { - questionnaireResponse.setEncounter(entryResource.asReference()) - } + // Set Encounter on QR if the ResourceType is Encounter + if (entryResource.resourceType == ResourceType.Encounter) { + questionnaireResponse.setEncounter(entryResource.asReference()) + } else if (resolvedEncounterReference != null) { + // Ties this submission's other resources back to the resolved Encounter instead of + // duplicating its sync tags onto each of them — see + // feature/20260817-encounter-scoped-sync-tags.md. + entryResource.appendEncounterReference(resolvedEncounterReference) + } - // Set the Group's Related Entity Location metadata tag on Resource before saving. - entryResource.applyRelatedEntityLocationMetaTag(questionnaireConfig, context, subjectType) + // Set the Group's Related Entity Location metadata tag on Resource before saving. + entryResource.applyRelatedEntityLocationMetaTag(questionnaireConfig, context, subjectType) - defaultRepository.addOrUpdate(true, resource = entryResource) + // Sync-strategy tags land on the resolved Encounter only; everything else in the same + // submission relies on its .encounter reference for that context instead of duplicating + // the tags. When no Encounter is resolved at all, behavior is unchanged: tag everything. + val addMandatoryTags = + resolvedEncounterReference == null || entryResource.resourceType == ResourceType.Encounter + defaultRepository.addOrUpdate(addMandatoryTags, resource = entryResource) - updateGroupManagingEntity( - resource = entryResource, - groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, - managingEntityRelationshipCode = questionnaireConfig.managingEntityRelationshipCode, - ) - addMemberToGroup( - resource = entryResource, - memberResourceType = questionnaireConfig.groupResource?.memberResourceType, - groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, - ) + updateGroupManagingEntity( + resource = entryResource, + groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, + managingEntityRelationshipCode = questionnaireConfig.managingEntityRelationshipCode, + ) + addMemberToGroup( + resource = entryResource, + memberResourceType = questionnaireConfig.groupResource?.memberResourceType, + groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, + ) - // Track ids for resources in ListResource added to the QuestionnaireResponse.contained - val listEntryComponent = - ListEntryComponent().apply { - deleted = false - date = extractionDate - item = entryResource.asReference() - } - listResource.addEntry(listEntryComponent) - } + // Track ids for resources in ListResource added to the QuestionnaireResponse.contained + val listEntryComponent = + ListEntryComponent().apply { + deleted = false + date = extractionDate + item = entryResource.asReference() + } + listResource.addEntry(listEntryComponent) + } // Reference extracted resources in QR then save it if subject exists questionnaireResponse.apply { addContained(listResource) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index 7871215135c..74d6f07f7e9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -84,6 +84,47 @@ import timber.log.Timber const val PRACTITIONER_ID = "practitionerId" +/** + * [ActionParameter] key for `APPLY_NAMED_EVENT` action configs. Start-care generates an Encounter + * on the first extraction that doesn't already produce one, unless this is explicitly `"false"`. + * See `feature/20260817-encounter-scoped-sync-tags.md`. + */ +const val GENERATE_ENCOUNTER_PARAM_KEY = "generateEncounter" + +/** Absent or any value other than `"false"` means generate the session Encounter. */ +fun List.resolveGenerateEncounter(): Boolean = + find { it.key == GENERATE_ENCOUNTER_PARAM_KEY } + ?.value + ?.equals("false", ignoreCase = true) != true + +fun buildStartCareActionParameters( + encounterId: String?, + generateEncounter: Boolean, +): ArrayList = + ArrayList().apply { + if (!encounterId.isNullOrBlank()) { + // Makes the current-visit Encounter id available to CQL as the `encounterid` library + // parameter and tells extraction/save which Encounter to attach this submission to. + add( + ActionParameter( + key = "encounter", + paramType = ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE, + value = encounterId, + resourceType = org.hl7.fhir.r4.model.ResourceType.Encounter, + ), + ) + } + if (generateEncounter) { + add( + ActionParameter( + key = GENERATE_ENCOUNTER_PARAM_KEY, + paramType = ActionParameterType.PARAMDATA, + value = "true", + ), + ) + } + } + fun List.handleClickEvent( navController: NavController, resourceData: ResourceData? = null, @@ -318,6 +359,7 @@ private fun handleApplyNamedEvent( context.showToast("No client selected for care", Toast.LENGTH_SHORT) return } + val generateEncounter = interpolatedParams.resolveGenerateEncounter() val lifecycleOwner = context as? LifecycleOwner if (lifecycleOwner == null) { @@ -353,6 +395,7 @@ private fun handleApplyNamedEvent( subjectId = subjectId, plans = plans, title = actionDisplayOrDefault(computedValuesMap), + generateEncounter = generateEncounter, ) } } @@ -378,6 +421,7 @@ private fun showAvailableCarePicker( subjectId: String, plans: List, title: String, + generateEncounter: Boolean, ) { val labels = plans.map { it.title }.toTypedArray() val checked = BooleanArray(plans.size) { true } @@ -396,6 +440,7 @@ private fun showAvailableCarePicker( subjectId = subjectId, selectedPlanIds = selectedPlans.map { it.planDefinitionId }.toSet(), initialPlans = selectedPlans, + generateEncounter = generateEncounter, ) advanceAvailableCareSession(context, navController, service, eventBus, session) } @@ -415,6 +460,7 @@ private class AvailableCareSession( val subjectId: String, val selectedPlanIds: Set, initialPlans: List, + val generateEncounter: Boolean, ) { /** Unique per session so [EventBus]'s one-time-per-consumer delivery doesn't cross sessions. */ val consumerId: String = UUID.randomUUID().toString() @@ -479,7 +525,13 @@ private fun advanceAvailableCareSession( val chosenQuestionnaireId = chosen?.questionnaireId if (chosen == null || chosenQuestionnaireId.isNullOrBlank()) return@launch - launchInterventionOption(navController, chosen, session.encounterId) + launchInterventionOption( + navController, + chosen, + session.subjectId, + session.encounterId, + session.generateEncounter, + ) val submission = eventBus.events @@ -515,40 +567,28 @@ private suspend fun awaitTieBreakChoice( private fun launchInterventionOption( navController: NavController, option: NamedEventInterventionService.InterventionOption, + subjectId: String, encounterId: String? = null, + generateEncounter: Boolean = true, ) { val questionnaireId = option.questionnaireId if (!questionnaireId.isNullOrBlank() && navController.context is QuestionnaireHandler) { Timber.i( "APPLY_NAMED_EVENT launching Questionnaire/$questionnaireId title=${option.title} " + - "order=${option.order} encounter=$encounterId", + "order=${option.order} subject=$subjectId encounter=$encounterId " + + "generateEncounter=$generateEncounter", ) - val actionParams = - if (encounterId.isNullOrBlank()) { - emptyList() - } else { - // Makes the current-visit Encounter id available to CQL as the `encounterid` library - // parameter (see `feature/20260812-intervention-order-and-dedup.md`, tricc) so the - // dedup `initialExpression`s it wires up can actually resolve. - listOf( - ActionParameter( - key = "encounter", - paramType = ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE, - value = encounterId, - resourceType = org.hl7.fhir.r4.model.ResourceType.Encounter, - ), - ) - } (navController.context as QuestionnaireHandler).launchQuestionnaire( context = navController.context, questionnaireConfig = QuestionnaireConfig( id = questionnaireId, title = option.title, + resourceIdentifier = subjectId, resourceType = org.hl7.fhir.r4.model.ResourceType.Patient, saveButtonText = "Save", ), - actionParams = actionParams, + actionParams = buildStartCareActionParameters(encounterId, generateEncounter), ) return } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt index f4cd0744aeb..284419dd712 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -126,6 +126,7 @@ import org.smartregister.fhircore.quest.assertResourceEquals import org.smartregister.fhircore.quest.robolectric.RobolectricTest import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireViewModel.Companion.CONTAINED_LIST_TITLE import org.smartregister.fhircore.quest.util.QuestionnaireResponseUtils +import org.smartregister.fhircore.quest.util.extensions.GENERATE_ENCOUNTER_PARAM_KEY import org.smartregister.model.practitioner.FhirPractitionerDetails import org.smartregister.model.practitioner.PractitionerDetails @@ -1756,6 +1757,131 @@ class QuestionnaireViewModelTest : RobolectricTest() { assertEquals(linkId, listResource.id) } + @Test + fun testSaveExtractedResourcesTagsOnlyEncounterWhenBundleContainsOne() = runTest { + val encounter = Encounter().apply { id = "enc-1" } + val observation = Observation().apply { id = "obs-1" } + val bundle = + Bundle().apply { + addEntry(Bundle.BundleEntryComponent().apply { resource = encounter }) + addEntry(Bundle.BundleEntryComponent().apply { resource = observation }) + } + val questionnaire = extractionQuestionnaire() + val questionnaireResponse = extractionQuestionnaireResponse() + + coEvery { defaultRepository.addOrUpdate(any(Boolean::class), any()) } just runs + + questionnaireViewModel.saveExtractedResources( + bundle = bundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + context = context, + ) + + // Encounter is tagged; the Observation alongside it in the same bundle is not, and is + // instead attached to the Encounter via its own .encounter reference. + coVerify { defaultRepository.addOrUpdate(true, resource = encounter) } + coVerify { defaultRepository.addOrUpdate(false, resource = observation) } + Assert.assertEquals("Encounter/enc-1", observation.encounter.reference) + } + + @Test + fun testSaveExtractedResourcesTagsEveryResourceWhenNoEncounterIsResolved() = runTest { + val observation = Observation().apply { id = "obs-1" } + val bundle = + Bundle().apply { addEntry(Bundle.BundleEntryComponent().apply { resource = observation }) } + val questionnaire = extractionQuestionnaire() + val questionnaireResponse = extractionQuestionnaireResponse() + + coEvery { defaultRepository.addOrUpdate(any(Boolean::class), any()) } just runs + + // No Encounter in the bundle, no `encounter`/`generateEncounter` action params — a plain + // questionnaire submission outside a start-care session must behave exactly as before. + questionnaireViewModel.saveExtractedResources( + bundle = bundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + context = context, + ) + + coVerify { defaultRepository.addOrUpdate(true, resource = observation) } + Assert.assertFalse(observation.hasEncounter()) + } + + @Test + fun testSaveExtractedResourcesGeneratesEncounterWhenOptedIn() = runTest { + val observation = Observation().apply { id = "obs-1" } + val bundle = + Bundle().apply { addEntry(Bundle.BundleEntryComponent().apply { resource = observation }) } + val questionnaire = extractionQuestionnaire() + val questionnaireResponse = + extractionQuestionnaireResponse().apply { subject = patient.asReference() } + val actionParameters = + listOf( + ActionParameter( + key = GENERATE_ENCOUNTER_PARAM_KEY, + paramType = ActionParameterType.PARAMDATA, + value = "true", + ), + ) + + coEvery { defaultRepository.addOrUpdate(any(Boolean::class), any()) } just runs + + questionnaireViewModel.saveExtractedResources( + bundle = bundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + context = context, + actionParameters = actionParameters, + ) + + Assert.assertTrue(questionnaireResponse.hasEncounter()) + val generatedEncounterReference = questionnaireResponse.encounter.reference + Assert.assertEquals(generatedEncounterReference, observation.encounter.reference) + val generatedEncounter = bundle.entry.map { it.resource }.filterIsInstance().single() + Assert.assertEquals(patient.asReference().reference, generatedEncounter.subject.reference) + + coVerify { defaultRepository.addOrUpdate(true, resource = any()) } + coVerify { defaultRepository.addOrUpdate(false, resource = observation) } + } + + @Test + fun testSaveExtractedResourcesAttachesToCarriedEncounterWithoutGenerating() = runTest { + val observation = Observation().apply { id = "obs-1" } + val bundle = + Bundle().apply { addEntry(Bundle.BundleEntryComponent().apply { resource = observation }) } + val questionnaire = extractionQuestionnaire() + val questionnaireResponse = extractionQuestionnaireResponse() + val actionParameters = + listOf( + ActionParameter( + key = "encounter", + paramType = ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE, + value = "existing-enc-id", + resourceType = ResourceType.Encounter, + ), + ) + + coEvery { defaultRepository.addOrUpdate(any(Boolean::class), any()) } just runs + + questionnaireViewModel.saveExtractedResources( + bundle = bundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + context = context, + actionParameters = actionParameters, + ) + + // Reuses the carried-over Encounter id — no new Encounter generated this round. + Assert.assertFalse(questionnaireResponse.hasEncounter()) + Assert.assertEquals("Encounter/existing-enc-id", observation.encounter.reference) + coVerify { defaultRepository.addOrUpdate(false, resource = observation) } + } + @Test fun testRetireUsedQuestionnaireUniqueIdShouldUpdateGroupResourceWhenIDIsUsed() = runTest { val linkId = "phn" diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt index a47eab97efc..070674f8469 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt @@ -989,6 +989,57 @@ class ConfigExtensionsKtTest : RobolectricTest() { Assert.assertEquals(mapOf("k" to "v"), array.toParamDataMap()) } + @Test + fun testResolveGenerateEncounterDefaultsTrueWhenParamAbsent() { + Assert.assertTrue(emptyList().resolveGenerateEncounter()) + } + + @Test + fun testResolveGenerateEncounterIsTrueWhenParamIsTrue() { + val params = + listOf( + ActionParameter( + key = GENERATE_ENCOUNTER_PARAM_KEY, + paramType = ActionParameterType.PARAMDATA, + value = "true", + ), + ) + Assert.assertTrue(params.resolveGenerateEncounter()) + } + + @Test + fun testResolveGenerateEncounterIsFalseOnlyWhenExplicitlyFalse() { + val params = + listOf( + ActionParameter( + key = GENERATE_ENCOUNTER_PARAM_KEY, + paramType = ActionParameterType.PARAMDATA, + value = "false", + ), + ) + Assert.assertFalse(params.resolveGenerateEncounter()) + } + + @Test + fun testBuildStartCareActionParametersIncludesGenerateEncounterAsArrayList() { + val params = buildStartCareActionParameters(encounterId = null, generateEncounter = true) + Assert.assertEquals(java.util.ArrayList::class.java, params.javaClass) + Assert.assertEquals(1, params.size) + Assert.assertEquals(GENERATE_ENCOUNTER_PARAM_KEY, params[0].key) + Assert.assertEquals("true", params[0].value) + Assert.assertEquals(ActionParameterType.PARAMDATA, params[0].paramType) + } + + @Test + fun testBuildStartCareActionParametersCarriesEncounterWithoutDroppingGenerateFlag() { + val params = + buildStartCareActionParameters(encounterId = "enc-1", generateEncounter = true) + Assert.assertEquals(2, params.size) + Assert.assertEquals("encounter", params[0].key) + Assert.assertEquals("enc-1", params[0].value) + Assert.assertEquals(GENERATE_ENCOUNTER_PARAM_KEY, params[1].key) + } + @Test fun testShowToastWhenAnImageWithActionParamsIsPressed() { val context = mockk(relaxed = true)