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/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 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/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..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 @@ -71,4 +71,21 @@ 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, + + /** + * 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 d0cd0656dee..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 @@ -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,16 @@ 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.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.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 @Singleton class RegisterRepository @@ -98,9 +112,91 @@ constructor( repositoryResourceDataList = repositoryResourceDataList, ) + enrichDependentChildrenFromRelatedPersons(repositoryResourceDataList) + enrichGuardianPatientsFromRelatedPersons(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 + } + } + + /** + * 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, @@ -173,6 +269,9 @@ constructor( configComputedRuleValues = configComputedRuleValues, repositoryResourceDataList = repositoryResourceDataList, ) + // 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/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/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/rulesengine/RulesFactory.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt index 96216a28e71..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 @@ -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,18 @@ 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/task/NamedEventInterventionService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt new file mode 100644 index 00000000000..535bbd038e8 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/NamedEventInterventionService.kt @@ -0,0 +1,442 @@ +/* + * 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.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 +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 + * FHIR content only — see `feature/register-tricc.md`. + * + * Options without a resolvable Questionnaire id are **omitted** (no empty toast entries). + */ +@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, + /** + * 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`). + * + * Prefers evaluating action.condition with FHIRPath (no side effects). Falls back to workflow + * `$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, + 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, + ) + } + + // 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() } + .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 + } + + 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()) { + // Strategy PD: evaluate nested children; leaf PD: evaluate the matching action itself + actionsWithEvent.flatMap { parent -> + if (parent.action.isNullOrEmpty()) listOf(parent) else parent.action + } + } else { + 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 { + it.hasExpression() && + it.expression.language != Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() + } + } + + if (needsApply) { + collectFromWorkflowApply(planDefinition, patient, options) + return + } + + actionsToEvaluate.forEach { action -> + if (!action.passesFhirPathConditions(patient)) { + Timber.d( + "Skipping non-applicable action '${action.title}' on PlanDefinition/${planDefinition.logicalId}", + ) + return@forEach + } + addResolvedOption(action.toInterventionOption(planDefinition), 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 -> + addResolvedOption(rgAction.toInterventionOption(planDefinition), options) + } + } + // 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( + nestedOption.questionnaireId!!, + nestedOption.copy(id = nestedOption.questionnaireId!!), + ) + } + } + } + } + } + + 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()) { + // Non-FHIRPath handled by needsApply branch; treat as not applicable on this path + 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 = + questionnaireId ?: id ?: definition ?: "${planDefinition.logicalId}-${title.hashCode()}" + return InterventionOption( + id = id, + title = title, + description = description, + 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? { + 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 = + questionnaireId ?: 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..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 @@ -24,7 +24,6 @@ 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 @@ -66,12 +65,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 +85,7 @@ constructor( data, ) as CarePlan - acceptCarePlan(carePlanProposal, output) + acceptCarePlan(carePlanProposal, output, persist) resolveDynamicValues( planDefinition = planDefinition, @@ -191,20 +195,32 @@ 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()}") } } } /** - * 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 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 +228,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 +241,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 +254,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) } @@ -270,12 +289,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/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/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..1a18539a891 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatient.kt @@ -0,0 +1,356 @@ +/* + * 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 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.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 + +/** + * 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" + +/** + * 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. + * + * 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 this list of [RelatedPerson]s 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 }) +} + +/** + * 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 delimiterIndex = trimmed.indexOf("Patient/", ignoreCase = true) + if (delimiterIndex == -1) return null + val afterPatient = trimmed.substring(delimiterIndex + "Patient/".length) + val logicalId = + 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. + */ +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/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/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/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/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/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 new file mode 100644 index 00000000000..34324abbdcc --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/RelatedPersonAsPatientTest.kt @@ -0,0 +1,208 @@ +/* + * 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.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 +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()) + } + + @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/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/feature/client-register-applicable-care.md b/android/feature/client-register-applicable-care.md new file mode 100644 index 00000000000..7400281e82a --- /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 | **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). | + +--- + +## 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/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. diff --git a/android/feature/register-tricc.md b/android/feature/register-tricc.md new file mode 100644 index 00000000000..86e1cd727b8 --- /dev/null +++ b/android/feature/register-tricc.md @@ -0,0 +1,629 @@ +# 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 + +```jsonc +"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 + +```jsonc +{ + "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" + }] +} +``` + +**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 +{ + "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 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`**. + +| 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. +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. + +--- + +# 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/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/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/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..06e845d815f --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/di/NamedEventInterventionEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * 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 +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/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index 46ad2c99c06..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 @@ -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() { @@ -84,6 +88,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 +147,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 @@ -156,7 +168,13 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { } if (savedInstanceState == null) { - lifecycleScope.launch { launchQuestionnaire() } + lifecycleScope.launch { + try { + launchQuestionnaire() + } catch (e: Exception) { + handleQuestionnaireRenderingFailure(e) + } + } } setupLocationServices() @@ -171,6 +189,57 @@ 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. + */ + private fun handleUncaughtQuestionnaireException(thread: Thread, throwable: Throwable) { + if (!isQuestionnaireRenderingException(throwable)) { + 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) + } + + 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 { + alertDialog?.dismiss() + alertDialog = null + 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 reviewRecordAudioPermissionToLaunchSpeechToText() { when { PermissionUtils.checkPermissions( @@ -297,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) @@ -333,44 +404,52 @@ 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 @@ -388,9 +467,8 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { 200, ) } - } catch (e: IllegalArgumentException) { - Timber.e(e) - showToast(e.message.toString()) + } catch (e: Exception) { + handleQuestionnaireRenderingFailure(e) } finally { removeSpeechToTextFragment() } @@ -443,6 +521,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( @@ -529,6 +624,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 { @@ -624,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/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..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 @@ -54,12 +55,16 @@ 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.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 +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 @@ -81,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 @@ -94,6 +100,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 @@ -105,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 @@ -203,14 +211,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, @@ -226,8 +254,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, ) } @@ -247,6 +277,7 @@ constructor( questionnaireConfig = questionnaireConfig, questionnaireResponse = currentQuestionnaireResponse, context = context, + actionParameters = actionParameters, ) updateResourcesLastUpdatedProperty(actionParameters) @@ -432,6 +463,7 @@ constructor( questionnaireConfig: QuestionnaireConfig, questionnaireResponse: QuestionnaireResponse, context: Context, + actionParameters: List = emptyList(), ) { val extractionDate = Date() @@ -459,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, + ) + ) { + val fhirPathExpression = + extractedResourceUniquePropertyExpressionsMap + .getValue(entryResource.resourceType) + .fhirPathExpression - if (questionnaireConfig.isEditable()) { - if (entryResource.resourceType == subjectType) { - entryResource.id = questionnaireResponse.subject.extractId() - } else if ( - extractedResourceUniquePropertyExpressionsMap.containsKey(entryResource.resourceType) && - previouslyExtractedResources.containsKey( - entryResource.resourceType, + 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) } @@ -760,16 +840,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 }, ), ) @@ -781,16 +881,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, ) } @@ -965,6 +1066,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 +1412,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 +1612,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/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..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 @@ -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, ) @@ -234,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, ) 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/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..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 @@ -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,20 @@ 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 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 import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig import org.smartregister.fhircore.engine.configuration.view.CardViewProperties @@ -49,6 +59,8 @@ 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 import org.smartregister.fhircore.engine.util.extension.encodeJson @@ -58,15 +70,61 @@ 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.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 +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 +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, @@ -256,10 +314,295 @@ fun ActionConfig.handleClickEvent( ) navController.navigate(MainNavigationScreen.AlertDialogFragment.route, args) } + ApplicationWorkflow.APPLY_NAMED_EVENT -> { + handleApplyNamedEvent( + navController = navController, + interpolatedParams = interpolatedParams, + resourceId = resourceId, + 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 } } +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 generateEncounter = interpolatedParams.resolveGenerateEncounter() + + 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 entryPoint = + EntryPointAccessors.fromApplication( + context.applicationContext, + NamedEventInterventionEntryPoint::class.java, + ) + val service = entryPoint.namedEventInterventionService() + + lifecycleOwner.lifecycleScope.launch { + val plans = + runCatching { service.listAvailableCarePlans(namedEvent, subjectId) } + .onFailure { Timber.e(it, "Failed to list available care plans for event=$namedEvent") } + .getOrDefault(emptyList()) + + if (plans.isEmpty()) { + context.showToast("No care available for this client", Toast.LENGTH_LONG) + return@launch + } + + showAvailableCarePicker( + context = context, + navController = navController, + service = service, + eventBus = entryPoint.eventBus(), + namedEvent = namedEvent, + subjectId = subjectId, + plans = plans, + title = actionDisplayOrDefault(computedValuesMap), + generateEncounter = generateEncounter, + ) + } +} + +private fun actionDisplayOrDefault(computedValuesMap: Map): String { + val fromMap = computedValuesMap["actionDisplay"] as? 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, + generateEncounter: Boolean, +) { + 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, + generateEncounter = generateEncounter, + ) + 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, + val generateEncounter: Boolean, +) { + /** 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.subjectId, + session.encounterId, + session.generateEncounter, + ) + + 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, + 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} subject=$subjectId encounter=$encounterId " + + "generateEncounter=$generateEncounter", + ) + (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 = buildStartCareActionParameters(encounterId, generateEncounter), + ) + return + } + + // Should not happen once listAvailableCarePlans filters to Questionnaire-only options. + navController.context.showToast( + "No questionnaire for: ${option.title}", + Toast.LENGTH_LONG, + ) + Timber.e( + "APPLY_NAMED_EVENT option has no Questionnaire id=${option.id} definition=${option.definitionCanonical}", + ) +} + fun interpolateActionParamsValue(actionConfig: ActionConfig, resourceData: ResourceData?) = actionConfig.params .encodeJson() diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 81fe8a3b53d..5d0f0c25051 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… @@ -142,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/CdssRegistrationDiagnosticTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt new file mode 100644 index 00000000000..5ef43623507 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/CdssRegistrationDiagnosticTest.kt @@ -0,0 +1,170 @@ +/* + * 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.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 — registration extracts the child Patient only (no inline guardian). + 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"}}]} + ] + } + """ + .trimIndent() + .decodeResourceFromString() + + val minorBundle = extract(questionnaire, compiled, minorResponse) + println("MINOR ENTRY COUNT = ${minorBundle.entry.size}") + println(minorBundle.encodeResourceToString()) + 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 }, + ) + } +} 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 = 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..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" @@ -2073,6 +2199,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 = 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 { 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)