diff --git a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt new file mode 100644 index 00000000..16cff918 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -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 ?: 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) + } + } +} 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 349c9cc4..f37b16ca 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -29,6 +29,12 @@ object FirestoreCollections { const val DATE_PLAN_PREFERENCES = "date_plan_preferences" const val DATE_PLANS = "date_plans" 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} ──────────────────── diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index 378bcdbf..5eedc3fb 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -121,12 +121,14 @@ private fun AnswerRevealContent( ) state.answer.isRevealed -> RevealedState( answer = state.answer, + partnerAnswer = state.partnerAnswer, question = state.question, onHistory = onHistory, onHome = onHome ) else -> ReadyToRevealState( answer = state.answer, + partnerAnswer = state.partnerAnswer, question = state.question, onReveal = onReveal, onHistory = onHistory @@ -192,6 +194,7 @@ private fun NoAnswerState( @Composable private fun ReadyToRevealState( answer: LocalAnswer, + partnerAnswer: LocalAnswer?, question: Question?, onReveal: () -> Unit, onHistory: () -> Unit @@ -208,6 +211,7 @@ private fun ReadyToRevealState( overflow = TextOverflow.Ellipsis ) AnswerPreview(answer = answer, revealed = false) + PartnerAnswerSection(partnerAnswer = partnerAnswer, revealed = false) Text( text = "No rush. Reveal this only when you want the conversation to open.", style = MaterialTheme.typography.bodyMedium, @@ -246,6 +250,7 @@ private fun ReadyToRevealState( @Composable private fun RevealedState( answer: LocalAnswer, + partnerAnswer: LocalAnswer?, question: Question?, onHistory: () -> Unit, onHome: () -> Unit @@ -266,6 +271,7 @@ private fun RevealedState( overflow = TextOverflow.Ellipsis ) AnswerPreview(answer = answer, revealed = true) + PartnerAnswerSection(partnerAnswer = partnerAnswer, revealed = true) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( 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 { return when (answerType) { "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 @Composable fun AnswerRevealScreenPreview() { diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index 8399b118..96045574 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -3,6 +3,8 @@ package app.closer.ui.answers import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel 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.repository.AuthRepository @@ -22,15 +24,19 @@ data class AnswerRevealUiState( val error: String? = null, val question: Question? = null, val answer: LocalAnswer? = null, - val coupleId: String? = null + val partnerAnswer: LocalAnswer? = null, + val coupleId: String? = null, + val partnerId: String? = null ) @HiltViewModel class AnswerRevealViewModel @Inject constructor( private val questionRepository: QuestionRepository, private val localAnswerRepository: LocalAnswerRepository, + private val firestoreAnswerDataSource: FirestoreAnswerDataSource, private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, + private val crashReporter: CrashReporter, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -48,16 +54,29 @@ class AnswerRevealViewModel @Inject constructor( viewModelScope.launch { _uiState.value = AnswerRevealUiState(isLoading = true) try { - val coupleId = authRepository.currentUserId?.let { uid -> - runCatching { coupleRepository.getCoupleForUser(uid)?.id }.getOrNull() - } + val (coupleId, partnerId) = resolveCoupleAndPartner() + 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( isLoading = false, - question = questionRepository.getQuestionById(questionId), - answer = localAnswerRepository.getAnswer(questionId), - coupleId = coupleId + question = question, + answer = answer, + partnerAnswer = partnerAnswer, + coupleId = coupleId, + partnerId = partnerId ) } catch (e: Exception) { + crashReporter.recordException(e) _uiState.value = AnswerRevealUiState( isLoading = false, 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 { + 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() { viewModelScope.launch { 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() { viewModelScope.launch { localAnswerRepository.markRevealed(questionId) diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt index a4aa0d97..f9d9cf6d 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt @@ -2,6 +2,9 @@ package app.closer.ui.questions import androidx.lifecycle.ViewModel 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.repository.AuthRepository import app.closer.domain.repository.CoupleRepository @@ -30,8 +33,10 @@ data class LocalQuestionUiState( class DailyQuestionViewModel @Inject constructor( private val repository: QuestionRepository, private val localAnswerRepository: LocalAnswerRepository, + private val firestoreAnswerDataSource: FirestoreAnswerDataSource, private val authRepository: AuthRepository, - private val coupleRepository: CoupleRepository + private val coupleRepository: CoupleRepository, + private val crashReporter: CrashReporter ) : ViewModel() { private val _uiState = MutableStateFlow(LocalQuestionUiState()) @@ -45,11 +50,8 @@ class DailyQuestionViewModel @Inject constructor( viewModelScope.launch { _uiState.value = LocalQuestionUiState(isLoading = true) try { - val question = repository.getDailyQuestion() + val (coupleId, question) = loadCoupleAndQuestion() val answer = question?.let { localAnswerRepository.getAnswer(it.id) } - val coupleId = authRepository.currentUserId?.let { uid -> - runCatching { coupleRepository.getCoupleForUser(uid)?.id }.getOrNull() - } _uiState.value = LocalQuestionUiState( isLoading = false, question = question, @@ -57,6 +59,7 @@ class DailyQuestionViewModel @Inject constructor( pendingScaleValue = defaultScaleValue(question) ).withLocalAnswer(answer) } catch (e: Exception) { + crashReporter.recordException(e) _uiState.value = LocalQuestionUiState( isLoading = false, 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 { + 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) { _uiState.update { it.copy(pendingWrittenText = text, submitted = false) } } @@ -94,8 +141,10 @@ class DailyQuestionViewModel @Inject constructor( val question = state.question ?: return if (!canSubmit(state)) return 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) } + syncAnswerToFirestore(state.coupleId, question.id, localAnswer) } } @@ -114,6 +163,20 @@ class DailyQuestionViewModel @Inject constructor( 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 { diff --git a/firestore.rules b/firestore.rules index 67eb2b57..2847eb09 100644 --- a/firestore.rules +++ b/firestore.rules @@ -349,6 +349,30 @@ service cloud.firestore { && (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid); 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 ──────────────────────────────────────────────────── diff --git a/functions/dist/billing/entitlementLogic.js b/functions/dist/billing/entitlementLogic.js index 2ffa72b0..dd5a0f04 100644 --- a/functions/dist/billing/entitlementLogic.js +++ b/functions/dist/billing/entitlementLogic.js @@ -33,11 +33,13 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); +exports.PREMIUM_REVOKED_TYPES = exports.PREMIUM_ACTIVE_TYPES = void 0; +exports.isPremiumEntitlement = isPremiumEntitlement; exports.applyEntitlementEvent = applyEntitlementEvent; exports.applyEntitlementSync = applyEntitlementSync; const admin = __importStar(require("firebase-admin")); // Events that should grant or keep premium access active. -const PREMIUM_ACTIVE_TYPES = new Set([ +exports.PREMIUM_ACTIVE_TYPES = new Set([ 'INITIAL_PURCHASE', 'RENEWAL', 'PRODUCT_CHANGE', @@ -45,7 +47,7 @@ const PREMIUM_ACTIVE_TYPES = new Set([ 'UNCANCELLATION', ]); // Events that remove premium access. -const PREMIUM_REVOKED_TYPES = new Set([ +exports.PREMIUM_REVOKED_TYPES = new Set([ 'EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE', @@ -96,7 +98,7 @@ async function applyEntitlementEvent(event) { return; } const ref = entitlementsRef(userId); - if (PREMIUM_ACTIVE_TYPES.has(type)) { + if (exports.PREMIUM_ACTIVE_TYPES.has(type)) { const expiresAt = event.expiration_at_ms ? admin.firestore.Timestamp.fromMillis(event.expiration_at_ms) : null; @@ -110,7 +112,7 @@ async function applyEntitlementEvent(event) { console.log(`[entitlement] premium=true for ${userId} (${type})`); return; } - if (PREMIUM_REVOKED_TYPES.has(type)) { + if (exports.PREMIUM_REVOKED_TYPES.has(type)) { await ref.set({ premium: false, expiresAt: null, diff --git a/functions/dist/billing/entitlementLogic.js.map b/functions/dist/billing/entitlementLogic.js.map index f2724dc0..30c5452a 100644 --- a/functions/dist/billing/entitlementLogic.js.map +++ b/functions/dist/billing/entitlementLogic.js.map @@ -1 +1 @@ -{"version":3,"file":"entitlementLogic.js","sourceRoot":"","sources":["../../src/billing/entitlementLogic.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"entitlementLogic.js","sourceRoot":"","sources":["../../src/billing/entitlementLogic.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/billing/entitlementLogic.test.js b/functions/dist/billing/entitlementLogic.test.js new file mode 100644 index 00000000..46e1886b --- /dev/null +++ b/functions/dist/billing/entitlementLogic.test.js @@ -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 \ No newline at end of file diff --git a/functions/dist/billing/entitlementLogic.test.js.map b/functions/dist/billing/entitlementLogic.test.js.map new file mode 100644 index 00000000..d0e1912d --- /dev/null +++ b/functions/dist/billing/entitlementLogic.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"entitlementLogic.test.js","sourceRoot":"","sources":["../../src/billing/entitlementLogic.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index 9fd4c9db..173ae4a6 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); 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 admin = __importStar(require("firebase-admin")); // 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; } }); var createDateMatch_1 = require("./dates/createDateMatch"); 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. * Useful for verifying function deployment and firebase-tools wiring. diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index b3bc1ad9..444809b3 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/functions/dist/questions/assignDailyQuestion.js b/functions/dist/questions/assignDailyQuestion.js new file mode 100644 index 00000000..6357dfd4 --- /dev/null +++ b/functions/dist/questions/assignDailyQuestion.js @@ -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 \ No newline at end of file diff --git a/functions/dist/questions/assignDailyQuestion.js.map b/functions/dist/questions/assignDailyQuestion.js.map new file mode 100644 index 00000000..c648e16f --- /dev/null +++ b/functions/dist/questions/assignDailyQuestion.js.map @@ -0,0 +1 @@ +{"version":3,"file":"assignDailyQuestion.js","sourceRoot":"","sources":["../../src/questions/assignDailyQuestion.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/questions/onAnswerWritten.js b/functions/dist/questions/onAnswerWritten.js new file mode 100644 index 00000000..eb14541b --- /dev/null +++ b/functions/dist/questions/onAnswerWritten.js @@ -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 \ No newline at end of file diff --git a/functions/dist/questions/onAnswerWritten.js.map b/functions/dist/questions/onAnswerWritten.js.map new file mode 100644 index 00000000..c329e4d0 --- /dev/null +++ b/functions/dist/questions/onAnswerWritten.js.map @@ -0,0 +1 @@ +{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts index 42eb3abc..e65f70c5 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,6 +16,11 @@ export { } from './notifications/reminders' export { checkDeviceIntegrity } from './security/checkDeviceIntegrity' export { createDateMatchOnMutualLove } from './dates/createDateMatch' +export { + assignDailyQuestion, + assignDailyQuestionCallable, +} from './questions/assignDailyQuestion' +export { onAnswerWritten } from './questions/onAnswerWritten' /** * Basic health check callable. diff --git a/functions/src/questions/assignDailyQuestion.ts b/functions/src/questions/assignDailyQuestion.ts new file mode 100644 index 00000000..03a7b63a --- /dev/null +++ b/functions/src/questions/assignDailyQuestion.ts @@ -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 { + 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) +} diff --git a/functions/src/questions/onAnswerWritten.ts b/functions/src/questions/onAnswerWritten.ts new file mode 100644 index 00000000..6fed9438 --- /dev/null +++ b/functions/src/questions/onAnswerWritten.ts @@ -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> + 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}` + ) + })