From 18ffdcdbafd42049cc61d367a5902db54d606121 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 16:51:50 -0500 Subject: [PATCH] feat(date-memories): add DateMemory/DateReflection domain models and Firestore data sources (batch 1/8) --- .../data/remote/FirestoreCollections.kt | 2 + .../remote/FirestoreDateMemoryDataSource.kt | 74 +++++++++++ .../FirestoreDateReflectionDataSource.kt | 118 ++++++++++++++++++ .../app/closer/domain/model/DateMemory.kt | 18 +++ .../app/closer/domain/model/DateReflection.kt | 34 +++++ 5 files changed, 246 insertions(+) create mode 100644 app/src/main/java/app/closer/data/remote/FirestoreDateMemoryDataSource.kt create mode 100644 app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt create mode 100644 app/src/main/java/app/closer/domain/model/DateMemory.kt create mode 100644 app/src/main/java/app/closer/domain/model/DateReflection.kt diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index 3e13ff39..21aaf1e5 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -31,6 +31,8 @@ object FirestoreCollections { const val DATE_MATCHES = "date_matches" const val DATE_PLAN_PREFERENCES = "date_plan_preferences" const val DATE_PLANS = "date_plans" + const val DATE_HISTORY = "date_history" + const val DATE_REFLECTIONS = "date_reflections" const val BUCKET_LIST = "bucket_list" const val DAILY_QUESTION = "daily_question" const val CHALLENGES = "challenges" diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateMemoryDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateMemoryDataSource.kt new file mode 100644 index 00000000..12941762 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateMemoryDataSource.kt @@ -0,0 +1,74 @@ +package app.closer.data.remote + +import app.closer.domain.model.DateMemory +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.SetOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Completed-date log for the Date Replay timeline: `couples/{coupleId}/date_history/{id}`. + * PLAINTEXT (date-idea title/category + timestamp — coarse metadata, not private words; the private + * reflection lives E2EE in [FirestoreDateReflectionDataSource]). Doc id = source matchId → idempotent. + */ +@Singleton +class FirestoreDateMemoryDataSource @Inject constructor(private val db: FirebaseFirestore) { + + private fun historyRef(coupleId: String) = + db.collection(FirestoreCollections.COUPLES).document(coupleId) + .collection(FirestoreCollections.Couples.DATE_HISTORY) + + private fun toMemory(d: com.google.firebase.firestore.DocumentSnapshot) = DateMemory( + id = d.id, + dateIdeaId = d.getString("dateIdeaId") ?: "", + title = d.getString("title") ?: "", + category = d.getString("category") ?: "", + completedAt = d.getLong("completedAt") ?: 0L, + addedBy = d.getString("addedBy") ?: "" + ) + + /** Idempotent (merge on doc id = matchId — both partners marking the same date = one record). */ + suspend fun markCompleted(coupleId: String, memory: DateMemory): Unit = + suspendCancellableCoroutine { cont -> + historyRef(coupleId).document(memory.id).set( + mapOf( + "dateIdeaId" to memory.dateIdeaId, + "title" to memory.title, + "category" to memory.category, + "completedAt" to memory.completedAt, + "addedBy" to memory.addedBy + ), + SetOptions.merge() + ).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) } + } + + fun observeHistory(coupleId: String): Flow> = callbackFlow { + val reg = historyRef(coupleId) + .orderBy("completedAt", Query.Direction.DESCENDING) + .addSnapshotListener { snap, err -> + if (err != null) { close(err); return@addSnapshotListener } + trySend(snap?.documents?.map(::toMemory) ?: emptyList()) + } + awaitClose { reg.remove() } + } + + suspend fun getHistoryOnce(coupleId: String): List = + suspendCancellableCoroutine { cont -> + historyRef(coupleId).orderBy("completedAt", Query.Direction.DESCENDING).get() + .addOnSuccessListener { snap -> cont.resume(snap.documents.map(::toMemory)) } + .addOnFailureListener { cont.resumeWithException(it) } + } + + suspend fun delete(coupleId: String, id: String): Unit = + suspendCancellableCoroutine { cont -> + historyRef(coupleId).document(id).delete() + .addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) } + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt new file mode 100644 index 00000000..85ee8208 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt @@ -0,0 +1,118 @@ +package app.closer.data.remote + +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor +import app.closer.domain.model.DateReflection +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Post-date reflections — mirrors the daily-question couple-key reveal: + * `couples/{coupleId}/date_reflections/{dateId}/answers/{userId}` (+ read-gated `secure/payload`). + * "Neither partner reads the other until both reflect" is enforced by the Firestore rule, not client code. + */ +@Singleton +class FirestoreDateReflectionDataSource @Inject constructor( + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor +) { + private fun answerRef(coupleId: String, dateId: String, userId: String) = + db.collection(FirestoreCollections.COUPLES).document(coupleId) + .collection(FirestoreCollections.Couples.DATE_REFLECTIONS).document(dateId) + .collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId) + + private fun securePayloadRef(coupleId: String, dateId: String, userId: String) = + answerRef(coupleId, dateId, userId).collection("secure").document("payload") + + /** Save my reflection: encrypted content in the gated secure subdoc + a content-free metadata doc. */ + suspend fun saveReflection(coupleId: String, dateId: String, userId: String, reflection: DateReflection) { + val aead = encryptionManager.aeadFor(coupleId) + ?: throw IllegalStateException("Couple key unavailable for $coupleId") + val payload = fieldEncryptor.encrypt(encode(reflection), aead, coupleId) + val now = System.currentTimeMillis() + suspendCancellableCoroutine { cont -> + securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload)) + .addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) } + } + suspendCancellableCoroutine { cont -> + answerRef(coupleId, dateId, userId).set( + mapOf( + "userId" to userId, + "schemaVersion" to DateReflection.SCHEMA_VERSION, + "createdAt" to now, + "updatedAt" to now, + "isRevealed" to false + ) + ).addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) } + } + } + + /** True once a user has reflected (metadata doc readable by both → drives the "your turn" state). */ + suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean = + suspendCancellableCoroutine { cont -> + answerRef(coupleId, dateId, userId).get() + .addOnSuccessListener { cont.resume(it.exists()) } + .addOnFailureListener { cont.resume(false) } + } + + /** Live "has this user reflected?" — lets the reflection screen complete the reveal in real time. */ + fun observeReflected(coupleId: String, dateId: String, userId: String): Flow = callbackFlow { + val reg = answerRef(coupleId, dateId, userId).addSnapshotListener { snap, err -> + if (err != null) return@addSnapshotListener + trySend(snap?.exists() == true) + } + awaitClose { reg.remove() } + } + + /** + * Read + decrypt a user's reflection content from the gated `secure` subdoc. Returns null when the + * read is denied (partner hasn't reflected yet → privacy gate) or the key is unavailable here. + */ + suspend fun decryptReflectionFor(coupleId: String, dateId: String, userId: String): DateReflection? { + val payload = runCatching { + suspendCancellableCoroutine { cont -> + securePayloadRef(coupleId, dateId, userId).get() + .addOnSuccessListener { cont.resume(it.getString("encryptedPayload")) } + .addOnFailureListener { cont.resumeWithException(it) } + } + }.getOrNull() ?: return null + val aead = encryptionManager.aeadFor(coupleId) ?: return null + val json = fieldEncryptor.decrypt(payload, aead, coupleId) ?: return null + return runCatching { + val o = JSONObject(json) + DateReflection( + dateId = dateId, + userId = userId, + favoriteMoment = o.optString("favoriteMoment"), + surprise = o.optString("surprise"), + appreciated = o.optString("appreciated"), + isRevealed = true, + schemaVersion = o.optInt("schemaVersion", DateReflection.SCHEMA_VERSION) + ) + }.getOrNull() + } + + /** Flag my reflection revealed — drives the optional "[partner] opened your reflection" push. */ + suspend fun markRevealed(coupleId: String, dateId: String, userId: String): Unit = + suspendCancellableCoroutine { cont -> + answerRef(coupleId, dateId, userId).update(mapOf("isRevealed" to true)) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resume(Unit) } // best-effort + } + + private fun encode(r: DateReflection): String = JSONObject().apply { + put("favoriteMoment", r.favoriteMoment) + put("surprise", r.surprise) + put("appreciated", r.appreciated) + put("schemaVersion", DateReflection.SCHEMA_VERSION) + }.toString() +} diff --git a/app/src/main/java/app/closer/domain/model/DateMemory.kt b/app/src/main/java/app/closer/domain/model/DateMemory.kt new file mode 100644 index 00000000..6d39cadc --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DateMemory.kt @@ -0,0 +1,18 @@ +package app.closer.domain.model + +/** + * A completed date the couple logged, for the Date Replay timeline. + * + * Stored at `couples/{coupleId}/date_history/{id}` as PLAINTEXT (app content / coarse metadata — the + * date-idea title + category, not the partners' private words; consistent with the metadata stance). + * The private post-date words live separately in [DateReflection] (E2EE). Doc id = the source matchId so + * "mark done" is idempotent. + */ +data class DateMemory( + val id: String = "", + val dateIdeaId: String = "", + val title: String = "", + val category: String = "", + val completedAt: Long = 0L, + val addedBy: String = "" +) diff --git a/app/src/main/java/app/closer/domain/model/DateReflection.kt b/app/src/main/java/app/closer/domain/model/DateReflection.kt new file mode 100644 index 00000000..9df63a1d --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DateReflection.kt @@ -0,0 +1,34 @@ +package app.closer.domain.model + +/** + * A partner's private post-date reflection (3 prompts). The content is couple-key encrypted and lives in + * a read-gated `secure/payload` subdoc — neither partner can read the other's until BOTH have reflected + * (enforced by the Firestore rule, mirroring the daily-question reveal). Privacy-native by design. + */ +data class DateReflection( + val dateId: String = "", + val userId: String = "", + val favoriteMoment: String = "", + val surprise: String = "", + val appreciated: String = "", + val isRevealed: Boolean = false, + val createdAt: Long = 0L, + val schemaVersion: Int = SCHEMA_VERSION +) { + val isEmpty: Boolean + get() = favoriteMoment.isBlank() && surprise.isBlank() && appreciated.isBlank() + + companion object { + const val SCHEMA_VERSION = 1 + } +} + +/** Per-user reflection progress for a completed date, derived at read time (not stored). */ +enum class DateReflectionState { + /** Neither this user has reflected. */ + NONE, + /** This user reflected; waiting on the partner. */ + AWAITING_PARTNER, + /** Both reflected — ready to reveal / revealed. */ + BOTH_DONE +}