Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down Expand Up @@ -468,7 +468,7 @@ constructor(
}
}

processCompositionSectionComponent(sectionComponentMap)
processCompositionSectionComponent(sectionComponentMap, forceRefresh)

// Save composition after fetching all the referenced section resources
addOrUpdate(compositionResource)
Expand All @@ -480,12 +480,13 @@ constructor(

private suspend fun processCompositionSectionComponent(
sectionComponentMap: Map<String, List<Composition.SectionComponent>>,
forceRefresh: Boolean = false,
) {
sectionComponentMap
.filter { entry -> entry.key in FILTER_RESOURCE_LIST }
.forEach { entry: Map.Entry<String, List<Composition.SectionComponent>> ->
if (entry.key == ResourceType.List.name) {
processCompositionListResources(entry)
processCompositionListResources(entry, forceRefresh)
} else {
val chunkedResourceIdList = entry.value.chunked(MANIFEST_PROCESSOR_BATCH_SIZE)

Expand All @@ -501,6 +502,7 @@ constructor(
resourceType = entry.key,
resourceIdList =
sectionComponents.map { sectionComponent -> sectionComponent.focus.extractId() },
forceRefresh = forceRefresh,
)
}
}
Expand Down Expand Up @@ -557,14 +559,15 @@ constructor(
private suspend fun fetchResources(
resourceType: String,
resourceIdList: List<String>,
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),
)
Expand Down Expand Up @@ -705,15 +708,19 @@ constructor(
}

@VisibleForTesting
fun generateRequestBundle(resourceType: String, idList: List<String>): Bundle {
fun generateRequestBundle(
resourceType: String,
idList: List<String>,
forceRefresh: Boolean = false,
): Bundle {
val bundleEntryComponents = mutableListOf<Bundle.BundleEntryComponent>()

idList.forEach {
bundleEntryComponents.add(
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
}
},
Expand All @@ -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<String>,
forceRefresh: Boolean = false,
): Bundle =
Bundle().apply {
type = Bundle.BundleType.COLLECTION
Expand All @@ -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
}
Expand All @@ -755,13 +769,15 @@ constructor(

private suspend fun processCompositionListResources(
sectionComponentEntry: Map.Entry<String, List<Composition.SectionComponent>>,
forceRefresh: Boolean = false,
) {
if (isNonProxy()) {
val chunkedResourceIdList = sectionComponentEntry.value.chunked(MANIFEST_PROCESSOR_BATCH_SIZE)
chunkedResourceIdList.forEach {
fetchResources(
resourceType = sectionComponentEntry.key,
resourceIdList = it.map { sectionComponent -> sectionComponent.focus.extractId() },
forceRefresh = forceRefresh,
)
.entry
.forEach { bundleEntryComponent ->
Expand Down Expand Up @@ -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,
Expand All @@ -913,7 +930,7 @@ constructor(
ResourceType.Measure.name,
ResourceType.Basic.name,
ResourceType.Binary.name,
ResourceType.Parameters,
ResourceType.Parameters.name,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ data class ApplicationConfiguration(
val settingsScreenMenuOptions: List<SettingsOptions> =
listOf(
SettingsOptions.MANUAL_SYNC,
SettingsOptions.SYNC_CONFIGURATION,
SettingsOptions.SWITCH_LANGUAGES,
SettingsOptions.RESET_DATA,
SettingsOptions.INSIGHTS,
Expand Down Expand Up @@ -77,6 +78,7 @@ enum class LocationLogOptions {

enum class SettingsOptions {
MANUAL_SYNC,
SYNC_CONFIGURATION,
OFFLINE_MAPS,
SWITCH_LANGUAGES,
RESET_DATA,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<RepositoryResourceData>,
) {
val patientRows = repositoryResourceDataList.filter { it.resource is Patient }
if (patientRows.isEmpty()) return

val allRelatedPersons =
runCatching { search<RelatedPerson>(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<Patient>(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<RepositoryResourceData>,
) {
repositoryResourceDataList.forEach { row ->
if (row.resource !is Patient) return@forEach
val relatedPersons =
row.relatedResourcesMap["relatedPersons"]?.filterIsInstance<RelatedPerson>().orEmpty()
if (relatedPersons.isEmpty()) return@forEach

val guardians =
relatedPersons.mapNotNull { rp ->
val guardianId =
rp.guardianPatientReference()?.extractLogicalIdUuid() ?: return@mapNotNull null
runCatching { fhirEngine.get<Patient>(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,
Expand Down Expand Up @@ -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()
}

Expand Down
Loading