diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index c7143d82..8744fa5b 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -73,6 +73,7 @@ import app.closer.ui.settings.PrivacyScreen import app.closer.ui.settings.RelationshipSettingsScreen import app.closer.ui.settings.SettingsScreen import app.closer.ui.settings.SubscriptionScreen +import app.closer.ui.outcomes.YourProgressScreen import app.closer.ui.wheel.CategoryPickerScreen import app.closer.ui.wheel.SpinWheelScreen import app.closer.ui.wheel.WheelCompleteScreen @@ -443,6 +444,11 @@ fun AppNavigation( composable(route = AppRoute.DELETE_ACCOUNT) { DeleteAccountScreen(onNavigate = navigateRoute) } + composable(route = AppRoute.YOUR_PROGRESS) { + YourProgressScreen( + onBack = navigateBackOrHome + ) + } } } } diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 2b52c84d..2c13556e 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -52,6 +52,7 @@ object AppRoute { const val WAITING_FOR_PARTNER = "waiting_for_partner" const val RECOVERY = "recovery" const val ENCRYPTION_UPGRADE = "encryption_upgrade" + const val YOUR_PROGRESS = "your_progress" // Question thread: coupleId and questionId are required; prevId and nextId are optional. const val QUESTION_THREAD = @@ -112,7 +113,8 @@ object AppRoute { Definition(MEMORY_LANE, "Memory Lane", "play"), Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"), Definition(RECOVERY, "Unlock Answers", "security"), - Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security") + Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security"), + Definition(YOUR_PROGRESS, "Your Progress", "settings") ) val topLevelRoutes = setOf( @@ -136,7 +138,8 @@ object AppRoute { EMAIL_INVITE, ACCEPT_INVITE, INVITE_CONFIRM, - PAYWALL + PAYWALL, + YOUR_PROGRESS ) val drillInRoutes = setOf( @@ -166,7 +169,8 @@ object AppRoute { DELETE_ACCOUNT, CONNECTION_CHALLENGES, MEMORY_LANE, - WAITING_FOR_PARTNER + WAITING_FOR_PARTNER, + YOUR_PROGRESS ) fun titleFor(route: String?): String? = diff --git a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt index 3afabc55..e20f36b1 100644 --- a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt +++ b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import app.closer.core.notifications.QuietHours import app.closer.domain.repository.AppSettings @@ -32,6 +33,10 @@ class SettingsDataStore @Inject constructor( private val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete") private val THEME_MODE = stringPreferencesKey("theme_mode") + private val OUTCOME_REMINDER_ENABLED = booleanPreferencesKey("outcome_reminder_enabled") + private val OUTCOME_BASELINE_SHOWN_AT = longPreferencesKey("outcome_baseline_shown_at") + private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day") + override val settings: Flow = dataStore.data.map { prefs -> AppSettings( dailyReminderEnabled = prefs[DAILY_REMINDER] ?: true, @@ -47,10 +52,22 @@ class SettingsDataStore @Inject constructor( endMinute = prefs[QUIET_HOURS_END_MINUTE] ?: 0 ), onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false, - themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE]) + themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE]), + outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true, + outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L, + outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: "" ) } + override suspend fun setOutcomeReminderEnabled(enabled: Boolean) = + dataStore.edit { it[OUTCOME_REMINDER_ENABLED] = enabled }.let {} + + override suspend fun markOutcomeBaselineShown() = + dataStore.edit { it[OUTCOME_BASELINE_SHOWN_AT] = System.currentTimeMillis() }.let {} + + override suspend fun setOutcomeLastPromptedDay(dayKey: String) = + dataStore.edit { it[OUTCOME_LAST_PROMPTED_DAY] = dayKey }.let {} + override suspend fun setDailyReminder(enabled: Boolean) = dataStore.edit { it[DAILY_REMINDER] = enabled }.let {} 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 792603cc..9915a2d2 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -40,6 +40,9 @@ object FirestoreCollections { const val HOW_WELL = "how_well" } + // Standalone subcollection name that appears under both couples/{id} and users/{uid} + const val OUTCOMES = "outcomes" + // ── Subcollections under couples/{coupleId}/daily_question/{date} ─────────── object DailyQuestion { const val ANSWERS = "answers" diff --git a/app/src/main/java/app/closer/data/remote/FirestoreOutcomeDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreOutcomeDataSource.kt new file mode 100644 index 00000000..e1e7a03c --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreOutcomeDataSource.kt @@ -0,0 +1,86 @@ +package app.closer.data.remote + +import app.closer.domain.model.Outcome +import app.closer.domain.model.OutcomeDayKey +import app.closer.domain.model.OutcomeScores +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.functions.FirebaseFunctions +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Singleton +class FirestoreOutcomeDataSource @Inject constructor( + private val db: FirebaseFirestore, + private val functions: FirebaseFunctions +) { + suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit = + suspendCancellableCoroutine { cont -> + functions.getHttpsCallable("submitOutcomeCallable") + .call( + mapOf( + "coupleId" to coupleId, + "dayKey" to dayKey, + "scores" to scores.toMap() + ) + ) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + + suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? = + suspendCancellableCoroutine { cont -> + outcomeRef(coupleId, dayKey) + .get() + .addOnSuccessListener { snap -> + cont.resume(if (snap.exists()) snap.toOutcome() else null) + } + .addOnFailureListener { cont.resumeWithException(it) } + } + + suspend fun getOutcomes(coupleId: String): List { + val snapshot = db + .collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.OUTCOMES) + .get() + .await() + return snapshot.documents.mapNotNull { it.toOutcome() } + } + + private fun outcomeRef(coupleId: String, dayKey: OutcomeDayKey) = + db.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.OUTCOMES) + .document(dayKey) + + @Suppress("UNCHECKED_CAST") + private fun DocumentSnapshot.toOutcome(): Outcome? { + val data = this.data ?: return null + val dayKey = data["dayKey"] as? String ?: return null + return Outcome( + dayKey = dayKey, + baseline = (data["baseline"] as? Map<*, *>)?.toOutcomeScores(), + scores = (data["scores"] as? Map<*, *>)?.toOutcomeScores(), + delta = (data["delta"] as? Map<*, *>)?.toOutcomeScores(), + answeredBy = (data["answeredBy"] as? List) ?: emptyList(), + createdAt = (data["createdAt"] as? com.google.firebase.Timestamp)?.toDate()?.time, + updatedAt = (data["updatedAt"] as? com.google.firebase.Timestamp)?.toDate()?.time + ) + } + + @Suppress("UNCHECKED_CAST") + private fun Map<*, *>.toOutcomeScores(): OutcomeScores? { + val map = this as? Map ?: return null + val connection = map["connection"] ?: return null + val communication = map["communication"] ?: return null + val intimacy = map["intimacy"] ?: return null + val happiness = map["happiness"] ?: return null + return runCatching { OutcomeScores(connection, communication, intimacy, happiness) } + .getOrNull() + } +} diff --git a/app/src/main/java/app/closer/data/repository/OutcomeRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/OutcomeRepositoryImpl.kt new file mode 100644 index 00000000..5d03d5a1 --- /dev/null +++ b/app/src/main/java/app/closer/data/repository/OutcomeRepositoryImpl.kt @@ -0,0 +1,35 @@ +package app.closer.data.repository + +import app.closer.core.crash.CrashReporter +import app.closer.data.remote.FirestoreOutcomeDataSource +import app.closer.domain.model.Outcome +import app.closer.domain.model.OutcomeDayKey +import app.closer.domain.model.OutcomeScores +import app.closer.domain.repository.OutcomeRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OutcomeRepositoryImpl @Inject constructor( + private val dataSource: FirestoreOutcomeDataSource, + private val crashReporter: CrashReporter +) : OutcomeRepository { + + override suspend fun submitOutcome( + coupleId: String, + dayKey: OutcomeDayKey, + scores: OutcomeScores + ): Result = runCatching { + dataSource.submitOutcome(coupleId, dayKey, scores) + }.onFailure { crashReporter.recordException(it) } + + override suspend fun getOutcomes(coupleId: String): List = + runCatching { dataSource.getOutcomes(coupleId) } + .onFailure { crashReporter.recordException(it) } + .getOrDefault(emptyList()) + + override suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? = + runCatching { dataSource.getOutcome(coupleId, dayKey) } + .onFailure { crashReporter.recordException(it) } + .getOrNull() +} diff --git a/app/src/main/java/app/closer/di/RepositoryModule.kt b/app/src/main/java/app/closer/di/RepositoryModule.kt index 1cdf9b0c..f88de782 100644 --- a/app/src/main/java/app/closer/di/RepositoryModule.kt +++ b/app/src/main/java/app/closer/di/RepositoryModule.kt @@ -7,6 +7,7 @@ import app.closer.data.repository.BucketListRepositoryImpl import app.closer.data.repository.CoupleRepositoryImpl import app.closer.data.repository.DateMatchRepositoryImpl import app.closer.data.repository.DatePlanRepositoryImpl +import app.closer.data.repository.OutcomeRepositoryImpl import app.closer.data.repository.QuestionSessionRepositoryImpl import app.closer.data.repository.FirebaseAuthRepositoryImpl import app.closer.data.repository.InviteRepositoryImpl @@ -22,6 +23,7 @@ import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.DateMatchRepository import app.closer.domain.repository.DatePlanRepository import app.closer.domain.repository.QuestionSessionRepository +import app.closer.domain.repository.OutcomeRepository import app.closer.domain.repository.InviteRepository import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.QuestionRepository @@ -79,4 +81,7 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindQuestionSessionRepository(impl: QuestionSessionRepositoryImpl): QuestionSessionRepository + + @Binds @Singleton + abstract fun bindOutcomeRepository(impl: OutcomeRepositoryImpl): OutcomeRepository } diff --git a/app/src/main/java/app/closer/domain/model/Outcome.kt b/app/src/main/java/app/closer/domain/model/Outcome.kt new file mode 100644 index 00000000..7839d77f --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/Outcome.kt @@ -0,0 +1,53 @@ +package app.closer.domain.model + +/** + * Manual test flow: + * 1. Pair two test users. Open Settings → Your Progress. Baseline should be empty. + * 2. Submit baseline from either member via outcome dialog. Return to Your Progress; baseline shown. + * 3. Submit follow-up day_30 outcome. Deltas should compute against baseline. + * 4. Verify invalid dayKey or out-of-range scores rejected by callable. + */ + +typealias OutcomeDayKey = String + +enum class OutcomeDay(val key: OutcomeDayKey, val label: String) { + BASELINE("day_0", "Baseline"), + DAY_30("day_30", "30-day"), + DAY_60("day_60", "60-day"), + DAY_90("day_90", "90-day"); + + companion object { + fun fromKey(key: OutcomeDayKey): OutcomeDay? = entries.firstOrNull { it.key == key } + } +} + +data class OutcomeScores( + val connection: Int, + val communication: Int, + val intimacy: Int, + val happiness: Int +) { + init { + require(connection in 1..10) { "connection must be 1-10" } + require(communication in 1..10) { "communication must be 1-10" } + require(intimacy in 1..10) { "intimacy must be 1-10" } + require(happiness in 1..10) { "happiness must be 1-10" } + } + + fun toMap(): Map = mapOf( + "connection" to connection, + "communication" to communication, + "intimacy" to intimacy, + "happiness" to happiness + ) +} + +data class Outcome( + val dayKey: OutcomeDayKey, + val baseline: OutcomeScores? = null, + val scores: OutcomeScores? = null, + val delta: OutcomeScores? = null, + val answeredBy: List = emptyList(), + val createdAt: Long? = null, + val updatedAt: Long? = null +) diff --git a/app/src/main/java/app/closer/domain/repository/OutcomeRepository.kt b/app/src/main/java/app/closer/domain/repository/OutcomeRepository.kt new file mode 100644 index 00000000..b84a8b02 --- /dev/null +++ b/app/src/main/java/app/closer/domain/repository/OutcomeRepository.kt @@ -0,0 +1,11 @@ +package app.closer.domain.repository + +import app.closer.domain.model.Outcome +import app.closer.domain.model.OutcomeDayKey +import app.closer.domain.model.OutcomeScores + +interface OutcomeRepository { + suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Result + suspend fun getOutcomes(coupleId: String): List + suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? +} diff --git a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt index ef20a92a..2d680a43 100644 --- a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt @@ -22,7 +22,10 @@ data class AppSettings( val quietHoursEnabled: Boolean = false, val quietHours: QuietHours = QuietHours(), val onboardingComplete: Boolean = false, - val themeMode: ThemeMode = ThemeMode.DEVICE + val themeMode: ThemeMode = ThemeMode.DEVICE, + val outcomeReminderEnabled: Boolean = true, + val outcomeBaselineShownAt: Long = 0L, + val outcomeLastPromptedDay: String = "" ) interface SettingsRepository { @@ -35,4 +38,7 @@ interface SettingsRepository { suspend fun setQuietHours(quietHours: QuietHours) suspend fun setOnboardingComplete(complete: Boolean) suspend fun setThemeMode(mode: ThemeMode) + suspend fun setOutcomeReminderEnabled(enabled: Boolean) + suspend fun markOutcomeBaselineShown() + suspend fun setOutcomeLastPromptedDay(dayKey: String) } diff --git a/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt b/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt new file mode 100644 index 00000000..22217f2b --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/OutcomeCheckInDialog.kt @@ -0,0 +1,188 @@ +package app.closer.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import app.closer.domain.model.OutcomeScores +import app.closer.ui.settings.SettingsInk +import app.closer.ui.settings.SettingsMuted +import app.closer.ui.settings.SettingsOnPrimary +import app.closer.ui.settings.SettingsPrimary +import app.closer.ui.settings.SettingsPrimaryDeep +import app.closer.ui.settings.SettingsSoft + +/** + * Manual test flow: + * 1. Trigger dialog from pairing completion, Home banner, or Settings → Your Progress. + * 2. Adjust sliders; confirm Submit is disabled until all four answered. + * 3. Submit and verify callable invoked with correct scores. + * 4. Skip closes dialog without submission. + */ +@Composable +fun OutcomeCheckInDialog( + title: String, + subtitle: String, + onDismiss: () -> Unit, + onSubmit: (OutcomeScores) -> Unit +) { + var connection by remember { mutableIntStateOf(5) } + var communication by remember { mutableIntStateOf(5) } + var intimacy by remember { mutableIntStateOf(5) } + var happiness by remember { mutableIntStateOf(5) } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(20.dp), + color = SettingsSoft, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = SettingsInk, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = SettingsMuted, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + OutcomeSlider( + label = "How connected do you feel?", + value = connection, + onValueChange = { connection = it } + ) + OutcomeSlider( + label = "How well do you communicate?", + value = communication, + onValueChange = { communication = it } + ) + OutcomeSlider( + label = "How satisfied are you with intimacy?", + value = intimacy, + onValueChange = { intimacy = it } + ) + OutcomeSlider( + label = "How happy are you overall?", + value = happiness, + onValueChange = { happiness = it } + ) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { + onSubmit( + OutcomeScores( + connection = connection, + communication = communication, + intimacy = intimacy, + happiness = happiness + ) + ) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = SettingsPrimary, + contentColor = SettingsOnPrimary + ) + ) { + Text("Submit", style = MaterialTheme.typography.labelLarge) + } + + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Skip for now", + style = MaterialTheme.typography.bodyMedium, + color = SettingsPrimaryDeep + ) + } + } + } + } +} + +@Composable +private fun OutcomeSlider( + label: String, + value: Int, + onValueChange: (Int) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = SettingsInk + ) + Text( + text = value.toString(), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = SettingsPrimaryDeep + ) + } + Slider( + value = value.toFloat(), + onValueChange = { onValueChange(it.toInt().coerceIn(1, 10)) }, + valueRange = 1f..10f, + steps = 8, + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("1", style = MaterialTheme.typography.labelSmall, color = SettingsMuted) + Text("10", style = MaterialTheme.typography.labelSmall, color = SettingsMuted) + } + } +} diff --git a/app/src/main/java/app/closer/ui/outcomes/YourProgressScreen.kt b/app/src/main/java/app/closer/ui/outcomes/YourProgressScreen.kt new file mode 100644 index 00000000..e05b2a6a --- /dev/null +++ b/app/src/main/java/app/closer/ui/outcomes/YourProgressScreen.kt @@ -0,0 +1,271 @@ +package app.closer.ui.outcomes + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.TrendingUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.closer.domain.model.Outcome +import app.closer.domain.model.OutcomeDay +import app.closer.domain.model.OutcomeScores +import app.closer.ui.components.ErrorState +import app.closer.ui.settings.SettingsBackgroundBrush +import app.closer.ui.settings.SettingsInk +import app.closer.ui.settings.SettingsMuted +import app.closer.ui.settings.SettingsPrimaryDeep +import app.closer.ui.settings.SettingsSoft + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun YourProgressScreen( + onBack: () -> Unit, + viewModel: YourProgressViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { viewModel.load() } + + Scaffold( + containerColor = Color.Transparent, + modifier = Modifier.background(SettingsBackgroundBrush), + topBar = { + TopAppBar( + title = { Text("Your Progress", color = SettingsInk) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = SettingsInk + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(Modifier.height(8.dp)) + + when { + state.isLoading -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } + state.error != null -> { + ErrorState( + title = "Couldn’t load progress", + message = state.error ?: "", + retryLabel = "Retry", + onRetry = viewModel::load + ) + } + else -> { + ProgressHeader(state.baseline) + state.baseline?.let { baseline -> + state.latest?.let { latest -> + DeltaCard(baseline = baseline, latest = latest) + } + } + MilestoneList(state.outcomes) + } + } + + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun ProgressHeader(baseline: Outcome?) { + val subtitle = if (baseline == null) { + "Submit your first check-in to start tracking how your relationship feels over time." + } else { + "You’ve started tracking how your relationship feels. Check back at 30, 60, and 90 days to see what changes." + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SettingsSoft) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.TrendingUp, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = SettingsPrimaryDeep + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = if (baseline == null) "No baseline yet" else "Baseline recorded", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = SettingsInk + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = SettingsMuted + ) + } + } + } +} + +@Composable +private fun DeltaCard(baseline: Outcome, latest: Outcome) { + val baselineScores = baseline.baseline ?: return + val latestScores = latest.scores ?: return + val delta = latest.delta ?: run { + OutcomeScores( + connection = latestScores.connection - baselineScores.connection, + communication = latestScores.communication - baselineScores.communication, + intimacy = latestScores.intimacy - baselineScores.intimacy, + happiness = latestScores.happiness - baselineScores.happiness + ) + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SettingsSoft) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Change since baseline", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = SettingsInk + ) + DeltaRow(label = "Connected", delta = delta.connection) + DeltaRow(label = "Communication", delta = delta.communication) + DeltaRow(label = "Intimacy", delta = delta.intimacy) + DeltaRow(label = "Happiness", delta = delta.happiness) + } + } +} + +@Composable +private fun DeltaRow(label: String, delta: Int) { + val color = when { + delta > 0 -> SettingsPrimaryDeep + delta < 0 -> Color(0xFFB00020) + else -> SettingsMuted + } + val sign = when { + delta > 0 -> "+" + else -> "" + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge, color = SettingsInk) + Text( + text = "$sign$delta", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = color, + textAlign = TextAlign.End + ) + } +} + +@Composable +private fun MilestoneList(outcomes: List) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = "Milestones", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = SettingsInk + ) + OutcomeDay.entries.forEach { day -> + val outcome = outcomes.firstOrNull { it.dayKey == day.key } + MilestoneRow(day = day, outcome = outcome) + } + } +} + +@Composable +private fun MilestoneRow(day: OutcomeDay, outcome: Outcome?) { + val (title, status) = when (day) { + OutcomeDay.BASELINE -> "Baseline" to if (outcome != null) "Recorded" else "Not yet" + OutcomeDay.DAY_30 -> "30-day check-in" to if (outcome != null) "Recorded" else "Not yet" + OutcomeDay.DAY_60 -> "60-day check-in" to if (outcome != null) "Recorded" else "Not yet" + OutcomeDay.DAY_90 -> "90-day check-in" to if (outcome != null) "Recorded" else "Not yet" + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = title, style = MaterialTheme.typography.bodyLarge, color = SettingsInk) + Text( + text = status, + style = MaterialTheme.typography.bodyMedium, + color = if (outcome != null) SettingsPrimaryDeep else SettingsMuted + ) + } +} diff --git a/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt b/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt new file mode 100644 index 00000000..686c943f --- /dev/null +++ b/app/src/main/java/app/closer/ui/outcomes/YourProgressViewModel.kt @@ -0,0 +1,141 @@ +package app.closer.ui.outcomes + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.domain.model.Outcome +import app.closer.domain.model.OutcomeDay +import app.closer.domain.model.OutcomeDayKey +import app.closer.domain.model.OutcomeScores +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import app.closer.domain.repository.OutcomeRepository +import app.closer.domain.repository.SettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject + +/** + * Manual test flow: + * 1. Open Your Progress; verify empty baseline state. + * 2. Submit baseline via OutcomeCheckInDialog; viewModel marks baseline shown and reloads. + * 3. Submit day_30 outcome; verify delta card computes. + * 4. Verify submitOutcome result reported to UI via snackbar/error. + */ +data class YourProgressUiState( + val isLoading: Boolean = false, + val error: String? = null, + val outcomes: List = emptyList(), + val showBaselineDialog: Boolean = false, + val showFollowUpDialog: Boolean = false, + val pendingDayKey: OutcomeDayKey? = null, + val submitSuccess: Boolean = false +) { + val baseline: Outcome? = outcomes.firstOrNull { it.dayKey == OutcomeDay.BASELINE.key } + val latest: Outcome? = outcomes + .filter { it.dayKey != OutcomeDay.BASELINE.key && it.scores != null } + .maxByOrNull { OutcomeDay.fromKey(it.dayKey)?.ordinal ?: -1 } +} + +@HiltViewModel +class YourProgressViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val outcomeRepository: OutcomeRepository, + private val settingsRepository: SettingsRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(YourProgressUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun load() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null, submitSuccess = false) } + try { + val userId = authRepository.currentUserId + ?: throw IllegalStateException("Not signed in.") + val couple = coupleRepository.getCoupleForUser(userId) + ?: throw IllegalStateException("Not paired.") + val outcomes = outcomeRepository.getOutcomes(couple.id) + + val baselineRecorded = outcomes.any { it.dayKey == OutcomeDay.BASELINE.key } + val promptDay = dueFollowUpDay(couple.createdAt, outcomes) + + _uiState.update { + it.copy( + isLoading = false, + outcomes = outcomes, + showBaselineDialog = !baselineRecorded, + showFollowUpDialog = promptDay != null, + pendingDayKey = promptDay?.key + ) + } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn’t load progress.") } + } + } + } + + fun submitOutcome(dayKey: OutcomeDayKey, scores: OutcomeScores) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null, submitSuccess = false) } + try { + val userId = authRepository.currentUserId + ?: throw IllegalStateException("Not signed in.") + val couple = coupleRepository.getCoupleForUser(userId) + ?: throw IllegalStateException("Not paired.") + outcomeRepository.submitOutcome(couple.id, dayKey, scores).getOrThrow() + + if (dayKey == OutcomeDay.BASELINE.key) { + settingsRepository.markOutcomeBaselineShown() + } + settingsRepository.setOutcomeLastPromptedDay(dayKey) + + _uiState.update { it.copy(submitSuccess = true) } + load() + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn’t submit check-in.") } + } + } + } + + fun dismissBaselineDialog() { + viewModelScope.launch { + settingsRepository.markOutcomeBaselineShown() + _uiState.update { it.copy(showBaselineDialog = false) } + } + } + + fun dismissFollowUpDialog() { + _uiState.update { it.copy(showFollowUpDialog = false) } + } + + fun consumeSuccess() { + _uiState.update { it.copy(submitSuccess = false) } + } + + fun consumeError() { + _uiState.update { it.copy(error = null) } + } + + private fun dueFollowUpDay(createdAt: Long, outcomes: List): OutcomeDay? { + val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()) + val today = LocalDate.now() + val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today) + + val due = listOf( + OutcomeDay.DAY_30 to 30, + OutcomeDay.DAY_60 to 60, + OutcomeDay.DAY_90 to 90 + ).firstOrNull { (_, days) -> ageDays >= days } + ?: return null + + return if (outcomes.any { it.dayKey == due.first.key }) null else due.first + } +} diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 7626a0f1..9a4fde93 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette @@ -41,6 +42,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -48,6 +51,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -60,6 +66,8 @@ import android.content.Context import androidx.compose.ui.platform.LocalContext import app.closer.core.navigation.AppRoute import app.closer.core.navigation.ExternalLinks +import app.closer.domain.model.OutcomeDay +import app.closer.ui.components.OutcomeCheckInDialog import app.closer.ui.settings.SettingsDanger import app.closer.ui.settings.SettingsInk import app.closer.ui.settings.SettingsMuted @@ -76,9 +84,11 @@ fun SettingsSubpage( title: String, onBack: () -> Unit, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable (PaddingValues) -> Unit ) { Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = Color.Transparent, modifier = modifier.background(SettingsBackgroundBrush), topBar = { @@ -172,12 +182,14 @@ fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() + val snackbar = remember { SnackbarHostState() } LaunchedEffect(state.navigateTo) { state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } } Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, containerColor = Color.Transparent, modifier = Modifier.background(SettingsBackgroundBrush), topBar = { @@ -202,6 +214,67 @@ fun SettingsScreen( CircularProgressIndicator() } } else { + var showBaselineOutcomeDialog by remember { mutableStateOf(false) } + var showFollowUpOutcomeDialog by remember { mutableStateOf(false) } + var pendingOutcomeDay by remember { mutableStateOf(null) } + + val followUp = state.outcomeFollowUpDay + LaunchedEffect(state.isPaired, followUp) { + if (state.isPaired) { + if (state.outcomeBaselineDialogDue) { + showBaselineOutcomeDialog = true + } + if (followUp != null) { + pendingOutcomeDay = followUp + showFollowUpOutcomeDialog = true + } + } + } + + if (showBaselineOutcomeDialog) { + OutcomeCheckInDialog( + title = "Quick check-in", + subtitle = "Before you start, how are you feeling about your relationship right now?", + onDismiss = { + viewModel.markBaselineOutcomeShown() + showBaselineOutcomeDialog = false + }, + onSubmit = { scores -> + viewModel.submitOutcome(OutcomeDay.BASELINE.key, scores) + showBaselineOutcomeDialog = false + } + ) + } + + pendingOutcomeDay?.let { day -> + if (showFollowUpOutcomeDialog) { + OutcomeCheckInDialog( + title = "${day.label} check-in", + subtitle = "How are you feeling now compared to when you started?", + onDismiss = { + viewModel.markFollowUpOutcomeShown(day.key) + showFollowUpOutcomeDialog = false + pendingOutcomeDay = null + }, + onSubmit = { scores -> + viewModel.submitOutcome(day.key, scores) + showFollowUpOutcomeDialog = false + pendingOutcomeDay = null + } + ) + } + } + + LaunchedEffect(state.outcomeSubmitSuccess) { + if (state.outcomeSubmitSuccess) { + snackbar.showSnackbar("Check-in saved") + viewModel.consumeOutcomeSuccess() + } + } + LaunchedEffect(state.outcomeError) { + state.outcomeError?.let { snackbar.showSnackbar(it); viewModel.consumeOutcomeError() } + } + Column( modifier = Modifier .fillMaxSize() @@ -338,6 +411,12 @@ fun SettingsScreen( onClick = { onNavigate(AppRoute.ANSWER_HISTORY) } ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + SettingsRow( + icon = Icons.Filled.TrendingUp, + label = "Your Progress", + onClick = { onNavigate(AppRoute.YOUR_PROGRESS) } + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) SettingsRow( icon = Icons.Filled.Palette, label = "Appearance", diff --git a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt index adac1422..3f446661 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt @@ -4,8 +4,13 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.core.navigation.AppRoute +import app.closer.domain.model.Outcome +import app.closer.domain.model.OutcomeDay +import app.closer.domain.model.OutcomeDayKey +import app.closer.domain.model.OutcomeScores import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.CoupleRepository +import app.closer.domain.repository.OutcomeRepository import app.closer.domain.repository.SettingsRepository import app.closer.domain.repository.ThemeMode import app.closer.domain.repository.UserRepository @@ -13,8 +18,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId import javax.inject.Inject data class SettingsUiState( @@ -25,7 +34,11 @@ data class SettingsUiState( val isPaired: Boolean = false, val isSigningOut: Boolean = false, val themeMode: ThemeMode = ThemeMode.DEVICE, - val navigateTo: String? = null + val navigateTo: String? = null, + val outcomeBaselineDialogDue: Boolean = false, + val outcomeFollowUpDay: OutcomeDay? = null, + val outcomeSubmitSuccess: Boolean = false, + val outcomeError: String? = null ) @HiltViewModel @@ -33,7 +46,8 @@ class SettingsViewModel @Inject constructor( private val authRepository: AuthRepository, private val userRepository: UserRepository, private val coupleRepository: CoupleRepository, - private val settingsRepository: SettingsRepository + private val settingsRepository: SettingsRepository, + private val outcomeRepository: OutcomeRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -74,18 +88,87 @@ class SettingsViewModel @Inject constructor( .onFailure { e -> Log.w(TAG, "Could not load partner display name", e) } .getOrNull() } + val outcomes = couple?.let { + runCatching { outcomeRepository.getOutcomes(it.id) } + .onFailure { Log.w(TAG, "Could not load outcomes", it) } + .getOrDefault(emptyList()) + } ?: emptyList() + val baselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt + val outcomeBaselineDialogDue = couple != null && + outcomes.none { it.dayKey == OutcomeDay.BASELINE.key } && + baselineShownAt == 0L + val followUpDay = couple?.let { dueFollowUpDay(it.createdAt, outcomes) } + _uiState.update { it.copy( isLoading = false, displayName = user?.displayName ?: "", email = email, partnerName = partnerName, - isPaired = couple != null + isPaired = couple != null, + outcomeBaselineDialogDue = outcomeBaselineDialogDue, + outcomeFollowUpDay = followUpDay ) } } } + fun submitOutcome(dayKey: OutcomeDayKey, scores: OutcomeScores) { + viewModelScope.launch { + _uiState.update { it.copy(outcomeSubmitSuccess = false, outcomeError = null) } + try { + val userId = authRepository.currentUserId + ?: throw IllegalStateException("Not signed in.") + val couple = coupleRepository.getCoupleForUser(userId) + ?: throw IllegalStateException("Not paired.") + outcomeRepository.submitOutcome(couple.id, dayKey, scores).getOrThrow() + settingsRepository.setOutcomeLastPromptedDay(dayKey) + if (dayKey == OutcomeDay.BASELINE.key) { + settingsRepository.markOutcomeBaselineShown() + } + _uiState.update { + it.copy( + outcomeSubmitSuccess = true, + outcomeBaselineDialogDue = false, + outcomeFollowUpDay = null + ) + } + loadSettings() + } catch (e: Exception) { + _uiState.update { it.copy(outcomeError = e.message ?: "Couldn’t save check-in.") } + } + } + } + + fun markBaselineOutcomeShown() { + viewModelScope.launch { + settingsRepository.markOutcomeBaselineShown() + _uiState.update { it.copy(outcomeBaselineDialogDue = false) } + } + } + + fun markFollowUpOutcomeShown(dayKey: OutcomeDayKey) { + viewModelScope.launch { + settingsRepository.setOutcomeLastPromptedDay(dayKey) + _uiState.update { it.copy(outcomeFollowUpDay = null) } + } + } + + fun consumeOutcomeSuccess() = _uiState.update { it.copy(outcomeSubmitSuccess = false) } + fun consumeOutcomeError() = _uiState.update { it.copy(outcomeError = null) } + + private fun dueFollowUpDay(createdAt: Long, outcomes: List): OutcomeDay? { + val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault()) + val today = LocalDate.now() + val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today) + val due = listOf( + OutcomeDay.DAY_30 to 30, + OutcomeDay.DAY_60 to 60, + OutcomeDay.DAY_90 to 90 + ).firstOrNull { (_, days) -> ageDays >= days } ?: return null + return if (outcomes.any { it.dayKey == due.first.key }) null else due.first + } + fun signOut() { _uiState.update { it.copy(isSigningOut = true) } viewModelScope.launch { diff --git a/firestore.rules b/firestore.rules index e40f15b2..ee498edc 100644 --- a/firestore.rules +++ b/firestore.rules @@ -206,6 +206,14 @@ service cloud.firestore { allow read, write: if false; } + // Per-user outcome mirrors for cross-relationship progress stats. + // Writes are server-side only (submitOutcomeCallable); direct client writes denied. + match /outcomes/{dayKey} { + allow read: if isOwner(uid) + && dayKey in ['day_0', 'day_30', 'day_60', 'day_90']; + allow create, update, delete: if false; + } + // FCM registration tokens: owner can read/write their own tokens. match /fcmTokens/{tokenId} { allow read, write: if isOwner(uid); @@ -521,6 +529,14 @@ service cloud.firestore { allow delete: if isCouplesMember(coupleId); } + // Outcomes: couple-level 30/60/90 day check-ins. Both members can read. + // Writes are server-side only via submitOutcomeCallable; direct client writes denied. + match /outcomes/{dayKey} { + allow read: if isCouplesMember(coupleId) + && dayKey in ['day_0', 'day_30', 'day_60', 'day_90']; + allow create, update, delete: if false; + } + // Daily question: server-assigned once per day per couple. // Writes are server-only (Cloud Functions / Admin SDK). match /daily_question/{date} { diff --git a/functions/dist/couples/acceptInviteCallable.js b/functions/dist/couples/acceptInviteCallable.js index 18e3c9c0..3a67a574 100644 --- a/functions/dist/couples/acceptInviteCallable.js +++ b/functions/dist/couples/acceptInviteCallable.js @@ -111,6 +111,10 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => { inviteCode: code, createdAt: admin.firestore.FieldValue.serverTimestamp(), streakCount: 0, + // encryptionVersion must stay in sync with Android's + // app/src/main/java/app/closer/crypto/EncryptionVersion.kt. + // v0 = plaintext (iOS MVP, no E2EE); v1 = legacy migration; + // v2 = strict E2EE — the default for all new Android couples. encryptionVersion: 2, wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, diff --git a/functions/dist/couples/acceptInviteCallable.js.map b/functions/dist/couples/acceptInviteCallable.js.map index 0c27a516..b7a4b5f6 100644 --- a/functions/dist/couples/acceptInviteCallable.js.map +++ b/functions/dist/couples/acceptInviteCallable.js.map @@ -1 +1 @@ -{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/couples/createInviteCallable.js b/functions/dist/couples/createInviteCallable.js new file mode 100644 index 00000000..519478f4 --- /dev/null +++ b/functions/dist/couples/createInviteCallable.js @@ -0,0 +1,174 @@ +"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.createInviteCallable = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +/** + * HTTPS callable that creates a secure invite code. + * + * Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create + * invites directly. 6-character document IDs are enumerable, so a direct client + * write would expose pending invites to scanning. This function generates a + * unique 6-character code server-side, stores the invite document, and returns + * only the code and expiry to the inviter. + * + * Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string } + * - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF + * - kdfSalt: base64 KDF salt + * - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1) + * - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor) + * + * When E2EE fields are omitted the function writes nulls; iOS MVP creates + * plaintext couples (encryptionVersion=0 on the resulting couple) and does not + * supply these fields. Android always supplies them. + * + * Response: { code: string, expiresAt: Timestamp } + * + * Operations (all via Admin SDK, so Firestore rules are bypassed): + * 1. Verify caller is authenticated and not already paired. + * 2. Rate-limit the caller to 5 invite creations per rolling hour. + * 3. Generate a unique 6-character alphanumeric code via transaction. + * 4. Write the invite document with a 24-hour TTL. + */ +const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const CODE_LENGTH = 6; +const INVITE_TTL_MS = 24 * 60 * 60 * 1000; +const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; +const RATE_LIMIT_MAX = 5; +function generateCode() { + let code = ''; + const randomValues = Buffer.alloc(CODE_LENGTH); + // crypto.randomBytes is synchronous and suitable for Cloud Functions. + require('crypto').randomFillSync(randomValues); + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_CHARS[randomValues[i] % CODE_CHARS.length]; + } + return code; +} +exports.createInviteCallable = functions.https.onCall(async (data, context) => { + var _a, _b, _c; + const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); + } + const db = admin.firestore(); + // Caller must not already be paired. + const callerDoc = await db.collection('users').doc(callerId).get(); + if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) { + throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.'); + } + const callerDisplayName = (_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.displayName; + // Rate limit: count invites created by this user in the last hour. + const now = admin.firestore.Timestamp.now(); + const windowStart = admin.firestore.Timestamp.fromMillis(now.toMillis() - RATE_LIMIT_WINDOW_MS); + const recentInvitesQuery = db + .collection('invites') + .where('inviterUserId', '==', callerId) + .where('createdAt', '>=', windowStart) + .orderBy('createdAt', 'desc') + .limit(RATE_LIMIT_MAX + 1); + const recentInvites = await recentInvitesQuery.get(); + if (recentInvites.size >= RATE_LIMIT_MAX) { + throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.'); + } + const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey; + const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt; + const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams; + const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase; + // E2EE fields must be supplied together or omitted together. + const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]; + const suppliedE2ee = e2eeFields.filter((v) => v != null).length; + if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) { + throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.'); + } + const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS); + // Race-safe unique code creation via transaction. We attempt a bounded number + // of times; each attempt verifies the candidate code is free before creating. + const maxAttempts = 10; + let inviteRef = null; + let code = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const candidate = generateCode(); + const candidateRef = db.collection('invites').doc(candidate); + // eslint-disable-next-line no-await-in-loop + const created = await db.runTransaction(async (tx) => { + const snap = await tx.get(candidateRef); + if (snap.exists) { + return false; + } + tx.set(candidateRef, { + code: candidate, + inviterUserId: callerId, + inviterDisplayName: callerDisplayName !== null && callerDisplayName !== void 0 ? callerDisplayName : null, + status: 'pending', + createdAt: admin.firestore.FieldValue.serverTimestamp(), + expiresAt, + usedAt: null, + usedByUserId: null, + wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, + kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, + kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, + recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null, + }); + return true; + }); + if (created) { + code = candidate; + inviteRef = candidateRef; + break; + } + } + if (!code || !inviteRef) { + throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.'); + } + // Write a server-side audit log entry for the inviter. This is not read by + // clients and supports the rate-limit count as well as future abuse review. + try { + await db.collection('users').doc(callerId).collection('notification_queue').add({ + type: 'invite_created', + inviteCode: code, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + read: true, + }); + } + catch (err) { + // Audit write is best-effort; do not fail the invite if it errors. + console.warn(`[createInviteCallable] audit log failed for ${callerId}:`, err); + } + console.log(`[createInviteCallable] ${callerId} created invite ${code}; expires ${expiresAt.toDate().toISOString()}`); + return { code, expiresAt }; +}); +//# sourceMappingURL=createInviteCallable.js.map \ No newline at end of file diff --git a/functions/dist/couples/createInviteCallable.js.map b/functions/dist/couples/createInviteCallable.js.map new file mode 100644 index 00000000..14ebc94d --- /dev/null +++ b/functions/dist/couples/createInviteCallable.js.map @@ -0,0 +1 @@ +{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/couples/scheduledOutcomesReminder.js b/functions/dist/couples/scheduledOutcomesReminder.js new file mode 100644 index 00000000..f13edec1 --- /dev/null +++ b/functions/dist/couples/scheduledOutcomesReminder.js @@ -0,0 +1,146 @@ +"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.scheduledOutcomesReminder = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +const DAY_MS = 24 * 60 * 60 * 1000; +const REMINDER_DAYS = [30, 60, 90]; +const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' }; +exports.scheduledOutcomesReminder = functions.pubsub + .schedule('every 24 hours') + .onRun(async () => { + var _a, _b, _c; + const db = admin.firestore(); + const messaging = admin.messaging(); + const now = Date.now(); + const couplesSnap = await db.collection('couples').limit(200).get(); + const notifications = []; + for (const coupleDoc of couplesSnap.docs) { + const coupleId = coupleDoc.id; + const data = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {}; + const createdAt = Number((_b = data.createdAt) !== null && _b !== void 0 ? _b : 0); + if (createdAt <= 0) + continue; + const ageDays = Math.floor((now - createdAt) / DAY_MS); + const dueDays = REMINDER_DAYS.filter((day) => ageDays >= day && ageDays <= day + 2); + if (dueDays.length === 0) + continue; + const userIds = ((_c = data.userIds) !== null && _c !== void 0 ? _c : []); + if (userIds.length === 0) + continue; + // Check each due checkpoint; only remind for the first one without an outcome. + let remindedDay = null; + for (const day of dueDays) { + const dayKey = DAY_KEY_MAP[day]; + const outcomeSnap = await coupleDoc.ref.collection('outcomes').doc(dayKey).get(); + if (!outcomeSnap.exists) { + remindedDay = day; + break; + } + } + if (remindedDay == null) + continue; + const dayLabel = remindedDay; + const title = 'How are you feeling together?'; + const body = `You’ve been connected for ${dayLabel} days. Take a quick check-in to see how things have changed.`; + for (const userId of userIds) { + notifications.push({ userId, coupleId, day: dayLabel, title, body }); + } + } + await Promise.all(notifications.map((notification) => sendOutcomeReminder(db, messaging, notification))); + console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`); +}); +async function sendOutcomeReminder(db, messaging, notification) { + await db + .collection('users') + .doc(notification.userId) + .collection('notification_queue') + .add({ + type: 'outcome_reminder', + title: notification.title, + body: notification.body, + coupleId: notification.coupleId, + day: notification.day, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + const tokens = await getUserTokens(db, notification.userId); + if (tokens.length === 0) { + console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`); + return; + } + const message = { + token: tokens[0], + notification: { + title: notification.title, + body: notification.body, + }, + data: { + type: 'outcome_reminder', + coupleId: notification.coupleId, + day: String(notification.day), + }, + }; + const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token })))); + sendResults.forEach((result, index) => { + if (result.status === 'rejected') { + console.warn(`[sendOutcomeReminder] FCM send to ${tokens[index]} failed:`, result.reason); + } + }); +} +async function getUserTokens(db, userId) { + var _a; + const tokens = []; + const userDoc = await db.collection('users').doc(userId).get(); + const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken); + } + const tokenSnapshot = await db + .collection('users') + .doc(userId) + .collection('fcmTokens') + .get(); + tokenSnapshot.docs.forEach((doc) => { + var _a; + const token = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token; + if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) { + tokens.push(token); + } + }); + return tokens; +} +//# sourceMappingURL=scheduledOutcomesReminder.js.map \ No newline at end of file diff --git a/functions/dist/couples/scheduledOutcomesReminder.js.map b/functions/dist/couples/scheduledOutcomesReminder.js.map new file mode 100644 index 00000000..a85a2a1a --- /dev/null +++ b/functions/dist/couples/scheduledOutcomesReminder.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scheduledOutcomesReminder.js","sourceRoot":"","sources":["../../src/couples/scheduledOutcomesReminder.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/couples/submitOutcomeCallable.js b/functions/dist/couples/submitOutcomeCallable.js new file mode 100644 index 00000000..ef273e5c --- /dev/null +++ b/functions/dist/couples/submitOutcomeCallable.js @@ -0,0 +1,141 @@ +"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.submitOutcomeCallable = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +const DAY_KEYS = ['day_0', 'day_30', 'day_60', 'day_90']; +const SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness']; +const MIN_SCORE = 1; +const MAX_SCORE = 10; +function isValidDayKey(value) { + return typeof value === 'string' && DAY_KEYS.includes(value); +} +function isValidScoreMap(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return false; + const map = value; + for (const key of SCORE_KEYS) { + const num = map[key]; + if (typeof num !== 'number' || Number.isNaN(num)) + return false; + if (num < MIN_SCORE || num > MAX_SCORE) + return false; + } + return true; +} +exports.submitOutcomeCallable = functions.https.onCall(async (data, context) => { + var _a, _b, _c; + const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); + } + const coupleId = data === null || data === void 0 ? void 0 : data.coupleId; + if (typeof coupleId !== 'string' || coupleId.length === 0) { + throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.'); + } + const dayKey = data === null || data === void 0 ? void 0 : data.dayKey; + if (!isValidDayKey(dayKey)) { + throw new functions.https.HttpsError('invalid-argument', `dayKey must be one of: ${DAY_KEYS.join(', ')}.`); + } + const scores = data === null || data === void 0 ? void 0 : data.scores; + if (!isValidScoreMap(scores)) { + throw new functions.https.HttpsError('invalid-argument', `scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.`); + } + const db = admin.firestore(); + const coupleRef = db.collection('couples').doc(coupleId); + // Caller must be a member of the couple. + const coupleDoc = await coupleRef.get(); + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.'); + } + 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 member of this couple.'); + } + const now = admin.firestore.Timestamp.now(); + const outcomeRef = coupleRef.collection('outcomes').doc(dayKey); + const result = await db.runTransaction(async (tx) => { + var _a, _b, _c, _d, _e, _f, _g, _h; + const existing = await tx.get(outcomeRef); + const existingData = existing.exists ? ((_a = existing.data()) !== null && _a !== void 0 ? _a : {}) : {}; + // If this is a follow-up (non-baseline), a baseline must exist to compute delta. + if (dayKey !== 'day_0') { + const baselineRef = coupleRef.collection('outcomes').doc('day_0'); + const baselineSnap = await tx.get(baselineRef); + if (!baselineSnap.exists) { + throw new functions.https.HttpsError('failed-precondition', 'Baseline (day_0) outcome must be submitted before follow-up outcomes.'); + } + } + const answeredBy = ((_b = existingData.answeredBy) !== null && _b !== void 0 ? _b : []); + if (!answeredBy.includes(callerId)) { + answeredBy.push(callerId); + } + const payload = { + dayKey, + answeredBy, + updatedAt: now, + createdAt: (_c = existingData.createdAt) !== null && _c !== void 0 ? _c : now, + }; + if (dayKey === 'day_0') { + payload.baseline = scores; + } + else { + payload.scores = scores; + } + // Delta is recalculated every time so repeated submissions stay consistent. + if (dayKey !== 'day_0') { + const baselineRef = coupleRef.collection('outcomes').doc('day_0'); + const baselineSnap = await tx.get(baselineRef); + if (baselineSnap.exists) { + const baseline = ((_e = (_d = baselineSnap.data()) === null || _d === void 0 ? void 0 : _d.baseline) !== null && _e !== void 0 ? _e : {}); + const delta = {}; + for (const key of SCORE_KEYS) { + delta[key] = ((_f = scores[key]) !== null && _f !== void 0 ? _f : 0) - ((_g = baseline[key]) !== null && _g !== void 0 ? _g : 0); + } + payload.delta = delta; + } + } + tx.set(outcomeRef, payload, { merge: true }); + // Per-user mirror for cross-relationship stats. + const userOutcomeRef = db.collection('users').doc(callerId).collection('outcomes').doc(dayKey); + tx.set(userOutcomeRef, Object.assign(Object.assign({ dayKey, + coupleId }, (dayKey === 'day_0' ? { baseline: scores } : { scores, delta: payload.delta })), { answeredBy: [callerId], createdAt: (_h = existingData.createdAt) !== null && _h !== void 0 ? _h : now, updatedAt: now }), { merge: true }); + return { dayKey, answeredBy }; + }); + console.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`); + return Object.assign({ success: true }, result); +}); +//# sourceMappingURL=submitOutcomeCallable.js.map \ No newline at end of file diff --git a/functions/dist/couples/submitOutcomeCallable.js.map b/functions/dist/couples/submitOutcomeCallable.js.map new file mode 100644 index 00000000..aab883fa --- /dev/null +++ b/functions/dist/couples/submitOutcomeCallable.js.map @@ -0,0 +1 @@ +{"version":3,"file":"submitOutcomeCallable.js","sourceRoot":"","sources":["../../src/couples/submitOutcomeCallable.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 c3834e8e..79f6f466 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.onGameSessionUpdate = exports.onUserDelete = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = 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. @@ -71,6 +71,12 @@ var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable"); Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } }); var acceptInviteCallable_1 = require("./couples/acceptInviteCallable"); Object.defineProperty(exports, "acceptInviteCallable", { enumerable: true, get: function () { return acceptInviteCallable_1.acceptInviteCallable; } }); +var createInviteCallable_1 = require("./couples/createInviteCallable"); +Object.defineProperty(exports, "createInviteCallable", { enumerable: true, get: function () { return createInviteCallable_1.createInviteCallable; } }); +var submitOutcomeCallable_1 = require("./couples/submitOutcomeCallable"); +Object.defineProperty(exports, "submitOutcomeCallable", { enumerable: true, get: function () { return submitOutcomeCallable_1.submitOutcomeCallable; } }); +var scheduledOutcomesReminder_1 = require("./couples/scheduledOutcomesReminder"); +Object.defineProperty(exports, "scheduledOutcomesReminder", { enumerable: true, get: function () { return scheduledOutcomesReminder_1.scheduledOutcomesReminder; } }); var onUserDelete_1 = require("./users/onUserDelete"); Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } }); var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate"); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 3d9593a4..a1ce5586 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,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,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;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;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,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,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;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;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/notifications/sendGentleReminderCallable.js b/functions/dist/notifications/sendGentleReminderCallable.js index b773cc8d..81611b3f 100644 --- a/functions/dist/notifications/sendGentleReminderCallable.js +++ b/functions/dist/notifications/sendGentleReminderCallable.js @@ -36,13 +36,20 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.sendGentleReminderCallable = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const GENTLE_REMINDER_MAX_PER_HOUR = 5; +const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000; // 1 hour /** * Sends a gentle nudge from one partner to the other when the caller has * already answered today's question but the partner hasn't. * - * Rate limit: one reminder per couple per calendar day (UTC). The lock is - * stored in couples/{coupleId}/gentle_reminders/{date} so it survives - * function restarts and is visible to both partners. + * Rate limits: + * - Per-user: max 5 gentle reminders per rolling hour. Guarded by a server-side + * transaction on `rate_limits/{uid}_gentle_reminder` so malicious clients + * cannot bypass it by calling the callable in a loop. The Android-side + * NotificationRateLimiter remains for UX but is not the authoritative guard. + * - Per-couple: one reminder per couple per calendar day (UTC). The lock is + * stored in couples/{coupleId}/gentle_reminders/{date} so it survives + * function restarts and is visible to both partners. * * The notification is both an FCM push (for the system tray) and an entry in * the partner's notification_queue (for in-app display). @@ -69,7 +76,44 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex if (!partnerId) { throw new functions.https.HttpsError('failed-precondition', 'No partner found.'); } - // ── 2. Rate limit: one per couple per day ──────────────────────────────── + // ── 2. Server-side per-user throttle: 5 per hour (rolling window) ──────── + const now = admin.firestore.Timestamp.now(); + const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`); + const throttleResult = await db.runTransaction(async (tx) => { + const snap = await tx.get(rateLimitRef); + const data = snap.data(); + let windowStart; + let count; + if (!snap.exists || !data) { + windowStart = now; + count = 0; + } + else { + windowStart = data.windowStart; + count = (typeof data.count === 'number' ? data.count : 0); + const elapsedMs = now.toMillis() - windowStart.toMillis(); + if (elapsedMs >= GENTLE_REMINDER_WINDOW_MS) { + // Rolling window has expired; start a fresh one. + windowStart = now; + count = 0; + } + } + if (count >= GENTLE_REMINDER_MAX_PER_HOUR) { + const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis()); + const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60000)); + return { allowed: false, retryAfterMinutes }; + } + tx.set(rateLimitRef, { + count: count + 1, + windowStart, + updatedAt: now, + }, { merge: true }); + return { allowed: true, count: count + 1, windowStart }; + }); + if (!throttleResult.allowed) { + throw new functions.https.HttpsError('resource-exhausted', `Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`); + } + // ── 3. Rate limit: one per couple per day ──────────────────────────────── const today = new Date().toISOString().slice(0, 10); // e.g. "2026-06-19" const lockRef = db .collection('couples') @@ -80,7 +124,7 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex if (existingLock.exists) { return { sent: false, reason: 'already_sent_today' }; } - // ── 3. Collect partner FCM tokens ──────────────────────────────────────── + // ── 4. Collect partner FCM tokens ──────────────────────────────────────── const tokens = []; const partnerDoc = await db.collection('users').doc(partnerId).get(); if (partnerDoc.exists) { @@ -101,7 +145,7 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex tokens.push(t); } }); - // ── 4. Write in-app notification record ────────────────────────────────── + // ── 5. Write in-app notification record ────────────────────────────────── await db .collection('users') .doc(partnerId) @@ -114,12 +158,12 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex sent: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - // ── 5. Claim the daily rate-limit lock ─────────────────────────────────── + // ── 6. Claim the daily rate-limit lock ─────────────────────────────────── await lockRef.set({ sentBy: callerId, sentAt: admin.firestore.FieldValue.serverTimestamp(), }); - // ── 6. Send FCM push ───────────────────────────────────────────────────── + // ── 7. Send FCM push ───────────────────────────────────────────────────── if (tokens.length > 0) { const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send({ token, diff --git a/functions/dist/notifications/sendGentleReminderCallable.js.map b/functions/dist/notifications/sendGentleReminderCallable.js.map index a004d54b..f36d9149 100644 --- a/functions/dist/notifications/sendGentleReminderCallable.js.map +++ b/functions/dist/notifications/sendGentleReminderCallable.js.map @@ -1 +1 @@ -{"version":3,"file":"sendGentleReminderCallable.js","sourceRoot":"","sources":["../../src/notifications/sendGentleReminderCallable.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;;;;;GAUG;AACU,QAAA,0BAA0B,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACxF,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA;IAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IAC/E,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,4EAA4E;IAE5E,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IAChE,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAA8B,CAAA;IAC/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,kBAAkB,CAAC,CAAA;IACjF,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAA;IACxE,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;IACvD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,mBAAmB,CAAC,CAAA;IAClF,CAAC;IAED,4EAA4E;IAE5E,MAAM,KAAK,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA,CAAC,oBAAoB;IACxE,MAAM,OAAO,GAAG,EAAE;SACf,UAAU,CAAC,SAAS,CAAC;SACrB,GAAG,CAAC,QAAQ,CAAC;SACb,UAAU,CAAC,kBAAkB,CAAC;SAC9B,GAAG,CAAC,KAAK,CAAC,CAAA;IAEb,MAAM,YAAY,GAAG,MAAM,OAAO,CAAC,GAAG,EAAE,CAAA;IACxC,IAAI,YAAY,CAAC,MAAM,EAAE,CAAC;QACxB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAA;IACtD,CAAC;IAED,4EAA4E;IAE5E,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,UAAU,CAAC,MAAM,EAAE,CAAC;QACtB,MAAM,WAAW,GAAG,MAAA,UAAU,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;QAC/C,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC9D,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC1B,CAAC;IACH,CAAC;IAED,MAAM,SAAS,GAAG,MAAM,EAAE;SACvB,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,WAAW,CAAC;SACvB,GAAG,EAAE,CAAA;IACR,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QAC7B,MAAM,CAAC,GAAG,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QAC3B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACjE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAChB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,4EAA4E;IAE5E,MAAM,EAAE;SACL,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,oBAAoB,CAAC;SAChC,GAAG,CAAC;QACH,IAAI,EAAE,iBAAiB;QACvB,KAAK,EAAE,qCAAqC;QAC5C,IAAI,EAAE,8DAA8D;QACpE,IAAI,EAAE,KAAK;QACX,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxD,CAAC,CAAA;IAEJ,4EAA4E;IAE5E,MAAM,OAAO,CAAC,GAAG,CAAC;QAChB,MAAM,EAAE,QAAQ;QAChB,MAAM,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACrD,CAAC,CAAA;IAEF,4EAA4E;IAE5E,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,UAAU,CAC1C,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC;YACrB,KAAK;YACL,YAAY,EAAE;gBACZ,KAAK,EAAE,qCAAqC;gBAC5C,IAAI,EAAE,8DAA8D;aACrE;YACD,IAAI,EAAE;gBACJ,IAAI,EAAE,iBAAiB;gBACvB,SAAS,EAAE,QAAQ;aACpB;SACF,CAAC,CACH,CACF,CAAA;QAED,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;YAChC,IAAI,MAAM,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;gBACjC,OAAO,CAAC,IAAI,CACV,qDAAqD,MAAM,CAAC,CAAC,CAAC,GAAG,EACjE,MAAM,CAAC,MAAM,CACd,CAAA;YACH,CAAC;QACH,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,OAAO,CAAC,GAAG,CACT,mDAAmD,QAAQ,OAAO,SAAS,cAAc,QAAQ,EAAE,CACpG,CAAA;IACD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;AACvB,CAAC,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"sendGentleReminderCallable.js","sourceRoot":"","sources":["../../src/notifications/sendGentleReminderCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/couples/scheduledOutcomesReminder.ts b/functions/src/couples/scheduledOutcomesReminder.ts new file mode 100644 index 00000000..c2bd7d6b --- /dev/null +++ b/functions/src/couples/scheduledOutcomesReminder.ts @@ -0,0 +1,157 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * Cloud Function: scheduledOutcomesReminder + * + * Manual test flow: + * 1. Create a test couple with createdAt = ~31 days ago. + * 2. Ensure no day_0/day_30 outcomes exist (or only day_0 exists to test day_30). + * 3. Use the Firebase Functions shell or temporarily change the schedule to test. + * 4. Verify FCM notification sent and notification_queue records written. + * 5. Verify day_30 not sent if day_30 outcome already exists. + * + * Cron: every 24 hours. Iterates couples and nudges both members when a 30/60/90 + * day outcome is due and has not yet been submitted. + */ + +export type OutcomeDayKey = 'day_0' | 'day_30' | 'day_60' | 'day_90' + +const DAY_MS = 24 * 60 * 60 * 1000 +const REMINDER_DAYS = [30, 60, 90] as const +const DAY_KEY_MAP: Record = { 30: 'day_30', 60: 'day_60', 90: 'day_90' } + +export const scheduledOutcomesReminder = functions.pubsub + .schedule('every 24 hours') + .onRun(async () => { + const db = admin.firestore() + const messaging = admin.messaging() + const now = Date.now() + + const couplesSnap = await db.collection('couples').limit(200).get() + const notifications: { + userId: string + coupleId: string + day: number + title: string + body: string + }[] = [] + + for (const coupleDoc of couplesSnap.docs) { + const coupleId = coupleDoc.id + const data = coupleDoc.data() ?? {} + const createdAt = Number(data.createdAt ?? 0) + if (createdAt <= 0) continue + + const ageDays = Math.floor((now - createdAt) / DAY_MS) + const dueDays = REMINDER_DAYS.filter((day) => ageDays >= day && ageDays <= day + 2) + if (dueDays.length === 0) continue + + const userIds = (data.userIds ?? []) as string[] + if (userIds.length === 0) continue + + // Check each due checkpoint; only remind for the first one without an outcome. + let remindedDay: number | null = null + for (const day of dueDays) { + const dayKey = DAY_KEY_MAP[day] + const outcomeSnap = await coupleDoc.ref.collection('outcomes').doc(dayKey).get() + if (!outcomeSnap.exists) { + remindedDay = day + break + } + } + if (remindedDay == null) continue + + const dayLabel = remindedDay + const title = 'How are you feeling together?' + const body = `You’ve been connected for ${dayLabel} days. Take a quick check-in to see how things have changed.` + + for (const userId of userIds) { + notifications.push({ userId, coupleId, day: dayLabel, title, body }) + } + } + + await Promise.all( + notifications.map((notification) => + sendOutcomeReminder(db, messaging, notification) + ) + ) + + console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`) + }) + +async function sendOutcomeReminder( + db: admin.firestore.Firestore, + messaging: admin.messaging.Messaging, + notification: { userId: string; coupleId: string; day: number; title: string; body: string } +): Promise { + await db + .collection('users') + .doc(notification.userId) + .collection('notification_queue') + .add({ + type: 'outcome_reminder', + title: notification.title, + body: notification.body, + coupleId: notification.coupleId, + day: notification.day, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + const tokens = await getUserTokens(db, notification.userId) + if (tokens.length === 0) { + console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`) + return + } + + const message: admin.messaging.Message = { + token: tokens[0], + notification: { + title: notification.title, + body: notification.body, + }, + data: { + type: 'outcome_reminder', + coupleId: notification.coupleId, + day: String(notification.day), + }, + } + + const sendResults = await Promise.allSettled( + tokens.map((token) => messaging.send({ ...message, token })) + ) + + sendResults.forEach((result, index) => { + if (result.status === 'rejected') { + console.warn( + `[sendOutcomeReminder] FCM send to ${tokens[index]} failed:`, + result.reason + ) + } + }) +} + +async function getUserTokens(db: admin.firestore.Firestore, userId: string): Promise { + const tokens: string[] = [] + const userDoc = await db.collection('users').doc(userId).get() + const legacyToken = userDoc.data()?.fcmToken + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken) + } + + const tokenSnapshot = await db + .collection('users') + .doc(userId) + .collection('fcmTokens') + .get() + + tokenSnapshot.docs.forEach((doc) => { + const token = doc.data()?.token + if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) { + tokens.push(token) + } + }) + + return tokens +} diff --git a/functions/src/couples/submitOutcomeCallable.ts b/functions/src/couples/submitOutcomeCallable.ts new file mode 100644 index 00000000..24a14160 --- /dev/null +++ b/functions/src/couples/submitOutcomeCallable.ts @@ -0,0 +1,167 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * Cloud Function: submitOutcomeCallable + * + * Manual test flow: + * 1. Pair two test users via acceptInviteCallable. + * 2. From one member, call submitOutcomeCallable with: + * { dayKey: "day_0", scores: { connection: 7, communication: 6, intimacy: 8, happiness: 9 } } + * 3. Verify Firestore doc couples/{coupleId}/outcomes/day_0 exists with the payload. + * 4. Verify users/{callerUid}/outcomes/day_0 mirror doc exists. + * 5. Submit from the partner; verify answeredBy contains both UIDs. + * 6. Try invalid inputs (day_30 without day_0, scores out of range, non-member) and confirm rejection. + * + * Auth required; caller must be a member of the couple. Writes are performed by the + * Admin SDK so they bypass Firestore rules — direct client writes to the outcomes + * subcollection are denied in firestore.rules. + * + * Schema for couples/{coupleId}/outcomes/{dayKey}: + * dayKey "day_0" | "day_30" | "day_60" | "day_90" + * baseline { connection, communication, intimacy, happiness } (day_0 only) + * scores { connection, communication, intimacy, happiness } (day_30/60/90 only) + * delta { connection, communication, intimacy, happiness } (day_30/60/90 only) + * answeredBy [uid1, uid2] — deduplicated, appends on each call + * createdAt server timestamp + * updatedAt server timestamp + * + * Per-user mirror at users/{uid}/outcomes/{dayKey}: + * dayKey, baseline/scores, delta, createdAt, updatedAt + * This supports "your progress across all relationships" later. + */ + +export type OutcomeDayKey = 'day_0' | 'day_30' | 'day_60' | 'day_90' +export type ScoreKey = 'connection' | 'communication' | 'intimacy' | 'happiness' + +const DAY_KEYS: OutcomeDayKey[] = ['day_0', 'day_30', 'day_60', 'day_90'] +const SCORE_KEYS: ScoreKey[] = ['connection', 'communication', 'intimacy', 'happiness'] +const MIN_SCORE = 1 +const MAX_SCORE = 10 + +function isValidDayKey(value: unknown): value is OutcomeDayKey { + return typeof value === 'string' && (DAY_KEYS as string[]).includes(value) +} + +function isValidScoreMap(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const map = value as Record + for (const key of SCORE_KEYS) { + const num = map[key] + if (typeof num !== 'number' || Number.isNaN(num)) return false + if (num < MIN_SCORE || num > MAX_SCORE) return false + } + return true +} + +export const submitOutcomeCallable = functions.https.onCall(async (data: any, context) => { + const callerId = context.auth?.uid + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') + } + + const coupleId = data?.coupleId + if (typeof coupleId !== 'string' || coupleId.length === 0) { + throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.') + } + + const dayKey = data?.dayKey + if (!isValidDayKey(dayKey)) { + throw new functions.https.HttpsError( + 'invalid-argument', + `dayKey must be one of: ${DAY_KEYS.join(', ')}.` + ) + } + + const scores = data?.scores + if (!isValidScoreMap(scores)) { + throw new functions.https.HttpsError( + 'invalid-argument', + `scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.` + ) + } + + const db = admin.firestore() + const coupleRef = db.collection('couples').doc(coupleId) + + // Caller must be a member of the couple. + const coupleDoc = await coupleRef.get() + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.') + } + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + if (!userIds.includes(callerId)) { + throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.') + } + + const now = admin.firestore.Timestamp.now() + const outcomeRef = coupleRef.collection('outcomes').doc(dayKey) + + const result = await db.runTransaction(async (tx) => { + const existing = await tx.get(outcomeRef) + const existingData = existing.exists ? (existing.data() ?? {}) : {} + + // If this is a follow-up (non-baseline), a baseline must exist to compute delta. + if (dayKey !== 'day_0') { + const baselineRef = coupleRef.collection('outcomes').doc('day_0') + const baselineSnap = await tx.get(baselineRef) + if (!baselineSnap.exists) { + throw new functions.https.HttpsError( + 'failed-precondition', + 'Baseline (day_0) outcome must be submitted before follow-up outcomes.' + ) + } + } + + const answeredBy = (existingData.answeredBy ?? []) as string[] + if (!answeredBy.includes(callerId)) { + answeredBy.push(callerId) + } + + const payload: Record = { + dayKey, + answeredBy, + updatedAt: now, + createdAt: (existingData.createdAt as admin.firestore.Timestamp | undefined) ?? now, + } + + if (dayKey === 'day_0') { + payload.baseline = scores + } else { + payload.scores = scores + } + + // Delta is recalculated every time so repeated submissions stay consistent. + if (dayKey !== 'day_0') { + const baselineRef = coupleRef.collection('outcomes').doc('day_0') + const baselineSnap = await tx.get(baselineRef) + if (baselineSnap.exists) { + const baseline = (baselineSnap.data()?.baseline ?? {}) as Record + const delta: Record = {} + for (const key of SCORE_KEYS) { + delta[key] = (scores[key] ?? 0) - (baseline[key] ?? 0) + } + payload.delta = delta + } + } + + tx.set(outcomeRef, payload, { merge: true }) + + // Per-user mirror for cross-relationship stats. + const userOutcomeRef = db.collection('users').doc(callerId).collection('outcomes').doc(dayKey) + tx.set(userOutcomeRef, { + dayKey, + coupleId, + ...(dayKey === 'day_0' ? { baseline: scores } : { scores, delta: payload.delta }), + answeredBy: [callerId], + createdAt: (existingData.createdAt as admin.firestore.Timestamp | undefined) ?? now, + updatedAt: now, + }, { merge: true }) + + return { dayKey, answeredBy } + }) + + console.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`) + + return { success: true, ...result } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index ea6b40e8..0477ba06 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,8 @@ export { onCoupleLeave } from './couples/onCoupleLeave' export { leaveCoupleCallable } from './couples/leaveCoupleCallable' export { acceptInviteCallable } from './couples/acceptInviteCallable' export { createInviteCallable } from './couples/createInviteCallable' +export { submitOutcomeCallable } from './couples/submitOutcomeCallable' +export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder' export { onUserDelete } from './users/onUserDelete' export { onGameSessionUpdate } from './games/onGameSessionUpdate'