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 @@ -58,7 +58,28 @@ class ChatTypeConverters {

@TypeConverter
fun fromUserProfile(value: String?): UserProfileSerialized? {
return value?.let { json.decodeFromString<UserProfileSerialized>(it) }
return value?.let {
// Decode via a compat DTO so rows persisted before phone/email were
// modeled as VerifiableContactMethod still load. Legacy rows stored
// verifiedPhoneNumber/verifiedEmailAddress as plain strings; a present
// value means the contact was verified, so migrate it as verified=true.
// runCatching guards against any future schema drift crashing chat loads.
runCatching {
val compat = json.decodeFromString<UserProfileSerializedCompat>(it)
UserProfileSerialized(
displayName = compat.displayName,
socialAccounts = compat.socialAccounts,
phoneNumber = compat.phoneNumber
?: compat.verifiedPhoneNumber?.let { number ->
VerifiableContactMethod(number, verified = true)
},
email = compat.email
?: compat.verifiedEmailAddress?.let { address ->
VerifiableContactMethod(address, verified = true)
},
)
}.getOrNull()
}
}

@TypeConverter
Expand Down Expand Up @@ -140,8 +161,24 @@ data class MessagePointerSerialized(
data class UserProfileSerialized(
val displayName: String?,
val socialAccounts: List<SocialAccountSerialized>,
val phoneNumber: VerifiableContactMethod?,
val email: VerifiableContactMethod?,
val phoneNumber: VerifiableContactMethod? = null,
val email: VerifiableContactMethod? = null,
)

/**
* Tolerant read model for [UserProfileSerialized] that carries both the current
* fields and the pre-migration legacy keys (`verifiedPhoneNumber` /
* `verifiedEmailAddress`, stored as plain strings). Used only when decoding
* persisted rows so older data can be migrated forward. Every field is optional.
*/
@Serializable
private data class UserProfileSerializedCompat(
val displayName: String? = null,
val socialAccounts: List<SocialAccountSerialized> = emptyList(),
val phoneNumber: VerifiableContactMethod? = null,
val email: VerifiableContactMethod? = null,
val verifiedPhoneNumber: String? = null,
val verifiedEmailAddress: String? = null,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,49 @@ class ChatTypeConvertersTest {
assertEquals(original, deserialized)
}

@Test
fun `fromUserProfile migrates legacy verified phone and email strings`() {
// Row persisted before phone/email were modeled as VerifiableContactMethod.
val legacyJson = """
{
"displayName": "Bob",
"socialAccounts": [],
"verifiedPhoneNumber": "+15551234567",
"verifiedEmailAddress": "bob@example.com"
}
""".trimIndent()

val result = converter.fromUserProfile(legacyJson)

assertEquals(
UserProfileSerialized(
displayName = "Bob",
socialAccounts = emptyList(),
phoneNumber = VerifiableContactMethod("+15551234567", verified = true),
email = VerifiableContactMethod("bob@example.com", verified = true),
),
result,
)
}

@Test
fun `fromUserProfile decodes legacy row missing phone and email as null`() {
// Legacy row that never had contact fields at all must not crash.
val legacyJson = """{"displayName":"Carol","socialAccounts":[]}"""

val result = converter.fromUserProfile(legacyJson)

assertEquals(
UserProfileSerialized(
displayName = "Carol",
socialAccounts = emptyList(),
phoneNumber = null,
email = null,
),
result,
)
}

@Test
fun `fromUserProfile returns null for null input`() {
assertNull(converter.fromUserProfile(null))
Expand Down
Loading