Revert "feat(date-memories): add DateMemory/DateReflection domain models and Firestore data sources (batch 1/8)"
This reverts commit 18ffdcdbaf.
This commit is contained in:
parent
6a0849deb7
commit
28eb10f6c9
|
|
@ -31,8 +31,6 @@ object FirestoreCollections {
|
||||||
const val DATE_MATCHES = "date_matches"
|
const val DATE_MATCHES = "date_matches"
|
||||||
const val DATE_PLAN_PREFERENCES = "date_plan_preferences"
|
const val DATE_PLAN_PREFERENCES = "date_plan_preferences"
|
||||||
const val DATE_PLANS = "date_plans"
|
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 BUCKET_LIST = "bucket_list"
|
||||||
const val DAILY_QUESTION = "daily_question"
|
const val DAILY_QUESTION = "daily_question"
|
||||||
const val CHALLENGES = "challenges"
|
const val CHALLENGES = "challenges"
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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<List<DateMemory>> = 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<DateMemory> =
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
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<Unit> { cont ->
|
|
||||||
securePayloadRef(coupleId, dateId, userId).set(mapOf("encryptedPayload" to payload))
|
|
||||||
.addOnSuccessListener { cont.resume(Unit) }.addOnFailureListener { cont.resumeWithException(it) }
|
|
||||||
}
|
|
||||||
suspendCancellableCoroutine<Unit> { 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<Boolean> = 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<String?> { 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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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 = ""
|
|
||||||
)
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue