Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepositor
import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecord
import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecordQuery
import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEvents
import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEventsV1
import com.simprints.infra.events.event.cosync.v1.toCoSync
import com.simprints.infra.events.event.domain.models.EnrolmentRecordCreationEvent
import com.simprints.infra.logging.Simber
import com.simprints.infra.serialization.SimJson
Expand Down Expand Up @@ -40,7 +42,9 @@ internal class GetEnrolmentCreationEventForRecordUseCase @Inject constructor(
)
return null
}
return SimJson.encodeToString(CoSyncEnrolmentRecordEvents(listOf(recordCreationEvent)))
return SimJson.encodeToString<CoSyncEnrolmentRecordEvents>(
CoSyncEnrolmentRecordEventsV1(events = listOf(recordCreationEvent.toCoSync())),
)
}

private fun EnrolmentRecord.fromSubjectToEnrolmentCreationEvent() = EnrolmentRecordCreationEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ internal class CommCareCandidateRecordDataSource @Inject constructor(
Simber.d(subjectActions)
val coSyncEnrolmentRecordEvents = parseRecordEvents(subjectActions)

coSyncEnrolmentRecordEvents?.events?.filterIsInstance<EnrolmentRecordCreationEvent>()?.filter { event ->
coSyncEnrolmentRecordEvents?.toDomainEvents()?.filterIsInstance<EnrolmentRecordCreationEvent>()?.filter { event ->
// [MS-852] Plain strings from CommCare might be tokenized or untokenized. The only way to properly compare them
// is by trying to decrypt the values to check if already tokenized, and then compare the values
isSubjectIdNullOrMatching(query, event) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ internal class CommCareEventDataSource @Inject constructor(
}

coSyncEnrolmentRecordEvents
.events
.toDomainEvents()
.filterIsInstance<EnrolmentRecordCreationEvent>()
.forEach { event ->
pendingSyncedCases.add(case.copy(simprintsId = event.payload.subjectId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,58 @@ package com.simprints.infra.events.event.cosync

import androidx.annotation.Keep
import com.simprints.infra.events.event.domain.models.EnrolmentRecordEvent
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

/** Extracts the major version from a schema version string (e.g. "1.2" → "1"). */
private fun String.majorVersion(): String = substringBefore(".")

/**
* Sealed interface for versioned CoSync enrolment record events.
*
* Uses [CoSyncEnrolmentRecordEventsSerializer] to inspect the `schemaVersion`
* field in JSON and route to the correct version-specific deserializer.
* Missing `schemaVersion` defaults to V1 for backward compatibility.
*
* Versioning convention:
* - Major version changes (e.g. "1.x" → "2.x") require a new sealed subclass.
* - Minor version changes (e.g. "1.0" → "1.1") are non-breaking (new optional/nullable
* fields) and are handled by the same subclass via `ignoreUnknownKeys` and defaults.
*/
@Keep
@Serializable
data class CoSyncEnrolmentRecordEvents(
val events: List<EnrolmentRecordEvent>,
)
@Serializable(with = CoSyncEnrolmentRecordEventsSerializer::class)
sealed interface CoSyncEnrolmentRecordEvents {
val schemaVersion: String

/** Converts version-specific models to internal domain events. */
fun toDomainEvents(): List<EnrolmentRecordEvent>
}

/**
* Polymorphic serializer that inspects the `schemaVersion` JSON field
* to select the correct [CoSyncEnrolmentRecordEvents] subclass.
*
* Matches on major version only. See [CoSyncEnrolmentRecordEvents] for versioning convention.
* Defaults to [CoSyncEnrolmentRecordEventsV1] when `schemaVersion` is missing.
*/
internal object CoSyncEnrolmentRecordEventsSerializer :
JsonContentPolymorphicSerializer<CoSyncEnrolmentRecordEvents>(CoSyncEnrolmentRecordEvents::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<CoSyncEnrolmentRecordEvents> {
val majorVersion = element.jsonObject["schemaVersion"]
?.jsonPrimitive
?.contentOrNull
?.majorVersion()

return when (majorVersion) {
null, CoSyncEnrolmentRecordEventsV1.SCHEMA_VERSION.majorVersion() -> CoSyncEnrolmentRecordEventsV1.serializer()
else -> throw IllegalArgumentException(
"Unknown CoSync schemaVersion: ${element.jsonObject["schemaVersion"]}",
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.simprints.infra.events.event.cosync

import androidx.annotation.Keep
import com.simprints.infra.events.event.cosync.v1.CoSyncEnrolmentRecordEvent
import com.simprints.infra.events.event.cosync.v1.toEventDomain
import com.simprints.infra.events.event.domain.models.EnrolmentRecordEvent
import kotlinx.serialization.Serializable

/**
* V1 schema for CoSync enrolment record events.
*
* Compatibility:
* - Old JSON without `schemaVersion` is deserialized as V1 (backward compatible).
* - Serialized JSON includes `schemaVersion = "1.0"` (forward compatible).
*/
@Keep
@Serializable
data class CoSyncEnrolmentRecordEventsV1(
override val schemaVersion: String = SCHEMA_VERSION,
val events: List<CoSyncEnrolmentRecordEvent>,
) : CoSyncEnrolmentRecordEvents {

override fun toDomainEvents(): List<EnrolmentRecordEvent> = events.map { it.toEventDomain() }

companion object {
const val SCHEMA_VERSION = "1.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.simprints.infra.events.event.cosync.v1

import androidx.annotation.Keep
import com.simprints.infra.events.event.domain.models.BiometricReference
import com.simprints.infra.events.event.domain.models.FaceReference
import com.simprints.infra.events.event.domain.models.FingerprintReference
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* V1 external schema for biometric references (polymorphic base type).
* Stable external contract decoupled from internal [BiometricReference].
*/
@Keep
@Serializable
sealed class CoSyncBiometricReference {
abstract val id: String
abstract val format: String
}

@Keep
@Serializable
@SerialName("FACE_REFERENCE")
data class CoSyncFaceReference(
override val id: String,
val templates: List<CoSyncFaceTemplate>,
override val format: String,
val metadata: Map<String, String>? = null,
) : CoSyncBiometricReference()

@Keep
@Serializable
@SerialName("FINGERPRINT_REFERENCE")
data class CoSyncFingerprintReference(
override val id: String,
val templates: List<CoSyncFingerprintTemplate>,
override val format: String,
val metadata: Map<String, String>? = null,
) : CoSyncBiometricReference()

fun BiometricReference.toCoSync(): CoSyncBiometricReference = when (this) {
is FaceReference -> toCoSync()
is FingerprintReference -> toCoSync()
}

fun CoSyncBiometricReference.toEventDomain(): BiometricReference = when (this) {
is CoSyncFaceReference -> toEventDomain()
is CoSyncFingerprintReference -> toEventDomain()
}

fun FaceReference.toCoSync() = CoSyncFaceReference(
id = id,
templates = templates.map { it.toCoSync() },
format = format,
metadata = metadata,
)

fun CoSyncFaceReference.toEventDomain() = FaceReference(
id = id,
templates = templates.map { it.toEventDomain() },
format = format,
metadata = metadata,
)

fun FingerprintReference.toCoSync() = CoSyncFingerprintReference(
id = id,
templates = templates.map { it.toCoSync() },
format = format,
metadata = metadata,
)

fun CoSyncFingerprintReference.toEventDomain() = FingerprintReference(
id = id,
templates = templates.map { it.toEventDomain() },
format = format,
metadata = metadata,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.simprints.infra.events.event.cosync.v1

import androidx.annotation.Keep
import com.simprints.infra.events.event.domain.models.EnrolmentRecordCreationEvent
import com.simprints.infra.events.event.domain.models.EnrolmentRecordEvent
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* V1 external schema for enrolment record events (polymorphic base type).
* Stable external contract decoupled from internal [EnrolmentRecordEvent].
*/
@Keep
@Serializable
sealed class CoSyncEnrolmentRecordEvent {
abstract val id: String
}

/**
* V1 external schema for enrolment record creation event.
*/
@Keep
@Serializable
@SerialName("EnrolmentRecordCreation")
data class CoSyncEnrolmentRecordCreationEvent(
override val id: String,
val payload: CoSyncEnrolmentRecordCreationPayload,
) : CoSyncEnrolmentRecordEvent()

@Keep
@Serializable
data class CoSyncEnrolmentRecordCreationPayload(
val subjectId: String,
val projectId: String,
val moduleId: CoSyncTokenizableString,
val attendantId: CoSyncTokenizableString,
val biometricReferences: List<CoSyncBiometricReference> = emptyList(),
val externalCredentials: List<CoSyncExternalCredential> = emptyList(),
)

fun EnrolmentRecordEvent.toCoSync(): CoSyncEnrolmentRecordEvent = when (this) {
is EnrolmentRecordCreationEvent -> toCoSync()
else -> throw IllegalArgumentException("Unsupported event type for V1: ${this::class.simpleName}")
}

fun CoSyncEnrolmentRecordEvent.toEventDomain(): EnrolmentRecordEvent = when (this) {
is CoSyncEnrolmentRecordCreationEvent -> toEventDomain()
}

fun EnrolmentRecordCreationEvent.toCoSync() = CoSyncEnrolmentRecordCreationEvent(
id = id,
payload = CoSyncEnrolmentRecordCreationPayload(
subjectId = payload.subjectId,
projectId = payload.projectId,
moduleId = payload.moduleId.toCoSync(),
attendantId = payload.attendantId.toCoSync(),
biometricReferences = payload.biometricReferences.map { it.toCoSync() },
externalCredentials = payload.externalCredentials.map { it.toCoSync() },
),
)

fun CoSyncEnrolmentRecordCreationEvent.toEventDomain() = EnrolmentRecordCreationEvent(
id = id,
payload = EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload(
subjectId = payload.subjectId,
projectId = payload.projectId,
moduleId = payload.moduleId.toDomain(),
attendantId = payload.attendantId.toDomain(),
biometricReferences = payload.biometricReferences.map { it.toEventDomain() },
externalCredentials = payload.externalCredentials.map { it.toDomain() },
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.simprints.infra.events.event.cosync.v1

import androidx.annotation.Keep
import com.simprints.core.domain.externalcredential.ExternalCredential
import com.simprints.core.domain.tokenization.TokenizableString
import kotlinx.serialization.Serializable

/**
* V1 external schema for external credentials.
* Stable external contract decoupled from internal [ExternalCredential].
*/
@Keep
@Serializable
data class CoSyncExternalCredential(
val id: String,
val value: CoSyncTokenizableString,
val subjectId: String,
val type: CoSyncExternalCredentialType,
)

fun ExternalCredential.toCoSync() = CoSyncExternalCredential(
id = id,
value = value.toCoSync(),
subjectId = subjectId,
type = type.toCoSync(),
)

fun CoSyncExternalCredential.toDomain() = ExternalCredential(
id = id,
value = when (val domainValue = value.toDomain()) {
is TokenizableString.Tokenized -> domainValue
is TokenizableString.Raw -> error("ExternalCredential value must be Tokenized, got Raw for id=$id")
},
subjectId = subjectId,
type = type.toDomain(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.simprints.infra.events.event.cosync.v1

import androidx.annotation.Keep
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.core.domain.externalcredential.ExternalCredentialType
import kotlinx.serialization.Serializable

/**
* V1 external schema for external credential types.
* Stable external contract decoupled from internal [ExternalCredentialType].
*/
@Keep
@Serializable
@ExcludedFromGeneratedTestCoverageReports("Enum")
enum class CoSyncExternalCredentialType {
NHISCard,
GhanaIdCard,
QRCode,
}

fun ExternalCredentialType.toCoSync(): CoSyncExternalCredentialType = when (this) {
ExternalCredentialType.NHISCard -> CoSyncExternalCredentialType.NHISCard
ExternalCredentialType.GhanaIdCard -> CoSyncExternalCredentialType.GhanaIdCard
ExternalCredentialType.QRCode -> CoSyncExternalCredentialType.QRCode
}

fun CoSyncExternalCredentialType.toDomain(): ExternalCredentialType = when (this) {
CoSyncExternalCredentialType.NHISCard -> ExternalCredentialType.NHISCard
CoSyncExternalCredentialType.GhanaIdCard -> ExternalCredentialType.GhanaIdCard
CoSyncExternalCredentialType.QRCode -> ExternalCredentialType.QRCode
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.simprints.infra.events.event.cosync.v1

import androidx.annotation.Keep
import com.simprints.infra.events.event.domain.models.FaceTemplate
import com.simprints.infra.events.event.domain.models.FingerprintTemplate
import kotlinx.serialization.Serializable

/** V1 external schema for face template. */
@Keep
@Serializable
data class CoSyncFaceTemplate(val template: String)

/** V1 external schema for fingerprint template with finger identifier. */
@Keep
@Serializable
data class CoSyncFingerprintTemplate(
val template: String,
val finger: CoSyncTemplateIdentifier,
)

fun FaceTemplate.toCoSync() = CoSyncFaceTemplate(template = template)
fun CoSyncFaceTemplate.toEventDomain() = FaceTemplate(template = template)

fun FingerprintTemplate.toCoSync() = CoSyncFingerprintTemplate(
template = template,
finger = finger.toCoSync(),
)

fun CoSyncFingerprintTemplate.toEventDomain() = FingerprintTemplate(
template = template,
finger = finger.toDomain(),
)
Loading
Loading