feat(outcomes): add 30/60/90 day check-in flow with baseline + reminders

This commit is contained in:
null 2026-06-20 23:59:24 -05:00
parent 535c0ce668
commit 57a3e35359
31 changed files with 1868 additions and 20 deletions

View File

@ -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
)
}
}
}
}

View File

@ -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? =

View File

@ -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<AppSettings> = 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 {}

View File

@ -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"

View File

@ -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<Outcome> {
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<String>) ?: 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<String, Int> ?: 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()
}
}

View File

@ -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<Unit> = runCatching {
dataSource.submitOutcome(coupleId, dayKey, scores)
}.onFailure { crashReporter.recordException(it) }
override suspend fun getOutcomes(coupleId: String): List<Outcome> =
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()
}

View File

@ -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
}

View File

@ -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<String, Int> = 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<String> = emptyList(),
val createdAt: Long? = null,
val updatedAt: Long? = null
)

View File

@ -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<Unit>
suspend fun getOutcomes(coupleId: String): List<Outcome>
suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome?
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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 = "Couldnt 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 {
"Youve 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<Outcome>) {
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
)
}
}

View File

@ -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<Outcome> = 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<YourProgressUiState> = _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 ?: "Couldnt 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 ?: "Couldnt 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<Outcome>): 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
}
}

View File

@ -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<OutcomeDay?>(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",

View File

@ -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 ?: "Couldnt 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<Outcome>): 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 {

View File

@ -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} {

View File

@ -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,

View File

@ -1 +1 @@
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}

View File

@ -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

View File

@ -0,0 +1 @@
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}

View File

@ -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 = `Youve 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

View File

@ -0,0 +1 @@
{"version":3,"file":"scheduledOutcomesReminder.js","sourceRoot":"","sources":["../../src/couples/scheduledOutcomesReminder.ts"],"names":[],"mappings":"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"}

View File

@ -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

View File

@ -0,0 +1 @@
{"version":3,"file":"submitOutcomeCallable.js","sourceRoot":"","sources":["../../src/couples/submitOutcomeCallable.ts"],"names":[],"mappings":"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"}

View File

@ -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");

View File

@ -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"}
{"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"}

View File

@ -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,

File diff suppressed because one or more lines are too long

View File

@ -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<number, OutcomeDayKey> = { 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 = `Youve 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<void> {
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<string[]> {
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
}

View File

@ -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<ScoreKey, number> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const map = value as Record<string, unknown>
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<string, any> = {
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<string, number>
const delta: Record<string, number> = {}
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 }
})

View File

@ -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'