feat: couple-scoped daily question, answer sync, partner notifications, and answer review
This commit is contained in:
parent
92a257b3eb
commit
eaac8ffcc9
|
|
@ -0,0 +1,156 @@
|
||||||
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.domain.model.LocalAnswer
|
||||||
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs daily-question answers to Firestore under the couple's daily question doc.
|
||||||
|
*
|
||||||
|
* Path: couples/{coupleId}/daily_question/{date}/answers/{userId}
|
||||||
|
*
|
||||||
|
* Only the authenticated user may create/update their own answer document;
|
||||||
|
* both partners may read either answer. Firestore rules enforce this.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
||||||
|
|
||||||
|
private fun dailyQuestionRef(coupleId: String, date: String) =
|
||||||
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
.document(coupleId)
|
||||||
|
.collection(FirestoreCollections.Couples.DAILY_QUESTION)
|
||||||
|
.document(date)
|
||||||
|
|
||||||
|
private fun answerRef(coupleId: String, date: String, userId: String) =
|
||||||
|
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the user's answer to Firestore. Creates or overwrites the answer
|
||||||
|
* document at `answers/{userId}`.
|
||||||
|
*/
|
||||||
|
suspend fun saveAnswer(
|
||||||
|
coupleId: String,
|
||||||
|
questionId: String,
|
||||||
|
userId: String,
|
||||||
|
answer: LocalAnswer
|
||||||
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
|
val date = todayCstString()
|
||||||
|
val data = mapOf(
|
||||||
|
"userId" to userId,
|
||||||
|
"questionId" to questionId,
|
||||||
|
"answerType" to answer.answerType,
|
||||||
|
"writtenText" to answer.writtenText,
|
||||||
|
"selectedOptionIds" to answer.selectedOptionIds,
|
||||||
|
"scaleValue" to answer.scaleValue,
|
||||||
|
"createdAt" to answer.createdAt,
|
||||||
|
"updatedAt" to answer.updatedAt,
|
||||||
|
"isRevealed" to answer.isRevealed
|
||||||
|
)
|
||||||
|
|
||||||
|
answerRef(coupleId, date, userId)
|
||||||
|
.set(data)
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a partner's answer for the current CST date.
|
||||||
|
*/
|
||||||
|
suspend fun getAnswerForUser(
|
||||||
|
coupleId: String,
|
||||||
|
userId: String,
|
||||||
|
date: String = todayCstString()
|
||||||
|
): LocalAnswer? = suspendCancellableCoroutine { cont ->
|
||||||
|
answerRef(coupleId, date, userId)
|
||||||
|
.get()
|
||||||
|
.addOnSuccessListener { snap ->
|
||||||
|
if (!snap.exists()) {
|
||||||
|
cont.resume(null)
|
||||||
|
return@addOnSuccessListener
|
||||||
|
}
|
||||||
|
cont.resume(snap.toLocalAnswer())
|
||||||
|
}
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the couple-scoped daily question assignment for today.
|
||||||
|
*/
|
||||||
|
suspend fun getDailyQuestionAssignment(coupleId: String, date: String = todayCstString()): DailyQuestionAssignment? =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
dailyQuestionRef(coupleId, date)
|
||||||
|
.get()
|
||||||
|
.addOnSuccessListener { snap ->
|
||||||
|
if (!snap.exists()) {
|
||||||
|
cont.resume(null)
|
||||||
|
return@addOnSuccessListener
|
||||||
|
}
|
||||||
|
cont.resume(
|
||||||
|
DailyQuestionAssignment(
|
||||||
|
questionId = snap.getString("questionId") ?: "",
|
||||||
|
date = snap.getString("date") ?: date,
|
||||||
|
assignedAt = snap.getTimestamp("assignedAt"),
|
||||||
|
expiresAt = snap.getTimestamp("expiresAt")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the cloud function to assign a daily question for the couple immediately.
|
||||||
|
* Used when a couple is newly created and has no assignment yet.
|
||||||
|
*/
|
||||||
|
suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayCstString()): Unit =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val functions = com.google.firebase.functions.FirebaseFunctions.getInstance()
|
||||||
|
functions
|
||||||
|
.getHttpsCallable("assignDailyQuestionCallable")
|
||||||
|
.call(mapOf("coupleId" to coupleId, "date" to date))
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
|
||||||
|
val ids = get("selectedOptionIds") as? List<String> ?: emptyList()
|
||||||
|
return LocalAnswer(
|
||||||
|
questionId = getString("questionId") ?: "",
|
||||||
|
questionText = "",
|
||||||
|
category = "",
|
||||||
|
answerType = getString("answerType") ?: "written",
|
||||||
|
writtenText = getString("writtenText"),
|
||||||
|
selectedOptionIds = ids,
|
||||||
|
selectedOptionTexts = emptyList(),
|
||||||
|
scaleValue = if (getLong("scaleValue") == null && get("scaleValue") == null) null else (getLong("scaleValue")?.toInt() ?: get("scaleValue") as? Int),
|
||||||
|
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
||||||
|
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
||||||
|
isRevealed = getBoolean("isRevealed") ?: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DailyQuestionAssignment(
|
||||||
|
val questionId: String,
|
||||||
|
val date: String,
|
||||||
|
val assignedAt: com.google.firebase.Timestamp?,
|
||||||
|
val expiresAt: com.google.firebase.Timestamp?
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CST_ID = "America/Chicago"
|
||||||
|
|
||||||
|
fun todayCstString(): String {
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
fmt.timeZone = TimeZone.getTimeZone(CST_ID)
|
||||||
|
return fmt.format(Calendar.getInstance(TimeZone.getTimeZone(CST_ID)).time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,12 @@ object FirestoreCollections {
|
||||||
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 BUCKET_LIST = "bucket_list"
|
const val BUCKET_LIST = "bucket_list"
|
||||||
|
const val DAILY_QUESTION = "daily_question"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────
|
||||||
|
object DailyQuestion {
|
||||||
|
const val ANSWERS = "answers"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subcollections under …/question_threads/{threadId} ────────────────────
|
// ── Subcollections under …/question_threads/{threadId} ────────────────────
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,14 @@ private fun AnswerRevealContent(
|
||||||
)
|
)
|
||||||
state.answer.isRevealed -> RevealedState(
|
state.answer.isRevealed -> RevealedState(
|
||||||
answer = state.answer,
|
answer = state.answer,
|
||||||
|
partnerAnswer = state.partnerAnswer,
|
||||||
question = state.question,
|
question = state.question,
|
||||||
onHistory = onHistory,
|
onHistory = onHistory,
|
||||||
onHome = onHome
|
onHome = onHome
|
||||||
)
|
)
|
||||||
else -> ReadyToRevealState(
|
else -> ReadyToRevealState(
|
||||||
answer = state.answer,
|
answer = state.answer,
|
||||||
|
partnerAnswer = state.partnerAnswer,
|
||||||
question = state.question,
|
question = state.question,
|
||||||
onReveal = onReveal,
|
onReveal = onReveal,
|
||||||
onHistory = onHistory
|
onHistory = onHistory
|
||||||
|
|
@ -192,6 +194,7 @@ private fun NoAnswerState(
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReadyToRevealState(
|
private fun ReadyToRevealState(
|
||||||
answer: LocalAnswer,
|
answer: LocalAnswer,
|
||||||
|
partnerAnswer: LocalAnswer?,
|
||||||
question: Question?,
|
question: Question?,
|
||||||
onReveal: () -> Unit,
|
onReveal: () -> Unit,
|
||||||
onHistory: () -> Unit
|
onHistory: () -> Unit
|
||||||
|
|
@ -208,6 +211,7 @@ private fun ReadyToRevealState(
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
AnswerPreview(answer = answer, revealed = false)
|
AnswerPreview(answer = answer, revealed = false)
|
||||||
|
PartnerAnswerSection(partnerAnswer = partnerAnswer, revealed = false)
|
||||||
Text(
|
Text(
|
||||||
text = "No rush. Reveal this only when you want the conversation to open.",
|
text = "No rush. Reveal this only when you want the conversation to open.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|
@ -246,6 +250,7 @@ private fun ReadyToRevealState(
|
||||||
@Composable
|
@Composable
|
||||||
private fun RevealedState(
|
private fun RevealedState(
|
||||||
answer: LocalAnswer,
|
answer: LocalAnswer,
|
||||||
|
partnerAnswer: LocalAnswer?,
|
||||||
question: Question?,
|
question: Question?,
|
||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
onHome: () -> Unit
|
onHome: () -> Unit
|
||||||
|
|
@ -266,6 +271,7 @@ private fun RevealedState(
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
AnswerPreview(answer = answer, revealed = true)
|
AnswerPreview(answer = answer, revealed = true)
|
||||||
|
PartnerAnswerSection(partnerAnswer = partnerAnswer, revealed = true)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onHistory,
|
onClick = onHistory,
|
||||||
|
|
@ -377,6 +383,71 @@ private fun RevealPill(label: String) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PartnerAnswerSection(partnerAnswer: LocalAnswer?, revealed: Boolean) {
|
||||||
|
when {
|
||||||
|
partnerAnswer == null -> WaitingForPartnerCard()
|
||||||
|
revealed -> PartnerAnswerPreview(answer = partnerAnswer)
|
||||||
|
else -> WaitingForPartnerCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PartnerAnswerPreview(answer: LocalAnswer) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
color = Color(0xFFE8F4FF)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Your partner answered",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF30566F),
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = answer.partnerRevealSummary(),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF33463E),
|
||||||
|
maxLines = 6,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaitingForPartnerCard() {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
color = Color.White.copy(alpha = 0.60f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Partner answer",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color(0xFF56306F),
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Waiting for your partner to answer...",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun LocalAnswer.revealSummary(): String {
|
fun LocalAnswer.revealSummary(): String {
|
||||||
return when (answerType) {
|
return when (answerType) {
|
||||||
"written" -> writtenText.orEmpty()
|
"written" -> writtenText.orEmpty()
|
||||||
|
|
@ -398,6 +469,17 @@ private fun LocalAnswer.privatePreview(): String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun LocalAnswer.partnerRevealSummary(): String {
|
||||||
|
return when (answerType) {
|
||||||
|
"written" -> writtenText.orEmpty()
|
||||||
|
"scale" -> "They chose ${scaleValue ?: "-"}."
|
||||||
|
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
||||||
|
.ifEmpty { selectedOptionIds }
|
||||||
|
.joinToString()
|
||||||
|
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AnswerRevealScreenPreview() {
|
fun AnswerRevealScreenPreview() {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package app.closer.ui.answers
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.core.crash.CrashReporter
|
||||||
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
|
@ -22,15 +24,19 @@ data class AnswerRevealUiState(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val question: Question? = null,
|
val question: Question? = null,
|
||||||
val answer: LocalAnswer? = null,
|
val answer: LocalAnswer? = null,
|
||||||
val coupleId: String? = null
|
val partnerAnswer: LocalAnswer? = null,
|
||||||
|
val coupleId: String? = null,
|
||||||
|
val partnerId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AnswerRevealViewModel @Inject constructor(
|
class AnswerRevealViewModel @Inject constructor(
|
||||||
private val questionRepository: QuestionRepository,
|
private val questionRepository: QuestionRepository,
|
||||||
private val localAnswerRepository: LocalAnswerRepository,
|
private val localAnswerRepository: LocalAnswerRepository,
|
||||||
|
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
|
private val crashReporter: CrashReporter,
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -48,16 +54,29 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = AnswerRevealUiState(isLoading = true)
|
_uiState.value = AnswerRevealUiState(isLoading = true)
|
||||||
try {
|
try {
|
||||||
val coupleId = authRepository.currentUserId?.let { uid ->
|
val (coupleId, partnerId) = resolveCoupleAndPartner()
|
||||||
runCatching { coupleRepository.getCoupleForUser(uid)?.id }.getOrNull()
|
val question = questionRepository.getQuestionById(questionId)
|
||||||
}
|
val answer = localAnswerRepository.getAnswer(questionId)
|
||||||
|
val partnerAnswer = if (coupleId != null && partnerId != null) {
|
||||||
|
runCatching {
|
||||||
|
firestoreAnswerDataSource.getAnswerForUser(
|
||||||
|
coupleId = coupleId,
|
||||||
|
userId = partnerId,
|
||||||
|
date = FirestoreAnswerDataSource.todayCstString()
|
||||||
|
)
|
||||||
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
|
} else null
|
||||||
|
|
||||||
_uiState.value = AnswerRevealUiState(
|
_uiState.value = AnswerRevealUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
question = questionRepository.getQuestionById(questionId),
|
question = question,
|
||||||
answer = localAnswerRepository.getAnswer(questionId),
|
answer = answer,
|
||||||
coupleId = coupleId
|
partnerAnswer = partnerAnswer,
|
||||||
|
coupleId = coupleId,
|
||||||
|
partnerId = partnerId
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
crashReporter.recordException(e)
|
||||||
_uiState.value = AnswerRevealUiState(
|
_uiState.value = AnswerRevealUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = e.message ?: "Could not load this reveal."
|
error = e.message ?: "Could not load this reveal."
|
||||||
|
|
@ -66,6 +85,19 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the current user's couple and partner IDs.
|
||||||
|
*/
|
||||||
|
private suspend fun resolveCoupleAndPartner(): Pair<String?, String?> {
|
||||||
|
val userId = authRepository.currentUserId ?: return null to null
|
||||||
|
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }
|
||||||
|
.onFailure { crashReporter.recordException(it) }
|
||||||
|
.getOrNull()
|
||||||
|
?: return null to null
|
||||||
|
val partnerId = couple.userIds.firstOrNull { it != userId }
|
||||||
|
return couple.id to partnerId
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeAnswer() {
|
private fun observeAnswer() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localAnswerRepository.observeAnswer(questionId).collect { answer ->
|
localAnswerRepository.observeAnswer(questionId).collect { answer ->
|
||||||
|
|
@ -74,6 +106,22 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshPartnerAnswer() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val coupleId = state.coupleId ?: return
|
||||||
|
val partnerId = state.partnerId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val partnerAnswer = runCatching {
|
||||||
|
firestoreAnswerDataSource.getAnswerForUser(
|
||||||
|
coupleId = coupleId,
|
||||||
|
userId = partnerId,
|
||||||
|
date = FirestoreAnswerDataSource.todayCstString()
|
||||||
|
)
|
||||||
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
|
partnerAnswer?.let { _uiState.update { it.copy(partnerAnswer = partnerAnswer) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun revealAnswer() {
|
fun revealAnswer() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localAnswerRepository.markRevealed(questionId)
|
localAnswerRepository.markRevealed(questionId)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package app.closer.ui.questions
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.core.crash.CrashReporter
|
||||||
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||||
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.repository.CoupleRepository
|
import app.closer.domain.repository.CoupleRepository
|
||||||
|
|
@ -30,8 +33,10 @@ data class LocalQuestionUiState(
|
||||||
class DailyQuestionViewModel @Inject constructor(
|
class DailyQuestionViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val localAnswerRepository: LocalAnswerRepository,
|
private val localAnswerRepository: LocalAnswerRepository,
|
||||||
|
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository
|
private val coupleRepository: CoupleRepository,
|
||||||
|
private val crashReporter: CrashReporter
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(LocalQuestionUiState())
|
private val _uiState = MutableStateFlow(LocalQuestionUiState())
|
||||||
|
|
@ -45,11 +50,8 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = LocalQuestionUiState(isLoading = true)
|
_uiState.value = LocalQuestionUiState(isLoading = true)
|
||||||
try {
|
try {
|
||||||
val question = repository.getDailyQuestion()
|
val (coupleId, question) = loadCoupleAndQuestion()
|
||||||
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
||||||
val coupleId = authRepository.currentUserId?.let { uid ->
|
|
||||||
runCatching { coupleRepository.getCoupleForUser(uid)?.id }.getOrNull()
|
|
||||||
}
|
|
||||||
_uiState.value = LocalQuestionUiState(
|
_uiState.value = LocalQuestionUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
question = question,
|
question = question,
|
||||||
|
|
@ -57,6 +59,7 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
pendingScaleValue = defaultScaleValue(question)
|
pendingScaleValue = defaultScaleValue(question)
|
||||||
).withLocalAnswer(answer)
|
).withLocalAnswer(answer)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
crashReporter.recordException(e)
|
||||||
_uiState.value = LocalQuestionUiState(
|
_uiState.value = LocalQuestionUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = e.message ?: "Could not load today's question."
|
error = e.message ?: "Could not load today's question."
|
||||||
|
|
@ -65,6 +68,50 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the current couple (if any) and the daily question.
|
||||||
|
*
|
||||||
|
* For paired users, read the couple's assigned daily question from Firestore
|
||||||
|
* so both partners see the same prompt. If no assignment exists yet, request
|
||||||
|
* one from the cloud function and fall back to a local random question while
|
||||||
|
* waiting.
|
||||||
|
*
|
||||||
|
* For unpaired users, fall back to the local random question pool.
|
||||||
|
*/
|
||||||
|
private suspend fun loadCoupleAndQuestion(): Pair<String?, Question?> {
|
||||||
|
val couple = authRepository.currentUserId?.let { uid ->
|
||||||
|
runCatching { coupleRepository.getCoupleForUser(uid) }
|
||||||
|
.onFailure { crashReporter.recordException(it) }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (couple == null) {
|
||||||
|
return null to repository.getDailyQuestion()
|
||||||
|
}
|
||||||
|
|
||||||
|
val coupleId = couple.id
|
||||||
|
val today = FirestoreAnswerDataSource.todayCstString()
|
||||||
|
val assignment = runCatching {
|
||||||
|
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
|
||||||
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
|
|
||||||
|
val question = if (assignment != null) {
|
||||||
|
repository.getQuestionById(assignment.questionId)
|
||||||
|
} else {
|
||||||
|
// No assignment yet. Request immediate assignment, but keep the app
|
||||||
|
// usable with a local random question in case the call fails.
|
||||||
|
runCatching {
|
||||||
|
firestoreAnswerDataSource.requestDailyQuestionAssignment(coupleId, today)
|
||||||
|
repository.getQuestionById(
|
||||||
|
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
|
||||||
|
)
|
||||||
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
|
?: repository.getDailyQuestion()
|
||||||
|
}
|
||||||
|
|
||||||
|
return coupleId to question
|
||||||
|
}
|
||||||
|
|
||||||
fun updateWrittenText(text: String) {
|
fun updateWrittenText(text: String) {
|
||||||
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
|
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
|
||||||
}
|
}
|
||||||
|
|
@ -94,8 +141,10 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
val question = state.question ?: return
|
val question = state.question ?: return
|
||||||
if (!canSubmit(state)) return
|
if (!canSubmit(state)) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localAnswerRepository.saveAnswer(state.toLocalAnswer(question))
|
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
|
||||||
|
localAnswerRepository.saveAnswer(localAnswer)
|
||||||
_uiState.update { it.copy(submitted = true) }
|
_uiState.update { it.copy(submitted = true) }
|
||||||
|
syncAnswerToFirestore(state.coupleId, question.id, localAnswer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +163,20 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the local answer to Firestore so the partner can see it. Failures are
|
||||||
|
* non-blocking: the user's answer is already persisted locally.
|
||||||
|
*/
|
||||||
|
private suspend fun syncAnswerToFirestore(coupleId: String?, questionId: String, answer: LocalAnswer) {
|
||||||
|
val userId = authRepository.currentUserId ?: return
|
||||||
|
if (coupleId.isNullOrBlank()) return
|
||||||
|
runCatching {
|
||||||
|
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer)
|
||||||
|
}.onFailure {
|
||||||
|
crashReporter.recordException(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun defaultScaleValue(question: Question?): Int {
|
fun defaultScaleValue(question: Question?): Int {
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,30 @@ service cloud.firestore {
|
||||||
&& (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid);
|
&& (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid);
|
||||||
allow delete: if isCouplesMember(coupleId);
|
allow delete: if isCouplesMember(coupleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Daily question: server-assigned once per day per couple.
|
||||||
|
// Writes are server-only (Cloud Functions / Admin SDK).
|
||||||
|
match /daily_question/{date} {
|
||||||
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily question answers: each user writes their own; both members read.
|
||||||
|
match /daily_question/{date}/answers/{userId} {
|
||||||
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
allow create: if isCouplesMember(coupleId)
|
||||||
|
&& request.auth.uid == userId
|
||||||
|
&& request.resource.data.keys().hasAll(['userId', 'questionId', 'answerType', 'createdAt', 'updatedAt'])
|
||||||
|
&& request.resource.data.userId == request.auth.uid
|
||||||
|
&& request.resource.data.questionId is string
|
||||||
|
&& request.resource.data.answerType is string;
|
||||||
|
allow update: if isCouplesMember(coupleId)
|
||||||
|
&& request.auth.uid == userId
|
||||||
|
&& request.resource.data.userId == resource.data.userId
|
||||||
|
&& request.resource.data.questionId == resource.data.questionId
|
||||||
|
&& request.resource.data.answerType == resource.data.answerType;
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── entitlement_events ────────────────────────────────────────────────────
|
// ── entitlement_events ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.PREMIUM_REVOKED_TYPES = exports.PREMIUM_ACTIVE_TYPES = void 0;
|
||||||
|
exports.isPremiumEntitlement = isPremiumEntitlement;
|
||||||
exports.applyEntitlementEvent = applyEntitlementEvent;
|
exports.applyEntitlementEvent = applyEntitlementEvent;
|
||||||
exports.applyEntitlementSync = applyEntitlementSync;
|
exports.applyEntitlementSync = applyEntitlementSync;
|
||||||
const admin = __importStar(require("firebase-admin"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
// Events that should grant or keep premium access active.
|
// Events that should grant or keep premium access active.
|
||||||
const PREMIUM_ACTIVE_TYPES = new Set([
|
exports.PREMIUM_ACTIVE_TYPES = new Set([
|
||||||
'INITIAL_PURCHASE',
|
'INITIAL_PURCHASE',
|
||||||
'RENEWAL',
|
'RENEWAL',
|
||||||
'PRODUCT_CHANGE',
|
'PRODUCT_CHANGE',
|
||||||
|
|
@ -45,7 +47,7 @@ const PREMIUM_ACTIVE_TYPES = new Set([
|
||||||
'UNCANCELLATION',
|
'UNCANCELLATION',
|
||||||
]);
|
]);
|
||||||
// Events that remove premium access.
|
// Events that remove premium access.
|
||||||
const PREMIUM_REVOKED_TYPES = new Set([
|
exports.PREMIUM_REVOKED_TYPES = new Set([
|
||||||
'EXPIRATION',
|
'EXPIRATION',
|
||||||
'CANCELLATION',
|
'CANCELLATION',
|
||||||
'BILLING_ISSUE',
|
'BILLING_ISSUE',
|
||||||
|
|
@ -96,7 +98,7 @@ async function applyEntitlementEvent(event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ref = entitlementsRef(userId);
|
const ref = entitlementsRef(userId);
|
||||||
if (PREMIUM_ACTIVE_TYPES.has(type)) {
|
if (exports.PREMIUM_ACTIVE_TYPES.has(type)) {
|
||||||
const expiresAt = event.expiration_at_ms
|
const expiresAt = event.expiration_at_ms
|
||||||
? admin.firestore.Timestamp.fromMillis(event.expiration_at_ms)
|
? admin.firestore.Timestamp.fromMillis(event.expiration_at_ms)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -110,7 +112,7 @@ async function applyEntitlementEvent(event) {
|
||||||
console.log(`[entitlement] premium=true for ${userId} (${type})`);
|
console.log(`[entitlement] premium=true for ${userId} (${type})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (PREMIUM_REVOKED_TYPES.has(type)) {
|
if (exports.PREMIUM_REVOKED_TYPES.has(type)) {
|
||||||
await ref.set({
|
await ref.set({
|
||||||
premium: false,
|
premium: false,
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"entitlementLogic.js","sourceRoot":"","sources":["../../src/billing/entitlementLogic.ts"],"names":[],"mappings":"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"}
|
{"version":3,"file":"entitlementLogic.js","sourceRoot":"","sources":["../../src/billing/entitlementLogic.ts"],"names":[],"mappings":"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"}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
// Mock firebase-admin before any module under test is imported.
|
||||||
|
// `admin.firestore` must work as both a callable (admin.firestore()) and a
|
||||||
|
// namespace (admin.firestore.Timestamp). Object.assign achieves both.
|
||||||
|
jest.mock('firebase-admin', () => {
|
||||||
|
const firestoreFn = jest.fn();
|
||||||
|
firestoreFn.Timestamp = {
|
||||||
|
now: jest.fn(() => ({ toMillis: () => 1000000000000 })),
|
||||||
|
fromMillis: jest.fn((ms) => ({ toMillis: () => ms })),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
firestore: firestoreFn,
|
||||||
|
apps: [],
|
||||||
|
initializeApp: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
const entitlementLogic_1 = require("./entitlementLogic");
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function event(overrides = {}) {
|
||||||
|
return Object.assign({ id: 'evt-001', type: 'INITIAL_PURCHASE', app_user_id: 'user-123', product_id: 'closer_premium_monthly', entitlement_id: 'closer_premium' }, overrides);
|
||||||
|
}
|
||||||
|
function buildFirestoreMock(opts = {}) {
|
||||||
|
const mockSet = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const mockCreate = jest.fn().mockImplementation(() => opts.createShouldFail
|
||||||
|
? Promise.reject(Object.assign(new Error('ALREADY_EXISTS'), { code: 6 }))
|
||||||
|
: Promise.resolve(undefined));
|
||||||
|
// Leaf doc ref (e.g. users/{uid}/entitlements/premium)
|
||||||
|
const deepDocRef = { set: mockSet };
|
||||||
|
const deepDocFn = jest.fn().mockReturnValue(deepDocRef);
|
||||||
|
const subCollectionRef = { doc: deepDocFn };
|
||||||
|
const subCollectionFn = jest.fn().mockReturnValue(subCollectionRef);
|
||||||
|
// Top-level doc ref: supports create (idempotency marker) and subcollection access
|
||||||
|
const topDocRef = { create: mockCreate, set: mockSet, collection: subCollectionFn };
|
||||||
|
const mockDoc = jest.fn().mockReturnValue(topDocRef);
|
||||||
|
const mockCollection = jest.fn().mockReturnValue({ doc: mockDoc });
|
||||||
|
const mockInstance = { collection: mockCollection };
|
||||||
|
admin.firestore.mockReturnValue(mockInstance);
|
||||||
|
return { mockSet, mockCreate };
|
||||||
|
}
|
||||||
|
// ── isPremiumEntitlement ──────────────────────────────────────────────────────
|
||||||
|
describe('isPremiumEntitlement', () => {
|
||||||
|
it('returns true when entitlement_id matches', () => {
|
||||||
|
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: 'closer_premium' }))).toBe(true);
|
||||||
|
});
|
||||||
|
it('returns true when entitlement_ids array includes the id', () => {
|
||||||
|
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: undefined, entitlement_ids: ['closer_premium', 'other'] }))).toBe(true);
|
||||||
|
});
|
||||||
|
it('returns false for a different entitlement_id', () => {
|
||||||
|
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: 'other_entitlement', entitlement_ids: [] }))).toBe(false);
|
||||||
|
});
|
||||||
|
it('returns false when neither id nor ids reference the premium entitlement', () => {
|
||||||
|
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: undefined, entitlement_ids: ['not_premium'] }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// ── Event type sets ───────────────────────────────────────────────────────────
|
||||||
|
describe('PREMIUM_ACTIVE_TYPES', () => {
|
||||||
|
it.each(['INITIAL_PURCHASE', 'RENEWAL', 'PRODUCT_CHANGE', 'TRANSFER', 'UNCANCELLATION'])('contains %s', (type) => expect(entitlementLogic_1.PREMIUM_ACTIVE_TYPES.has(type)).toBe(true));
|
||||||
|
it.each(['EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE'])('does not contain revocation event %s', (type) => expect(entitlementLogic_1.PREMIUM_ACTIVE_TYPES.has(type)).toBe(false));
|
||||||
|
});
|
||||||
|
describe('PREMIUM_REVOKED_TYPES', () => {
|
||||||
|
it.each(['EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE', 'SUBSCRIBER_ALIAS'])('contains %s', (type) => expect(entitlementLogic_1.PREMIUM_REVOKED_TYPES.has(type)).toBe(true));
|
||||||
|
it.each(['INITIAL_PURCHASE', 'RENEWAL'])('does not contain active event %s', (type) => expect(entitlementLogic_1.PREMIUM_REVOKED_TYPES.has(type)).toBe(false));
|
||||||
|
});
|
||||||
|
// ── applyEntitlementEvent ─────────────────────────────────────────────────────
|
||||||
|
describe('applyEntitlementEvent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
it('grants premium on INITIAL_PURCHASE', async () => {
|
||||||
|
const { mockSet } = buildFirestoreMock();
|
||||||
|
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'INITIAL_PURCHASE' }));
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: true }));
|
||||||
|
});
|
||||||
|
it('revokes premium on EXPIRATION', async () => {
|
||||||
|
const { mockSet } = buildFirestoreMock();
|
||||||
|
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'EXPIRATION' }));
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: false, expiresAt: null }));
|
||||||
|
});
|
||||||
|
it('revokes premium on CANCELLATION', async () => {
|
||||||
|
const { mockSet } = buildFirestoreMock();
|
||||||
|
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'CANCELLATION' }));
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: false }));
|
||||||
|
});
|
||||||
|
it('is idempotent — skips duplicate event ids', async () => {
|
||||||
|
const { mockSet } = buildFirestoreMock({ createShouldFail: true });
|
||||||
|
await (0, entitlementLogic_1.applyEntitlementEvent)(event());
|
||||||
|
// The idempotency guard fires (create threw ALREADY_EXISTS), so set is never called.
|
||||||
|
expect(mockSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('stores expiresAt when expiration_at_ms is present', async () => {
|
||||||
|
const { mockSet } = buildFirestoreMock();
|
||||||
|
const expiresAtMs = Date.now() + 86400000;
|
||||||
|
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'RENEWAL', expiration_at_ms: expiresAtMs }));
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: true }));
|
||||||
|
});
|
||||||
|
it('ignores non-premium entitlement events', async () => {
|
||||||
|
const { mockSet } = buildFirestoreMock();
|
||||||
|
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ entitlement_id: 'some_other_entitlement', entitlement_ids: [] }));
|
||||||
|
expect(mockSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=entitlementLogic.test.js.map
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.health = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
|
exports.health = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
|
||||||
const functions = __importStar(require("firebase-functions"));
|
const functions = __importStar(require("firebase-functions"));
|
||||||
const admin = __importStar(require("firebase-admin"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
// Initialize the Admin SDK once for every function in this codebase.
|
// Initialize the Admin SDK once for every function in this codebase.
|
||||||
|
|
@ -53,6 +53,11 @@ var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity");
|
||||||
Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } });
|
Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } });
|
||||||
var createDateMatch_1 = require("./dates/createDateMatch");
|
var createDateMatch_1 = require("./dates/createDateMatch");
|
||||||
Object.defineProperty(exports, "createDateMatchOnMutualLove", { enumerable: true, get: function () { return createDateMatch_1.createDateMatchOnMutualLove; } });
|
Object.defineProperty(exports, "createDateMatchOnMutualLove", { enumerable: true, get: function () { return createDateMatch_1.createDateMatchOnMutualLove; } });
|
||||||
|
var assignDailyQuestion_1 = require("./questions/assignDailyQuestion");
|
||||||
|
Object.defineProperty(exports, "assignDailyQuestion", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestion; } });
|
||||||
|
Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } });
|
||||||
|
var onAnswerWritten_1 = require("./questions/onAnswerWritten");
|
||||||
|
Object.defineProperty(exports, "onAnswerWritten", { enumerable: true, get: function () { return onAnswerWritten_1.onAnswerWritten; } });
|
||||||
/**
|
/**
|
||||||
* Basic health check callable.
|
* Basic health check callable.
|
||||||
* Useful for verifying function deployment and firebase-tools wiring.
|
* Useful for verifying function deployment and firebase-tools wiring.
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AAEpC;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"}
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AAExB;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
|
||||||
|
const functions = __importStar(require("firebase-functions"));
|
||||||
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
const CST_OFFSET_HOURS = -6;
|
||||||
|
/**
|
||||||
|
* Scheduled function that assigns one daily question per couple every day.
|
||||||
|
*
|
||||||
|
* Schedule: 6:00 PM CST (America/Chicago) == 23:00 UTC.
|
||||||
|
* Document path: couples/{coupleId}/daily_question/{date}
|
||||||
|
* - questionId: string
|
||||||
|
* - date: string (YYYY-MM-DD)
|
||||||
|
* - assignedAt: Timestamp
|
||||||
|
* - expiresAt: Timestamp (next day at 6 PM CST)
|
||||||
|
*
|
||||||
|
* Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write
|
||||||
|
* only (`allow write: if false` in rules).
|
||||||
|
*/
|
||||||
|
exports.assignDailyQuestion = functions.pubsub
|
||||||
|
.schedule('0 23 * * *')
|
||||||
|
.timeZone('America/Chicago')
|
||||||
|
.onRun(async () => {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const today = cstDateString();
|
||||||
|
const nextDay = nextCstDateString();
|
||||||
|
const questionId = await pickRandomQuestionId();
|
||||||
|
if (!questionId) {
|
||||||
|
console.error('[assignDailyQuestion] no active questions available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const couplesSnap = await db.collection('couples').get();
|
||||||
|
const assignedAt = admin.firestore.FieldValue.serverTimestamp();
|
||||||
|
const expiresAt = timestampAt6PmCst(nextDay);
|
||||||
|
const writes = couplesSnap.docs.map(async (coupleDoc) => {
|
||||||
|
var _a;
|
||||||
|
const coupleId = coupleDoc.id;
|
||||||
|
const docRef = db
|
||||||
|
.collection('couples')
|
||||||
|
.doc(coupleId)
|
||||||
|
.collection('daily_question')
|
||||||
|
.doc(today);
|
||||||
|
try {
|
||||||
|
await docRef.create({
|
||||||
|
questionId,
|
||||||
|
date: today,
|
||||||
|
assignedAt,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_a = err === null || err === void 0 ? void 0 : err.message) === null || _a === void 0 ? void 0 : _a.includes('ALREADY_EXISTS'))) {
|
||||||
|
// Already assigned for today — idempotent.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`[assignDailyQuestion] failed for ${coupleId}:`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(writes);
|
||||||
|
console.log(`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`);
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Callable function for immediate daily question assignment.
|
||||||
|
*
|
||||||
|
* Useful when a couple is newly created and should not wait for the next
|
||||||
|
* scheduled run, or for manual admin/testing triggers.
|
||||||
|
*
|
||||||
|
* Body: { coupleId: string, date?: string }
|
||||||
|
*/
|
||||||
|
exports.assignDailyQuestionCallable = functions.https.onCall(async (data, context) => {
|
||||||
|
var _a, _b, _c, _d;
|
||||||
|
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
|
||||||
|
if (!callerId) {
|
||||||
|
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.');
|
||||||
|
}
|
||||||
|
const coupleId = data === null || data === void 0 ? void 0 : data.coupleId;
|
||||||
|
if (!coupleId || typeof coupleId !== 'string') {
|
||||||
|
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.');
|
||||||
|
}
|
||||||
|
const db = admin.firestore();
|
||||||
|
const coupleDoc = await db.collection('couples').doc(coupleId).get();
|
||||||
|
if (!coupleDoc.exists) {
|
||||||
|
throw new functions.https.HttpsError('not-found', 'Couple not found.');
|
||||||
|
}
|
||||||
|
// Caller must be a member of the couple.
|
||||||
|
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
|
||||||
|
if (!userIds.includes(callerId)) {
|
||||||
|
throw new functions.https.HttpsError('permission-denied', 'Caller is not a couple member.');
|
||||||
|
}
|
||||||
|
const date = (data === null || data === void 0 ? void 0 : data.date) && typeof data.date === 'string' ? data.date : cstDateString();
|
||||||
|
const questionId = await pickRandomQuestionId();
|
||||||
|
if (!questionId) {
|
||||||
|
throw new functions.https.HttpsError('internal', 'No active questions available.');
|
||||||
|
}
|
||||||
|
const nextDay = nextCstDateString(date);
|
||||||
|
const docRef = db
|
||||||
|
.collection('couples')
|
||||||
|
.doc(coupleId)
|
||||||
|
.collection('daily_question')
|
||||||
|
.doc(date);
|
||||||
|
try {
|
||||||
|
await docRef.create({
|
||||||
|
questionId,
|
||||||
|
date,
|
||||||
|
assignedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
expiresAt: timestampAt6PmCst(nextDay),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_d = err === null || err === void 0 ? void 0 : err.message) === null || _d === void 0 ? void 0 : _d.includes('ALREADY_EXISTS'))) {
|
||||||
|
throw new functions.https.HttpsError('already-exists', `Daily question already assigned for ${date}.`);
|
||||||
|
}
|
||||||
|
throw new functions.https.HttpsError('internal', 'Failed to assign daily question.');
|
||||||
|
}
|
||||||
|
return { success: true, coupleId, date, questionId };
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Picks a random active free question from the Firestore `questions` pool.
|
||||||
|
*
|
||||||
|
* The pool is expected to be a top-level collection with documents:
|
||||||
|
* - id: string
|
||||||
|
* - active: boolean
|
||||||
|
* - isPremium: boolean
|
||||||
|
*
|
||||||
|
* If no Firestore pool exists yet, falls back to a deterministic placeholder
|
||||||
|
* so the app still functions during rollout. In production, keep the local
|
||||||
|
* Room database and Firestore pool in sync through the existing seed flow.
|
||||||
|
*/
|
||||||
|
async function pickRandomQuestionId() {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const snapshot = await db
|
||||||
|
.collection('questions')
|
||||||
|
.where('active', '==', true)
|
||||||
|
.where('isPremium', '==', false)
|
||||||
|
.get();
|
||||||
|
if (snapshot.empty) {
|
||||||
|
// Rollout fallback: keep the feature working until the questions pool is seeded.
|
||||||
|
return 'q_default_daily';
|
||||||
|
}
|
||||||
|
const docs = snapshot.docs;
|
||||||
|
const chosen = docs[Math.floor(Math.random() * docs.length)];
|
||||||
|
return chosen.id;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Returns today's date as YYYY-MM-DD in America/Chicago time.
|
||||||
|
*/
|
||||||
|
function cstDateString() {
|
||||||
|
return formatCstDate(new Date());
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Returns tomorrow's date as YYYY-MM-DD relative to America/Chicago time.
|
||||||
|
*/
|
||||||
|
function nextCstDateString(from) {
|
||||||
|
if (from) {
|
||||||
|
const d = parseCstDate(from);
|
||||||
|
d.setUTCDate(d.getUTCDate() + 1);
|
||||||
|
return formatCstDate(d);
|
||||||
|
}
|
||||||
|
const d = new Date();
|
||||||
|
const cst = applyCstOffset(d);
|
||||||
|
cst.setUTCDate(cst.getUTCDate() + 1);
|
||||||
|
return formatCstDate(cst);
|
||||||
|
}
|
||||||
|
function applyCstOffset(d) {
|
||||||
|
const utcMs = d.getTime();
|
||||||
|
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000;
|
||||||
|
return new Date(cstMs);
|
||||||
|
}
|
||||||
|
function formatCstDate(d) {
|
||||||
|
const cst = applyCstOffset(d);
|
||||||
|
const y = cst.getUTCFullYear();
|
||||||
|
const m = String(cst.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(cst.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
function parseCstDate(dateStr) {
|
||||||
|
const [y, m, day] = dateStr.split('-').map(Number);
|
||||||
|
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
|
||||||
|
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0);
|
||||||
|
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
function timestampAt6PmCst(dateStr) {
|
||||||
|
const d = parseCstDate(dateStr);
|
||||||
|
const utcMs = d.getTime();
|
||||||
|
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
|
||||||
|
// We already have midnight CST in UTC form; add 18 hours.
|
||||||
|
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000;
|
||||||
|
return admin.firestore.Timestamp.fromMillis(at6pmCstMs);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=assignDailyQuestion.js.map
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,117 @@
|
||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.onAnswerWritten = void 0;
|
||||||
|
const functions = __importStar(require("firebase-functions"));
|
||||||
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
/**
|
||||||
|
* Firestore trigger that sends an FCM notification to the other partner when
|
||||||
|
* one partner writes an answer under
|
||||||
|
* couples/{coupleId}/daily_question/{date}/answers/{userId}.
|
||||||
|
*
|
||||||
|
* The notification payload is a data message so the client can route directly to
|
||||||
|
* the answer reveal screen. It also contains a `notification` block for
|
||||||
|
* system-tray display when the app is in the background.
|
||||||
|
*/
|
||||||
|
exports.onAnswerWritten = functions.firestore
|
||||||
|
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}')
|
||||||
|
.onCreate(async (snap, context) => {
|
||||||
|
var _a, _b, _c;
|
||||||
|
const { coupleId, date, userId } = context.params;
|
||||||
|
const db = admin.firestore();
|
||||||
|
const coupleDoc = await db.collection('couples').doc(coupleId).get();
|
||||||
|
if (!coupleDoc.exists) {
|
||||||
|
console.warn(`[onAnswerWritten] couple ${coupleId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
|
||||||
|
const partnerId = userIds.find((uid) => uid !== userId);
|
||||||
|
if (!partnerId) {
|
||||||
|
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Look up the partner's FCM tokens. We support a legacy `fcmToken` field
|
||||||
|
// on the user doc and a dedicated `fcmTokens` subcollection for multiple devices.
|
||||||
|
const tokens = [];
|
||||||
|
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
|
||||||
|
if (partnerUserDoc.exists) {
|
||||||
|
const legacyToken = (_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.fcmToken;
|
||||||
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
|
tokens.push(legacyToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tokenSnapshot = await db
|
||||||
|
.collection('users')
|
||||||
|
.doc(partnerId)
|
||||||
|
.collection('fcmTokens')
|
||||||
|
.get();
|
||||||
|
tokenSnapshot.docs.forEach((doc) => {
|
||||||
|
var _a;
|
||||||
|
const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
|
||||||
|
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
|
||||||
|
tokens.push(t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
console.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const answerData = snap.data();
|
||||||
|
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : '';
|
||||||
|
const payload = {
|
||||||
|
notification: {
|
||||||
|
title: 'Your partner just answered!',
|
||||||
|
body: "See what they shared for tonight's prompt.",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'partner_answered',
|
||||||
|
coupleId,
|
||||||
|
questionId,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token }))));
|
||||||
|
const failures = [];
|
||||||
|
sendResults.forEach((result, index) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
failures.push(`${tokens[index]}: ${String(result.reason)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(`[onAnswerWritten] some notifications failed:`, failures);
|
||||||
|
}
|
||||||
|
console.log(`[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`);
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=onAnswerWritten.js.map
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"}
|
||||||
|
|
@ -16,6 +16,11 @@ export {
|
||||||
} from './notifications/reminders'
|
} from './notifications/reminders'
|
||||||
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
||||||
export { createDateMatchOnMutualLove } from './dates/createDateMatch'
|
export { createDateMatchOnMutualLove } from './dates/createDateMatch'
|
||||||
|
export {
|
||||||
|
assignDailyQuestion,
|
||||||
|
assignDailyQuestionCallable,
|
||||||
|
} from './questions/assignDailyQuestion'
|
||||||
|
export { onAnswerWritten } from './questions/onAnswerWritten'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Basic health check callable.
|
* Basic health check callable.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
const CST_OFFSET_HOURS = -6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled function that assigns one daily question per couple every day.
|
||||||
|
*
|
||||||
|
* Schedule: 6:00 PM CST (America/Chicago) == 23:00 UTC.
|
||||||
|
* Document path: couples/{coupleId}/daily_question/{date}
|
||||||
|
* - questionId: string
|
||||||
|
* - date: string (YYYY-MM-DD)
|
||||||
|
* - assignedAt: Timestamp
|
||||||
|
* - expiresAt: Timestamp (next day at 6 PM CST)
|
||||||
|
*
|
||||||
|
* Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write
|
||||||
|
* only (`allow write: if false` in rules).
|
||||||
|
*/
|
||||||
|
export const assignDailyQuestion = functions.pubsub
|
||||||
|
.schedule('0 23 * * *')
|
||||||
|
.timeZone('America/Chicago')
|
||||||
|
.onRun(async () => {
|
||||||
|
const db = admin.firestore()
|
||||||
|
const today = cstDateString()
|
||||||
|
const nextDay = nextCstDateString()
|
||||||
|
|
||||||
|
const questionId = await pickRandomQuestionId()
|
||||||
|
if (!questionId) {
|
||||||
|
console.error('[assignDailyQuestion] no active questions available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const couplesSnap = await db.collection('couples').get()
|
||||||
|
const assignedAt = admin.firestore.FieldValue.serverTimestamp()
|
||||||
|
const expiresAt = timestampAt6PmCst(nextDay)
|
||||||
|
|
||||||
|
const writes = couplesSnap.docs.map(async (coupleDoc) => {
|
||||||
|
const coupleId = coupleDoc.id
|
||||||
|
const docRef = db
|
||||||
|
.collection('couples')
|
||||||
|
.doc(coupleId)
|
||||||
|
.collection('daily_question')
|
||||||
|
.doc(today)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await docRef.create({
|
||||||
|
questionId,
|
||||||
|
date: today,
|
||||||
|
assignedAt,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) {
|
||||||
|
// Already assigned for today — idempotent.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error(`[assignDailyQuestion] failed for ${coupleId}:`, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(writes)
|
||||||
|
console.log(
|
||||||
|
`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callable function for immediate daily question assignment.
|
||||||
|
*
|
||||||
|
* Useful when a couple is newly created and should not wait for the next
|
||||||
|
* scheduled run, or for manual admin/testing triggers.
|
||||||
|
*
|
||||||
|
* Body: { coupleId: string, date?: string }
|
||||||
|
*/
|
||||||
|
export const assignDailyQuestionCallable = functions.https.onCall(async (data: any, context) => {
|
||||||
|
const callerId = context.auth?.uid
|
||||||
|
if (!callerId) {
|
||||||
|
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const coupleId = data?.coupleId
|
||||||
|
if (!coupleId || typeof coupleId !== 'string') {
|
||||||
|
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = admin.firestore()
|
||||||
|
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
||||||
|
if (!coupleDoc.exists) {
|
||||||
|
throw new functions.https.HttpsError('not-found', 'Couple not found.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller must be a member of the couple.
|
||||||
|
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||||
|
if (!userIds.includes(callerId)) {
|
||||||
|
throw new functions.https.HttpsError('permission-denied', 'Caller is not a couple member.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = data?.date && typeof data.date === 'string' ? data.date : cstDateString()
|
||||||
|
const questionId = await pickRandomQuestionId()
|
||||||
|
if (!questionId) {
|
||||||
|
throw new functions.https.HttpsError('internal', 'No active questions available.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDay = nextCstDateString(date)
|
||||||
|
const docRef = db
|
||||||
|
.collection('couples')
|
||||||
|
.doc(coupleId)
|
||||||
|
.collection('daily_question')
|
||||||
|
.doc(date)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await docRef.create({
|
||||||
|
questionId,
|
||||||
|
date,
|
||||||
|
assignedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
expiresAt: timestampAt6PmCst(nextDay),
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) {
|
||||||
|
throw new functions.https.HttpsError('already-exists', `Daily question already assigned for ${date}.`)
|
||||||
|
}
|
||||||
|
throw new functions.https.HttpsError('internal', 'Failed to assign daily question.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, coupleId, date, questionId }
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a random active free question from the Firestore `questions` pool.
|
||||||
|
*
|
||||||
|
* The pool is expected to be a top-level collection with documents:
|
||||||
|
* - id: string
|
||||||
|
* - active: boolean
|
||||||
|
* - isPremium: boolean
|
||||||
|
*
|
||||||
|
* If no Firestore pool exists yet, falls back to a deterministic placeholder
|
||||||
|
* so the app still functions during rollout. In production, keep the local
|
||||||
|
* Room database and Firestore pool in sync through the existing seed flow.
|
||||||
|
*/
|
||||||
|
async function pickRandomQuestionId(): Promise<string | null> {
|
||||||
|
const db = admin.firestore()
|
||||||
|
const snapshot = await db
|
||||||
|
.collection('questions')
|
||||||
|
.where('active', '==', true)
|
||||||
|
.where('isPremium', '==', false)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (snapshot.empty) {
|
||||||
|
// Rollout fallback: keep the feature working until the questions pool is seeded.
|
||||||
|
return 'q_default_daily'
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = snapshot.docs
|
||||||
|
const chosen = docs[Math.floor(Math.random() * docs.length)]
|
||||||
|
return chosen.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns today's date as YYYY-MM-DD in America/Chicago time.
|
||||||
|
*/
|
||||||
|
function cstDateString(): string {
|
||||||
|
return formatCstDate(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns tomorrow's date as YYYY-MM-DD relative to America/Chicago time.
|
||||||
|
*/
|
||||||
|
function nextCstDateString(from?: string): string {
|
||||||
|
if (from) {
|
||||||
|
const d = parseCstDate(from)
|
||||||
|
d.setUTCDate(d.getUTCDate() + 1)
|
||||||
|
return formatCstDate(d)
|
||||||
|
}
|
||||||
|
const d = new Date()
|
||||||
|
const cst = applyCstOffset(d)
|
||||||
|
cst.setUTCDate(cst.getUTCDate() + 1)
|
||||||
|
return formatCstDate(cst)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCstOffset(d: Date): Date {
|
||||||
|
const utcMs = d.getTime()
|
||||||
|
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000
|
||||||
|
return new Date(cstMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCstDate(d: Date): string {
|
||||||
|
const cst = applyCstOffset(d)
|
||||||
|
const y = cst.getUTCFullYear()
|
||||||
|
const m = String(cst.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(cst.getUTCDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCstDate(dateStr: string): Date {
|
||||||
|
const [y, m, day] = dateStr.split('-').map(Number)
|
||||||
|
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
|
||||||
|
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0)
|
||||||
|
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
|
||||||
|
const d = parseCstDate(dateStr)
|
||||||
|
const utcMs = d.getTime()
|
||||||
|
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
|
||||||
|
// We already have midnight CST in UTC form; add 18 hours.
|
||||||
|
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000
|
||||||
|
return admin.firestore.Timestamp.fromMillis(at6pmCstMs)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firestore trigger that sends an FCM notification to the other partner when
|
||||||
|
* one partner writes an answer under
|
||||||
|
* couples/{coupleId}/daily_question/{date}/answers/{userId}.
|
||||||
|
*
|
||||||
|
* The notification payload is a data message so the client can route directly to
|
||||||
|
* the answer reveal screen. It also contains a `notification` block for
|
||||||
|
* system-tray display when the app is in the background.
|
||||||
|
*/
|
||||||
|
export const onAnswerWritten = functions.firestore
|
||||||
|
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}')
|
||||||
|
.onCreate(async (snap, context) => {
|
||||||
|
const { coupleId, date, userId } = context.params as {
|
||||||
|
coupleId: string
|
||||||
|
date: string
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = admin.firestore()
|
||||||
|
|
||||||
|
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
||||||
|
if (!coupleDoc.exists) {
|
||||||
|
console.warn(`[onAnswerWritten] couple ${coupleId} not found`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||||
|
const partnerId = userIds.find((uid) => uid !== userId)
|
||||||
|
if (!partnerId) {
|
||||||
|
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the partner's FCM tokens. We support a legacy `fcmToken` field
|
||||||
|
// on the user doc and a dedicated `fcmTokens` subcollection for multiple devices.
|
||||||
|
const tokens: string[] = []
|
||||||
|
const partnerUserDoc = await db.collection('users').doc(partnerId).get()
|
||||||
|
if (partnerUserDoc.exists) {
|
||||||
|
const legacyToken = partnerUserDoc.data()?.fcmToken
|
||||||
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
|
tokens.push(legacyToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenSnapshot = await db
|
||||||
|
.collection('users')
|
||||||
|
.doc(partnerId)
|
||||||
|
.collection('fcmTokens')
|
||||||
|
.get()
|
||||||
|
tokenSnapshot.docs.forEach((doc) => {
|
||||||
|
const t = doc.data()?.token
|
||||||
|
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
|
||||||
|
tokens.push(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
console.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerData = snap.data() as Partial<Record<string, unknown>>
|
||||||
|
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''
|
||||||
|
|
||||||
|
const payload: admin.messaging.MessagingPayload = {
|
||||||
|
notification: {
|
||||||
|
title: 'Your partner just answered!',
|
||||||
|
body: "See what they shared for tonight's prompt.",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'partner_answered',
|
||||||
|
coupleId,
|
||||||
|
questionId,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResults = await Promise.allSettled(
|
||||||
|
tokens.map((token) =>
|
||||||
|
admin.messaging().send({ ...payload, token } as admin.messaging.Message)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const failures: string[] = []
|
||||||
|
sendResults.forEach((result, index) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
failures.push(`${tokens[index]}: ${String(result.reason)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(`[onAnswerWritten] some notifications failed:`, failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue