fix(daily-question): add reconcileLocalAnswerFromFirestore to heal Room/Firestore desync

This commit is contained in:
null 2026-06-30 19:05:59 -05:00
parent 8d563d4fd4
commit a6d3062585
1 changed files with 55 additions and 0 deletions

View File

@ -1,9 +1,64 @@
package app.closer.ui.questions
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.LocalAnswerRepository
/**
* Reconcile a missing local answer with Firestore. When Room has no answer for [question] but the
* couple's Firestore record shows this [userId] already answered [date]'s question, rebuild the
* answer from the read-gated couple-key payload (the owner can always read their own) and persist
* it back to Room so subsequent loads are correct.
*
* This closes the R23 data-loss class: on a fresh device / after the local DB is cleared, Room is
* empty while Firestore still holds the answer, so the screen would otherwise offer a re-answer
* but the `secure/payload` doc is immutable (`allow update: if false`), so the re-write is silently
* rejected and a *changed* answer is lost. Returning the prior answer keeps the screen in its
* submitted/reveal state instead of an editable form. Returns null only when there is genuinely no
* prior answer (the normal first-answer path).
*
* The decrypted content is persisted to Room only when it actually decrypts, so a transient
* key-unavailable read never poisons Room with an empty answer (a later load can still recover it).
*
* Room-first: when Room already holds the answer it is returned immediately (a single local read,
* no network), so this is safe to call on every load from any surface.
*/
suspend fun reconcileLocalAnswerFromFirestore(
question: Question,
coupleId: String,
date: String,
userId: String,
firestore: FirestoreAnswerDataSource,
localAnswers: LocalAnswerRepository
): LocalAnswer? {
localAnswers.getAnswer(question.id)?.let { return it }
val meta = runCatching { firestore.getAnswerForUser(coupleId, userId, date) }.getOrNull() ?: return null
// The daily assignment can rotate; only heal when the stored answer is for THIS question.
if (meta.questionId.isNotBlank() && meta.questionId != question.id) return null
val decoded = runCatching { firestore.decryptCoupleKeyAnswerFor(coupleId, date, userId) }.getOrNull()
val ids = decoded?.selectedOptionIds ?: emptyList()
val healed = LocalAnswer(
questionId = question.id,
questionText = question.text,
category = question.category,
answerType = meta.answerType.ifBlank { question.type },
writtenText = decoded?.writtenText,
selectedOptionIds = ids,
selectedOptionTexts = selectedOptionTexts(question, ids),
scaleValue = decoded?.scaleValue,
createdAt = meta.createdAt,
updatedAt = meta.updatedAt,
isRevealed = meta.isRevealed,
schemaVersion = 2,
isSealed = false,
answerDate = date
)
if (decoded != null) runCatching { localAnswers.saveAnswer(healed) }
return healed
}
fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState {
answer ?: return copy(submitted = false, isRevealed = false)