diff --git a/.gitignore b/.gitignore index 23a82c95..6bc12e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ docs/brand/exports/ scratchpad/ SECURITY.md Future.md +docs/strategy/positioning-vs-paired.md diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index da27272b..924040a0 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -1163,6 +1163,38 @@ takes real effect. Read [Authentication and pairing flow](docs/Engineering_Refer still no private content in any event (D6). - Every toggle survives **process death + reinstall-with-data** (overlaps F). +**⛔ Notification Enforcement Matrix (the gap that let the dead Daily/Streak toggles ship — RETROSPECTIVE).** +Crashes/visuals probing isn't enough; trace every toggle end-to-end and prove `off ⇒ suppressed`. Run +`scripts/wiring-scan.sh` first — its Tier-4 check flags any `notif*` field mirrored to `users/{uid}` that **no** +Cloud Function reads (a dead toggle). Then fill this matrix in `ClaudeQACoverage.md`: + +| Toggle / setting | Local store key | `users/{uid}` field | Function(s) that READ it | off ⇒ suppressed? | +|---|---|---|---|---| +| Partner answered | `partner_answered` | `notifPartnerAnswered` | `onAnswerWritten` | … | +| New chat message | `chat_message` | `notifChatMessage` | `onMessageWritten` | … | +| Daily question | `daily_reminder` | `notifDailyReminder` | `dailyQuestionReminder` | … | +| Shared-rhythm (streak) | `streak_reminder` | `notifStreakReminder` | `streakReminder` | … | +| Tips & nudges (promo) | `promotional_notifications` | `notifPromotional` | `reengagement` + `gameRetention`(challenge) | … | +| Quiet hours window | `quiet_hours_*` | `quietHoursEnabled`/`*StartMinutes`/`*EndMinutes`/`timezone` | `recipientInQuietHours` (ALL senders) | … | + +Matrix rules: (1) a toggle with **no `users/{uid}` mirror** or **no function reader** is a DEAD setting — file it, +don't pass it. (2) **Scheduled/cron senders are in scope** — do NOT blanket-defer them to `needs-device`: audit by +code (does the sender read the pref + `recipientInQuietHours`?) and invoke manually where possible (Functions shell / +temporary schedule). Senders to cover: `dailyQuestionReminder`, `streakReminder`, `reengagement`, `gameRetention` +(capsule + challenge), `scheduledOutcomesReminder`. (3) prove `off` live: flip off → trigger → assert **0** push + +**0** `notification_queue` for that user; on → delivers. + +**Standard-settings completeness checklist (presence, not just correctness).** A missing standard control is its own +defect class — audit that each EXISTS: +- [ ] **OS-notification-permission-off awareness** — when `areNotificationsEnabled()` is false, a banner + "Open + system settings" deep-link (`Settings.ACTION_APP_NOTIFICATION_SETTINGS`), re-checked on `ON_RESUME` — else every + toggle is silently dead. +- [ ] **Promotional / marketing opt-out** — a toggle for non-essential nudges, enforced server-side (`notifPromotional`). +- [ ] **Customizable quiet hours** — user-settable Start/End (not a hardcoded window), mirrored + server-enforced. +- [ ] **Sign out** ✓ · **Delete account** ✓ · **Subscription** (Pass K) · **Security** (app-lock + recovery) · **Appearance/theme**. +- [ ] **Export my data** (GDPR — SECURITY.md P2) and a **Help/Support** surface (contact · FAQ · report-a-bug · app + version) — currently GAPS; flag in Future.md, don't silently pass "Settings looks complete". + ### Pass N — Daily question, reveal, check-ins & the other interactive features > **⛔ CLAUDE: Run `scripts/wiring-scan.sh` BEFORE driving these features** (review `/tmp/claude-wiring-scan-.md`, > record counts in `ClaudeQACoverage.md`). Every 🔴 dead-setter / 🟠 orphan-reader is a likely silent dead feature — 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 7f118ca5..ece566fe 100644 --- a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt +++ b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt @@ -25,6 +25,7 @@ class SettingsDataStore @Inject constructor( private val PARTNER_ANSWERED = booleanPreferencesKey("partner_answered") private val CHAT_MESSAGE = booleanPreferencesKey("chat_message") private val STREAK_REMINDER = booleanPreferencesKey("streak_reminder") + private val PROMOTIONAL = booleanPreferencesKey("promotional_notifications") private val QUIET_HOURS = booleanPreferencesKey("quiet_hours") private val QUIET_HOURS_START_HOUR = intPreferencesKey("quiet_hours_start_hour") private val QUIET_HOURS_START_MINUTE = intPreferencesKey("quiet_hours_start_minute") @@ -46,6 +47,7 @@ class SettingsDataStore @Inject constructor( partnerAnsweredEnabled = prefs[PARTNER_ANSWERED] ?: true, chatMessageEnabled = prefs[CHAT_MESSAGE] ?: true, streakReminderEnabled = prefs[STREAK_REMINDER] ?: false, + promotionalEnabled = prefs[PROMOTIONAL] ?: true, quietHoursEnabled = prefs[QUIET_HOURS] ?: false, quietHours = QuietHours( enabled = prefs[QUIET_HOURS] ?: false, @@ -89,6 +91,9 @@ class SettingsDataStore @Inject constructor( override suspend fun setStreakReminder(enabled: Boolean) = dataStore.edit { it[STREAK_REMINDER] = enabled }.let {} + override suspend fun setPromotional(enabled: Boolean) = + dataStore.edit { it[PROMOTIONAL] = enabled }.let {} + override suspend fun setQuietHours(enabled: Boolean) = dataStore.edit { it[QUIET_HOURS] = enabled }.let {} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt index 468348ca..630b8453 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt @@ -151,12 +151,18 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest suspend fun updateNotificationPrefs( uid: String, partnerAnswered: Boolean, - chatMessage: Boolean + chatMessage: Boolean, + dailyReminder: Boolean, + streakReminder: Boolean, + promotional: Boolean ): Unit = suspendCancellableCoroutine { cont -> userRef(uid).set( mapOf( "notifPartnerAnswered" to partnerAnswered, - "notifChatMessage" to chatMessage + "notifChatMessage" to chatMessage, + "notifDailyReminder" to dailyReminder, + "notifStreakReminder" to streakReminder, + "notifPromotional" to promotional ), SetOptions.merge() ) diff --git a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt index e1d7e1d3..f6286671 100644 --- a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt @@ -39,8 +39,16 @@ class UserRepositoryImpl @Inject constructor( metadata: TokenRegistrar.DeviceMetadata ) = dataSource.storeTokenMetadata(uid, token, metadata) - override suspend fun updateNotificationPrefs(uid: String, partnerAnswered: Boolean, chatMessage: Boolean) = - dataSource.updateNotificationPrefs(uid, partnerAnswered, chatMessage) + override suspend fun updateNotificationPrefs( + uid: String, + partnerAnswered: Boolean, + chatMessage: Boolean, + dailyReminder: Boolean, + streakReminder: Boolean, + promotional: Boolean + ) = dataSource.updateNotificationPrefs( + uid, partnerAnswered, chatMessage, dailyReminder, streakReminder, promotional + ) override suspend fun updateQuietHours( uid: String, 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 e4d3c453..bd936e2c 100644 --- a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt @@ -19,6 +19,8 @@ data class AppSettings( val partnerAnsweredEnabled: Boolean = true, val chatMessageEnabled: Boolean = true, val streakReminderEnabled: Boolean = false, + /** Non-essential nudges (re-engagement + retention). Opt-out; default on. */ + val promotionalEnabled: Boolean = true, val quietHoursEnabled: Boolean = false, val quietHours: QuietHours = QuietHours(), val onboardingComplete: Boolean = false, @@ -43,6 +45,7 @@ interface SettingsRepository { suspend fun setPartnerAnswered(enabled: Boolean) suspend fun setChatMessage(enabled: Boolean) suspend fun setStreakReminder(enabled: Boolean) + suspend fun setPromotional(enabled: Boolean) suspend fun setQuietHours(enabled: Boolean) suspend fun setQuietHours(quietHours: QuietHours) suspend fun setOnboardingComplete(complete: Boolean) diff --git a/app/src/main/java/app/closer/domain/repository/UserRepository.kt b/app/src/main/java/app/closer/domain/repository/UserRepository.kt index 6f84c5f2..f4f3f364 100644 --- a/app/src/main/java/app/closer/domain/repository/UserRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/UserRepository.kt @@ -14,7 +14,14 @@ interface UserRepository { suspend fun hasProfile(uid: String): Boolean suspend fun storeFcmToken(uid: String, token: String) suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata) - suspend fun updateNotificationPrefs(uid: String, partnerAnswered: Boolean, chatMessage: Boolean) + suspend fun updateNotificationPrefs( + uid: String, + partnerAnswered: Boolean, + chatMessage: Boolean, + dailyReminder: Boolean, + streakReminder: Boolean, + promotional: Boolean + ) suspend fun updateQuietHours(uid: String, enabled: Boolean, startMinutes: Int, endMinutes: Int, timezone: String) suspend fun clearCoupleId(uid: String) suspend fun deleteUserData(uid: String) diff --git a/app/src/main/java/app/closer/notifications/QuietHoursManager.kt b/app/src/main/java/app/closer/notifications/QuietHoursManager.kt index 9d882ba4..a4514cc4 100644 --- a/app/src/main/java/app/closer/notifications/QuietHoursManager.kt +++ b/app/src/main/java/app/closer/notifications/QuietHoursManager.kt @@ -27,7 +27,11 @@ class QuietHoursManager { val startMinutes = quietHours.startHour * 60 + quietHours.startMinute val endMinutes = quietHours.endHour * 60 + quietHours.endMinute - return if (startMinutes <= endMinutes) { + // Start == end means "no window" (nothing suppressed) — kept in lockstep with the server's + // recipientInQuietHours (functions/src/notifications/quietHours.ts) so both decide identically. + if (startMinutes == endMinutes) return false + + return if (startMinutes < endMinutes) { currentMinutes in startMinutes..endMinutes } else { currentMinutes >= startMinutes || currentMinutes <= endMinutes diff --git a/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt index a6657070..7ec77069 100644 --- a/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt @@ -1,5 +1,6 @@ package app.closer.ui.settings +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.domain.repository.AuthRepository @@ -52,6 +53,25 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import android.content.Intent +import android.provider.Settings +import android.text.format.DateFormat +import androidx.core.app.NotificationManagerCompat +import androidx.compose.foundation.clickable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import app.closer.R import app.closer.ui.components.CloserGlyphs @@ -60,7 +80,12 @@ data class NotificationSettingsUiState( val partnerAnsweredEnabled: Boolean = true, val chatMessageEnabled: Boolean = true, val streakReminderEnabled: Boolean = false, - val quietHoursEnabled: Boolean = false + val promotionalEnabled: Boolean = true, + val quietHoursEnabled: Boolean = false, + val quietHoursStartHour: Int = 22, + val quietHoursStartMinute: Int = 0, + val quietHoursEndHour: Int = 8, + val quietHoursEndMinute: Int = 0 ) @HiltViewModel @@ -77,27 +102,39 @@ class NotificationSettingsViewModel @Inject constructor( partnerAnsweredEnabled = s.partnerAnsweredEnabled, chatMessageEnabled = s.chatMessageEnabled, streakReminderEnabled = s.streakReminderEnabled, - quietHoursEnabled = s.quietHoursEnabled + promotionalEnabled = s.promotionalEnabled, + quietHoursEnabled = s.quietHoursEnabled, + quietHoursStartHour = s.quietHours.startHour, + quietHoursStartMinute = s.quietHours.startMinute, + quietHoursEndHour = s.quietHours.endHour, + quietHoursEndMinute = s.quietHours.endMinute ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NotificationSettingsUiState()) fun toggleDailyReminder(on: Boolean) = viewModelScope.launch { settingsRepository.setDailyReminder(on) + syncNotifPrefs() } fun togglePartnerAnswered(on: Boolean) = viewModelScope.launch { settingsRepository.setPartnerAnswered(on) - syncNotifPrefs(partnerAnswered = on, chatMessage = uiState.value.chatMessageEnabled) + syncNotifPrefs() } fun toggleChatMessage(on: Boolean) = viewModelScope.launch { settingsRepository.setChatMessage(on) - syncNotifPrefs(partnerAnswered = uiState.value.partnerAnsweredEnabled, chatMessage = on) + syncNotifPrefs() } fun toggleStreakReminder(on: Boolean) = viewModelScope.launch { settingsRepository.setStreakReminder(on) + syncNotifPrefs() + } + + fun togglePromotional(on: Boolean) = viewModelScope.launch { + settingsRepository.setPromotional(on) + syncNotifPrefs() } fun toggleQuietHours(on: Boolean) = viewModelScope.launch { @@ -105,17 +142,43 @@ class NotificationSettingsViewModel @Inject constructor( syncQuietHours() } + /** Set a custom quiet-hours window (keeping the current on/off) and mirror it to the server. */ + fun setQuietHoursWindow(startHour: Int, startMinute: Int, endHour: Int, endMinute: Int) = + viewModelScope.launch { + val current = settingsRepository.settings.first().quietHours + settingsRepository.setQuietHours( + current.copy( + startHour = startHour, + startMinute = startMinute, + endHour = endHour, + endMinute = endMinute + ) + ) + syncQuietHours() + } + init { - // Backfill the recipient-side quiet-hours window/timezone to Firestore so the server can - // honor it for backgrounded/killed delivery (M-001) — covers users who enabled quiet hours - // before this build, the next time they open Notification settings. + // Backfill ALL notification prefs + the quiet-hours window/timezone to Firestore so the server + // honors them for backgrounded/killed delivery — heals users from before these fields existed. + syncNotifPrefs() syncQuietHours() } - private fun syncNotifPrefs(partnerAnswered: Boolean, chatMessage: Boolean) { + /** Mirror every local notification pref to the user doc so Cloud Functions can honor them. */ + private fun syncNotifPrefs() { val uid = authRepository.currentUserId ?: return viewModelScope.launch { - runCatching { userRepository.updateNotificationPrefs(uid, partnerAnswered, chatMessage) } + runCatching { + val s = settingsRepository.settings.first() + userRepository.updateNotificationPrefs( + uid = uid, + partnerAnswered = s.partnerAnsweredEnabled, + chatMessage = s.chatMessageEnabled, + dailyReminder = s.dailyReminderEnabled, + streakReminder = s.streakReminderEnabled, + promotional = s.promotionalEnabled + ) + }.onFailure { Log.w(TAG, "syncNotifPrefs failed", it) } } } @@ -132,9 +195,11 @@ class NotificationSettingsViewModel @Inject constructor( endMinutes = qh.endHour * 60 + qh.endMinute, timezone = TimeZone.getDefault().id ) - } + }.onFailure { Log.w(TAG, "syncQuietHours failed", it) } } } + + private companion object { const val TAG = "NotifSettingsVM" } } @OptIn(ExperimentalMaterial3Api::class) @@ -174,6 +239,8 @@ fun NotificationSettingsScreen( .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + NotificationsOffBanner() + Text( text = stringResource(R.string.notifications_reminders_section), style = MaterialTheme.typography.labelLarge, @@ -214,25 +281,23 @@ fun NotificationSettingsScreen( checked = state.streakReminderEnabled, onCheckedChange = viewModel::toggleStreakReminder ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + NotifToggleRow( + label = stringResource(R.string.notifications_promotional), + description = stringResource(R.string.notifications_promotional_desc), + checked = state.promotionalEnabled, + onCheckedChange = viewModel::togglePromotional + ) } } Spacer(Modifier.height(4.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = SettingsCard) - ) { - Column { - NotifToggleRow( - label = stringResource(R.string.notifications_quiet_hours), - description = stringResource(R.string.notifications_quiet_hours_desc), - checked = state.quietHoursEnabled, - onCheckedChange = viewModel::toggleQuietHours - ) - } - } + QuietHoursSection( + state = state, + onToggle = viewModel::toggleQuietHours, + onSetWindow = viewModel::setQuietHoursWindow + ) Spacer(Modifier.height(8.dp)) @@ -281,6 +346,162 @@ private fun NotifToggleRow( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun QuietHoursSection( + state: NotificationSettingsUiState, + onToggle: (Boolean) -> Unit, + onSetWindow: (Int, Int, Int, Int) -> Unit +) { + val context = LocalContext.current + val is24h = remember { DateFormat.is24HourFormat(context) } + // 0 = closed, 1 = editing start, 2 = editing end + var picker by rememberSaveable { mutableStateOf(0) } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SettingsCard) + ) { + Column { + NotifToggleRow( + label = stringResource(R.string.notifications_quiet_hours), + description = if (state.quietHoursEnabled) stringResource( + R.string.notifications_quiet_hours_window, + formatTime(state.quietHoursStartHour, state.quietHoursStartMinute, is24h), + formatTime(state.quietHoursEndHour, state.quietHoursEndMinute, is24h) + ) else stringResource(R.string.notifications_quiet_hours_desc), + checked = state.quietHoursEnabled, + onCheckedChange = onToggle + ) + if (state.quietHoursEnabled) { + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + QuietTimeRow( + label = stringResource(R.string.notifications_quiet_start), + value = formatTime(state.quietHoursStartHour, state.quietHoursStartMinute, is24h), + onClick = { picker = 1 } + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + QuietTimeRow( + label = stringResource(R.string.notifications_quiet_end), + value = formatTime(state.quietHoursEndHour, state.quietHoursEndMinute, is24h), + onClick = { picker = 2 } + ) + } + } + } + + if (picker != 0) { + val editingStart = picker == 1 + val tpState = rememberTimePickerState( + initialHour = if (editingStart) state.quietHoursStartHour else state.quietHoursEndHour, + initialMinute = if (editingStart) state.quietHoursStartMinute else state.quietHoursEndMinute, + is24Hour = is24h + ) + AlertDialog( + onDismissRequest = { picker = 0 }, + confirmButton = { + TextButton(onClick = { + if (editingStart) { + onSetWindow(tpState.hour, tpState.minute, state.quietHoursEndHour, state.quietHoursEndMinute) + } else { + onSetWindow(state.quietHoursStartHour, state.quietHoursStartMinute, tpState.hour, tpState.minute) + } + picker = 0 + }) { Text(stringResource(R.string.action_ok)) } + }, + dismissButton = { + TextButton(onClick = { picker = 0 }) { Text(stringResource(R.string.action_cancel)) } + }, + title = { + Text( + if (editingStart) stringResource(R.string.notifications_quiet_start) + else stringResource(R.string.notifications_quiet_end) + ) + }, + text = { TimePicker(state = tpState) } + ) + } +} + +@Composable +private fun QuietTimeRow(label: String, value: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, style = MaterialTheme.typography.bodyLarge, color = SettingsInk) + Text( + value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun NotificationsOffBanner() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var enabled by remember { + mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled()) + } + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + enabled = NotificationManagerCompat.from(context).areNotificationsEnabled() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + if (enabled) return + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SettingsCard) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(R.string.notifications_off_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = SettingsInk + ) + Text( + text = stringResource(R.string.notifications_off_body), + style = MaterialTheme.typography.bodySmall, + color = SettingsMuted + ) + Button(onClick = { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + context.startActivity(intent) + }) { + Text(stringResource(R.string.notifications_open_settings)) + } + } + } +} + +private fun formatTime(hour: Int, minute: Int, is24Hour: Boolean): String = + if (is24Hour) { + "%02d:%02d".format(hour, minute) + } else { + val period = if (hour < 12) "AM" else "PM" + val h = if (hour % 12 == 0) 12 else hour % 12 + "%d:%02d %s".format(h, minute, period) + } + @Preview @Composable fun NotificationSettingsScreenPreview() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54410556..e20dc872 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,7 +46,15 @@ New chat message Shared rhythm reminder Quiet hours - 10 PM – 8 AM, no notifications + Silence notifications during set hours + Silenced %1$s – %2$s + Start + End + Tips & nudges + Occasional ideas to reconnect — you can turn these off anytime + Notifications are turned off + Turn them on in system settings to get these. + Open settings Your notification preferences are yours alone. Your partner cannot see or change them. diff --git a/app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt b/app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt index 94054315..71140753 100644 --- a/app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt +++ b/app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt @@ -56,6 +56,23 @@ class QuietHoursManagerTest { assertFalse(manager.isInQuietHours(quietHours, calendarAt(8, 31))) } + @Test + fun `same day window includes both boundaries`() { + val quietHours = QuietHours(enabled = true, startHour = 10, endHour = 12) + assertTrue(manager.isInQuietHours(quietHours, calendarAt(10, 0))) + assertTrue(manager.isInQuietHours(quietHours, calendarAt(12, 0))) + assertFalse(manager.isInQuietHours(quietHours, calendarAt(12, 1))) + } + + @Test + fun `start equals end is treated as no window`() { + // Equal start/end means "off" — must match the server (recipientInQuietHours), never suppress. + val quietHours = QuietHours(enabled = true, startHour = 22, startMinute = 0, endHour = 22, endMinute = 0) + assertFalse(manager.isInQuietHours(quietHours, calendarAt(22, 0))) + assertFalse(manager.isInQuietHours(quietHours, calendarAt(3, 0))) + assertFalse(manager.isInQuietHours(quietHours, calendarAt(12, 0))) + } + private fun calendarAt(hour: Int, minute: Int): Calendar { return Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, hour) diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index bb342242..c174aaf4 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -1183,7 +1183,12 @@ These are bugs that cost real debugging time and are easy to re-introduce if you ### M-001 — quiet hours must be enforced SERVER-SIDE (a `notification` block bypasses client code when backgrounded) **Symptom (R15)**: "Quiet hours — 10 PM–8 AM, no notifications" did nothing for the case it exists for. With quiet hours ON and the recipient backgrounded/killed, partner chats/answers still posted to the shade. **Root cause**: quiet hours was **local-only** (`SettingsDataStore`, never written to Firestore) and the only check, `PartnerNotificationManager.isInQuietHours`, runs inside `AppMessagingService.onMessageReceived` — which FCM invokes **only in the foreground**. Partner pushes carry a `notification` block, so when the app is backgrounded/killed the **OS renders it directly** and no app code runs → the window is never consulted. The Settings copy was therefore a false promise for the primary scenario. **Fix (R15)**: enforce server-side. (1) Client mirrors the window to the recipient's user doc — `FirestoreUserDataSource.updateQuietHours()` writes `quietHoursEnabled` + `quietHoursStartMinutes` + `quietHoursEndMinutes` + `timezone` (`TimeZone.getDefault().id`); `NotificationSettingsViewModel` syncs on toggle **and** on init (backfill). (2) `functions/src/notifications/quietHours.ts:recipientInQuietHours(userData)` computes the recipient's local now via `Intl.DateTimeFormat({timeZone})`, handles the midnight-crossing window, and is **FAIL-OPEN** (any missing/malformed field → returns false → deliver). (3) The four partner-action senders skip when it returns true: `onMessageWritten`, `onAnswerWritten`, `onAnswerRevealed`, `onGameSessionUpdate`. (4) `firestore.rules` user-doc update allowlist extended for the four new fields. -**Re-introduction risk**: (a) Client-side suppression of a server `notification`-block push only ever works foreground — any "don't notify when X" rule (quiet hours, snooze, DND) must be enforced where the push is **sent** (Cloud Functions), or sent as data-only (unreliable when killed). (b) **The `users/{uid}` update rule is a field allowlist** (`firestore.rules` ~L198, `hasOnly([...])`) — a new client-written user-doc field is silently `PERMISSION_DENIED` until added there *and* to `FirestoreUserDataSource`. (c) Keep the helper fail-open so a bug can only under-suppress (deliver), never wrongly drop a notification. (d) Scheduled/promotional senders (`reengagement`) already had their own quiet-hours check — the gap was the real-time partner-action path. +**Re-introduction risk**: (a) Client-side suppression of a server `notification`-block push only ever works foreground — any "don't notify when X" rule (quiet hours, snooze, DND) must be enforced where the push is **sent** (Cloud Functions), or sent as data-only (unreliable when killed). (b) **The `users/{uid}` update rule is a field allowlist** (`firestore.rules` ~L198, `hasOnly([...])`) — a new client-written user-doc field is silently `PERMISSION_DENIED` until added there *and* to `FirestoreUserDataSource`. (c) Keep the helper fail-open so a bug can only under-suppress (deliver), never wrongly drop a notification. (d) Scheduled/promotional senders (`reengagement`) already had their own quiet-hours check — the gap was the real-time partner-action path. **[Superseded in part by N-NOTIF-001: it turned out the scheduled senders did NOT all honor quiet hours.]** + +### N-NOTIF-001 — a settings toggle is only real if a server reader enforces it (dead Daily/Streak toggles; scheduled-push quiet-hours blind spot) +**Symptom (R20)**: Notification Settings showed 5 controls but only 2 worked. **Daily question reminder** was local-only (`SettingsDataStore`) and `dailyQuestionReminder` sent to everyone with no pref check → dead. **Streak reminder** was a phantom — local-only AND no streak notification existed anywhere. **Quiet hours** worked for real-time partner pushes (M-001) but the **scheduled/cron senders ignored it** (`dailyQuestionReminder`, `gameRetention`, `scheduledOutcomesReminder`), and the window was a **hardcoded 10 PM–8 AM** the user couldn't change. No promotional opt-out; no OS-permission-off awareness. +**Fix (R20)**: (1) mirror ALL prefs to `users/{uid}` — extended `updateNotificationPrefs` to also write `notifDailyReminder`/`notifStreakReminder`/`notifPromotional`, and the `init` backfill re-mirrors on Settings open so pre-existing users heal; (2) gate `dailyQuestionReminder` on `notifDailyReminder !== false` + `recipientInQuietHours`; (3) NEW `notifications/streakReminder.ts` (couples `streakCount>0` with no shared action today, transactional per-day `streak_reminders/{dateKey}` marker, gated on `notifStreakReminder` + QH); (4) QH guards added to `gameRetention` (both senders) + `scheduledOutcomesReminder`; (5) promotional opt-out enforced on `reengagement` + the `gameRetention` challenge-day nudge (`notifPromotional`); (6) user-settable quiet-hours window via Material3 `TimePicker` + dynamic description; (7) OS-off banner (`NotificationManagerCompat.areNotificationsEnabled()` + `Settings.ACTION_APP_NOTIFICATION_SETTINGS`, re-checked `ON_RESUME`); (8) `firestore.rules` user-doc allowlist extended for the 3 new fields. +**Re-introduction risk**: (a) **a toggle is only real if a Cloud Function reads the mirrored field** — a local-only toggle, or a `users/{uid}` pref no sender reads, is a DEAD setting; `scripts/wiring-scan.sh` Tier-4 now fails CI on any unread `notif*` field. (b) `updateNotificationPrefs` writes all prefs in ONE `set(merge)`, so adding a field there WITHOUT first adding it to the `firestore.rules` allowlist makes the **whole** write `PERMISSION_DENIED` — silently regressing the previously-working prefs too (logcat `NotifSettingsVM: PERMISSION_DENIED`; the `Log.w` in `syncNotifPrefs` surfaces it). The rules deploy is therefore a hard prerequisite — ship the Tier-1 client and the rules together. (c) **scheduled/cron senders are a QH + opt-out blind spot** — every new sender must read the relevant pref AND `recipientInQuietHours`. (d) client `QuietHoursManager.isInQuietHours` and server `recipientInQuietHours` must stay in lockstep (inclusive ends, midnight-crossing, and `start==end` ⇒ "no window"/off) — both are covered by tests (`QuietHoursManagerTest`, `quietHours.test.ts`). ### C-DARK-UI-001 — game surfaces must use theme tokens, not fixed palette darks **Symptom**: This-or-That active gameplay was off-brand and weakly legible in dark mode — option body text and mood/duration chips used fixed `CloserPalette.PurpleDeep`/`PinkAccentDeep` (dark values) on a dark surface, and `ChoicePromptBackdrop` drew a diagonal line + two circles that read like a technical diagram crossing the prompt. diff --git a/firestore.rules b/firestore.rules index 8536da92..7762905e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -199,6 +199,8 @@ service cloud.firestore { 'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId', 'plan', 'createdAt', 'lastActiveAt', 'fcmToken', 'notifPartnerAnswered', 'notifChatMessage', + // Daily/streak/promotional prefs mirrored so the scheduled senders can honor them. + 'notifDailyReminder', 'notifStreakReminder', 'notifPromotional', // M-001: quiet-hours window mirrored for server-side push suppression. 'quietHoursEnabled', 'quietHoursStartMinutes', 'quietHoursEndMinutes', 'timezone' ]); diff --git a/functions/dist/couples/scheduledOutcomesReminder.js b/functions/dist/couples/scheduledOutcomesReminder.js index 0e31492c..bba25524 100644 --- a/functions/dist/couples/scheduledOutcomesReminder.js +++ b/functions/dist/couples/scheduledOutcomesReminder.js @@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.scheduledOutcomesReminder = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const quietHours_1 = require("../notifications/quietHours"); 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' }; @@ -95,6 +96,13 @@ function millisFromFirestoreValue(value) { return 0; } async function sendOutcomeReminder(db, messaging, notification) { + const userDoc = await db.collection('users').doc(notification.userId).get(); + const userData = userDoc.data(); + // Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate). + if ((0, quietHours_1.recipientInQuietHours)(userData)) { + console.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`); + return; + } await db .collection('users') .doc(notification.userId) @@ -108,7 +116,7 @@ async function sendOutcomeReminder(db, messaging, notification) { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - const tokens = await getUserTokens(db, notification.userId); + const tokens = await getUserTokens(db, notification.userId, userData); if (tokens.length === 0) { console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`); return; @@ -133,11 +141,10 @@ async function sendOutcomeReminder(db, messaging, notification) { } }); } -async function getUserTokens(db, userId) { - var _a; +async function getUserTokens(db, userId, userData) { const tokens = []; - const userDoc = await db.collection('users').doc(userId).get(); - const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; + const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data(); + const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken; if (typeof legacyToken === 'string' && legacyToken.length > 0) { tokens.push(legacyToken); } diff --git a/functions/dist/couples/scheduledOutcomesReminder.js.map b/functions/dist/couples/scheduledOutcomesReminder.js.map index 3942d9d7..ce33f4e1 100644 --- a/functions/dist/couples/scheduledOutcomesReminder.js.map +++ b/functions/dist/couples/scheduledOutcomesReminder.js.map @@ -1 +1 @@ -{"version":3,"file":"scheduledOutcomesReminder.js","sourceRoot":"","sources":["../../src/couples/scheduledOutcomesReminder.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"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/index.js b/functions/dist/index.js index 71e490fb..c983d2c5 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.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; const admin = __importStar(require("firebase-admin")); // Initialize the Admin SDK once for every function in this codebase. // Handlers call admin.firestore()/messaging() lazily at invocation time, so a @@ -57,6 +57,8 @@ Object.defineProperty(exports, "sendChallengeDayReminders", { enumerable: true, Object.defineProperty(exports, "unlockDueMemoryCapsules", { enumerable: true, get: function () { return gameRetention_1.unlockDueMemoryCapsules; } }); var dailyQuestionReminder_1 = require("./notifications/dailyQuestionReminder"); Object.defineProperty(exports, "sendDailyQuestionProactiveReminder", { enumerable: true, get: function () { return dailyQuestionReminder_1.sendDailyQuestionProactiveReminder; } }); +var streakReminder_1 = require("./notifications/streakReminder"); +Object.defineProperty(exports, "sendStreakReminder", { enumerable: true, get: function () { return streakReminder_1.sendStreakReminder; } }); var reengagement_1 = require("./notifications/reengagement"); Object.defineProperty(exports, "sendReengagementReminder", { enumerable: true, get: function () { return reengagement_1.sendReengagementReminder; } }); var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity"); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 396962c6..bbc17998 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,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,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,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,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,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,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,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,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,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,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,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,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file diff --git a/functions/dist/notifications/dailyQuestionReminder.js b/functions/dist/notifications/dailyQuestionReminder.js index 37bd2ac6..c7bcf760 100644 --- a/functions/dist/notifications/dailyQuestionReminder.js +++ b/functions/dist/notifications/dailyQuestionReminder.js @@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.sendDailyQuestionProactiveReminder = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const quietHours_1 = require("./quietHours"); /** * Proactive daily question reminder. * @@ -126,6 +127,17 @@ exports.sendDailyQuestionProactiveReminder = functions.pubsub console.log(`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`); }); async function sendReminder(db, messaging, userId, coupleId, questionDate) { + const userDoc = await db.collection('users').doc(userId).get(); + const userData = userDoc.data(); + // Respect the user's Daily Reminder toggle (default on) and quiet hours. + if ((userData === null || userData === void 0 ? void 0 : userData.notifDailyReminder) === false) { + console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`); + return; + } + if ((0, quietHours_1.recipientInQuietHours)(userData)) { + console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`); + return; + } // In-app notification record. await db .collection('users') @@ -139,7 +151,7 @@ async function sendReminder(db, messaging, userId, coupleId, questionDate) { createdAt: admin.firestore.FieldValue.serverTimestamp(), }); // FCM push. - const tokens = await getUserTokens(db, userId); + const tokens = await getUserTokens(db, userId, userData); if (tokens.length === 0) return; const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({ @@ -161,11 +173,10 @@ async function sendReminder(db, messaging, userId, coupleId, questionDate) { } }); } -async function getUserTokens(db, userId) { - var _a; +async function getUserTokens(db, userId, userData) { const tokens = []; - const userDoc = await db.collection('users').doc(userId).get(); - const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; + const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data(); + const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken; if (typeof legacyToken === 'string' && legacyToken.length > 0) { tokens.push(legacyToken); } diff --git a/functions/dist/notifications/dailyQuestionReminder.js.map b/functions/dist/notifications/dailyQuestionReminder.js.map index cb7dbc90..c25eaec3 100644 --- a/functions/dist/notifications/dailyQuestionReminder.js.map +++ b/functions/dist/notifications/dailyQuestionReminder.js.map @@ -1 +1 @@ -{"version":3,"file":"dailyQuestionReminder.js","sourceRoot":"","sources":["../../src/notifications/dailyQuestionReminder.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"dailyQuestionReminder.js","sourceRoot":"","sources":["../../src/notifications/dailyQuestionReminder.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/notifications/gameRetention.js b/functions/dist/notifications/gameRetention.js index 95ba2cea..b6e512fd 100644 --- a/functions/dist/notifications/gameRetention.js +++ b/functions/dist/notifications/gameRetention.js @@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const quietHours_1 = require("./quietHours"); const DAY_MS = 24 * 60 * 60 * 1000; const CHALLENGE_TITLES = { gratitude_week: { title: 'Gratitude Week', durationDays: 7 }, @@ -171,6 +172,19 @@ function reminderKey(userId, day) { return `${userId.replace(/[^\w-]/g, '_')}_${day}`; } async function sendNotification(db, messaging, notification) { + const userDoc = await db.collection('users').doc(notification.userId).get(); + const userData = userDoc.data(); + // Challenge-day reminders are retention nudges → respect the promotional opt-out (default on). + // (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.) + if (notification.type === 'challenge_day_ready' && (userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) { + console.log(`[sendNotification] skip ${notification.userId} — promotional off`); + return; + } + // Honor the recipient's quiet hours for every scheduled push. + if ((0, quietHours_1.recipientInQuietHours)(userData)) { + console.log(`[sendNotification] skip ${notification.userId} — quiet hours`); + return; + } await db .collection('users') .doc(notification.userId) @@ -182,7 +196,7 @@ async function sendNotification(db, messaging, notification) { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - const tokens = await getUserTokens(db, notification.userId); + const tokens = await getUserTokens(db, notification.userId, userData); if (tokens.length === 0) { console.log(`[sendNotification] no FCM tokens for ${notification.userId}`); return; @@ -212,11 +226,10 @@ async function sendNotification(db, messaging, notification) { console.error(`[sendNotification] some notifications failed:`, failures); } } -async function getUserTokens(db, userId) { - var _a; +async function getUserTokens(db, userId, userData) { const tokens = []; - const userDoc = await db.collection('users').doc(userId).get(); - const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; + const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data(); + const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken; if (typeof legacyToken === 'string' && legacyToken.length > 0) { tokens.push(legacyToken); } diff --git a/functions/dist/notifications/gameRetention.js.map b/functions/dist/notifications/gameRetention.js.map index 579c1701..6ad527f9 100644 --- a/functions/dist/notifications/gameRetention.js.map +++ b/functions/dist/notifications/gameRetention.js.map @@ -1 +1 @@ -{"version":3,"file":"gameRetention.js","sourceRoot":"","sources":["../../src/notifications/gameRetention.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"gameRetention.js","sourceRoot":"","sources":["../../src/notifications/gameRetention.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/notifications/quietHours.js b/functions/dist/notifications/quietHours.js index 253c0a7d..17f479a5 100644 --- a/functions/dist/notifications/quietHours.js +++ b/functions/dist/notifications/quietHours.js @@ -43,8 +43,12 @@ function recipientInQuietHours(userData, now = new Date()) { // Unknown/invalid timezone id → fail open. return false; } + // Start == end means "no window" (nothing suppressed) — kept in lockstep with the client's + // QuietHoursManager.isInQuietHours so both decide identically. + if (start === end) + return false; // Window may cross midnight (e.g. 22:00 → 08:00). - return start <= end + return start < end ? nowMinutes >= start && nowMinutes <= end : nowMinutes >= start || nowMinutes <= end; } diff --git a/functions/dist/notifications/quietHours.js.map b/functions/dist/notifications/quietHours.js.map index 328bbb0e..576acaf2 100644 --- a/functions/dist/notifications/quietHours.js.map +++ b/functions/dist/notifications/quietHours.js.map @@ -1 +1 @@ -{"version":3,"file":"quietHours.js","sourceRoot":"","sources":["../../src/notifications/quietHours.ts"],"names":[],"mappings":";;AAcA,sDAkCC;AAhDD;;;;;;;;;;;;;GAaG;AACH,SAAgB,qBAAqB,CACnC,QAAoD,EACpD,MAAY,IAAI,IAAI,EAAE;;IAEtB,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,iBAAiB,KAAK,IAAI;QAAE,OAAO,KAAK,CAAA;IAElE,MAAM,KAAK,GAAG,QAAQ,CAAC,sBAAsB,CAAA;IAC7C,MAAM,GAAG,GAAG,QAAQ,CAAC,oBAAoB,CAAA;IACzC,MAAM,EAAE,GAAG,QAAQ,CAAC,QAAQ,CAAA;IAC5B,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,CAAC,EAAE,EAAE,CAAC;QAC1F,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,UAAkB,CAAA;IACtB,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;YAC7C,QAAQ,EAAE,EAAE;YACZ,IAAI,EAAE,SAAS;YACf,MAAM,EAAE,SAAS;YACjB,MAAM,EAAE,KAAK;SACd,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,CAAA;QACrB,MAAM,IAAI,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,0CAAE,KAAK,CAAC,GAAG,EAAE,CAAA;QACrE,MAAM,MAAM,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,0CAAE,KAAK,CAAC,CAAA;QACpE,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;YAAE,OAAO,KAAK,CAAA;QAC5D,UAAU,GAAG,IAAI,GAAG,EAAE,GAAG,MAAM,CAAA;IACjC,CAAC;IAAC,WAAM,CAAC;QACP,2CAA2C;QAC3C,OAAO,KAAK,CAAA;IACd,CAAC;IAED,kDAAkD;IAClD,OAAO,KAAK,IAAI,GAAG;QACjB,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG;QAC1C,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG,CAAA;AAC9C,CAAC"} \ No newline at end of file +{"version":3,"file":"quietHours.js","sourceRoot":"","sources":["../../src/notifications/quietHours.ts"],"names":[],"mappings":";;AAcA,sDAsCC;AApDD;;;;;;;;;;;;;GAaG;AACH,SAAgB,qBAAqB,CACnC,QAAoD,EACpD,MAAY,IAAI,IAAI,EAAE;;IAEtB,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,iBAAiB,KAAK,IAAI;QAAE,OAAO,KAAK,CAAA;IAElE,MAAM,KAAK,GAAG,QAAQ,CAAC,sBAAsB,CAAA;IAC7C,MAAM,GAAG,GAAG,QAAQ,CAAC,oBAAoB,CAAA;IACzC,MAAM,EAAE,GAAG,QAAQ,CAAC,QAAQ,CAAA;IAC5B,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,CAAC,EAAE,EAAE,CAAC;QAC1F,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,UAAkB,CAAA;IACtB,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;YAC7C,QAAQ,EAAE,EAAE;YACZ,IAAI,EAAE,SAAS;YACf,MAAM,EAAE,SAAS;YACjB,MAAM,EAAE,KAAK;SACd,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,CAAA;QACrB,MAAM,IAAI,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,0CAAE,KAAK,CAAC,GAAG,EAAE,CAAA;QACrE,MAAM,MAAM,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,0CAAE,KAAK,CAAC,CAAA;QACpE,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;YAAE,OAAO,KAAK,CAAA;QAC5D,UAAU,GAAG,IAAI,GAAG,EAAE,GAAG,MAAM,CAAA;IACjC,CAAC;IAAC,WAAM,CAAC;QACP,2CAA2C;QAC3C,OAAO,KAAK,CAAA;IACd,CAAC;IAED,2FAA2F;IAC3F,+DAA+D;IAC/D,IAAI,KAAK,KAAK,GAAG;QAAE,OAAO,KAAK,CAAA;IAE/B,kDAAkD;IAClD,OAAO,KAAK,GAAG,GAAG;QAChB,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG;QAC1C,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG,CAAA;AAC9C,CAAC"} \ No newline at end of file diff --git a/functions/dist/notifications/quietHours.test.js b/functions/dist/notifications/quietHours.test.js new file mode 100644 index 00000000..b25edd2d --- /dev/null +++ b/functions/dist/notifications/quietHours.test.js @@ -0,0 +1,60 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const quietHours_1 = require("./quietHours"); +// recipientInQuietHours is a pure function (Intl + plain userData), so no firebase-admin mock is needed. +// All times below are expressed in UTC and the userData timezone is 'UTC' for determinism. Minutes: +// 22:00 = 1320, 08:00 = 480, 10:00 = 600, 12:00 = 720. +const at = (iso) => new Date(`2026-01-15T${iso}:00Z`); +describe('recipientInQuietHours', () => { + describe('fail-open guards', () => { + it('returns false when quiet hours is not explicitly enabled', () => { + expect((0, quietHours_1.recipientInQuietHours)(undefined, at('23:30'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)({}, at('23:30'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)({ quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'UTC' }, at('23:30'))).toBe(false); + }); + it('returns false when window or timezone fields are missing/malformed', () => { + const base = { quietHoursEnabled: true, timezone: 'UTC' }; + expect((0, quietHours_1.recipientInQuietHours)(Object.assign(Object.assign({}, base), { quietHoursEndMinutes: 480 }), at('23:30'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)(Object.assign(Object.assign({}, base), { quietHoursStartMinutes: 1320 }), at('23:30'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)({ quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480 }, at('23:30'))).toBe(false); + }); + it('returns false for an unknown timezone id', () => { + expect((0, quietHours_1.recipientInQuietHours)({ quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'Not/AZone' }, at('23:30'))).toBe(false); + }); + }); + describe('overnight window (22:00 → 08:00)', () => { + const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'UTC' }; + it('suppresses inside the window, including both boundaries', () => { + expect((0, quietHours_1.recipientInQuietHours)(qh, at('23:30'))).toBe(true); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('03:00'))).toBe(true); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('22:00'))).toBe(true); // start inclusive + expect((0, quietHours_1.recipientInQuietHours)(qh, at('08:00'))).toBe(true); // end inclusive + }); + it('does not suppress outside the window', () => { + expect((0, quietHours_1.recipientInQuietHours)(qh, at('08:01'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:00'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('21:59'))).toBe(false); + }); + }); + describe('same-day window (10:00 → 12:00)', () => { + const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 600, quietHoursEndMinutes: 720, timezone: 'UTC' }; + it('suppresses inside the window, including both boundaries', () => { + expect((0, quietHours_1.recipientInQuietHours)(qh, at('10:30'))).toBe(true); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('10:00'))).toBe(true); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:00'))).toBe(true); + }); + it('does not suppress outside the window', () => { + expect((0, quietHours_1.recipientInQuietHours)(qh, at('09:59'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:01'))).toBe(false); + }); + }); + describe('start == end is "no window"', () => { + it('never suppresses (matches the client QuietHoursManager)', () => { + const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 1320, timezone: 'UTC' }; + expect((0, quietHours_1.recipientInQuietHours)(qh, at('22:00'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('03:00'))).toBe(false); + expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:00'))).toBe(false); + }); + }); +}); +//# sourceMappingURL=quietHours.test.js.map \ No newline at end of file diff --git a/functions/dist/notifications/quietHours.test.js.map b/functions/dist/notifications/quietHours.test.js.map new file mode 100644 index 00000000..7aeab062 --- /dev/null +++ b/functions/dist/notifications/quietHours.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"quietHours.test.js","sourceRoot":"","sources":["../../src/notifications/quietHours.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/notifications/reengagement.js b/functions/dist/notifications/reengagement.js index 34670196..8ae901c3 100644 --- a/functions/dist/notifications/reengagement.js +++ b/functions/dist/notifications/reengagement.js @@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.sendReengagementReminder = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const quietHours_1 = require("./quietHours"); const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000; const REENGAGEMENT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; @@ -104,6 +105,17 @@ exports.sendReengagementReminder = functions.pubsub console.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`); }); async function sendNudge(db, messaging, userId, coupleId) { + const userDoc = await db.collection('users').doc(userId).get(); + const userData = userDoc.data(); + // Re-engagement is a promotional nudge — respect the opt-out (default on) and quiet hours. + if ((userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) { + console.log(`[sendReengagementReminder] skip ${userId} — promotional off`); + return; + } + if ((0, quietHours_1.recipientInQuietHours)(userData)) { + console.log(`[sendReengagementReminder] skip ${userId} — quiet hours`); + return; + } await db.collection('users').doc(userId).collection('notification_queue').add({ type: 'reengagement', title: "It's been a while.", @@ -111,7 +123,7 @@ async function sendNudge(db, messaging, userId, coupleId) { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - const tokens = await getUserTokens(db, userId); + const tokens = await getUserTokens(db, userId, userData); if (tokens.length === 0) return; await Promise.allSettled(tokens.map((token) => messaging.send({ @@ -127,11 +139,10 @@ async function sendNudge(db, messaging, userId, coupleId) { }, }))); } -async function getUserTokens(db, userId) { - var _a; +async function getUserTokens(db, userId, userData) { const tokens = []; - const userDoc = await db.collection('users').doc(userId).get(); - const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; + const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data(); + const legacy = data === null || data === void 0 ? void 0 : data.fcmToken; if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy); const snap = await db.collection('users').doc(userId).collection('fcmTokens').get(); diff --git a/functions/dist/notifications/reengagement.js.map b/functions/dist/notifications/reengagement.js.map index 2483697a..665931fb 100644 --- a/functions/dist/notifications/reengagement.js.map +++ b/functions/dist/notifications/reengagement.js.map @@ -1 +1 @@ -{"version":3,"file":"reengagement.js","sourceRoot":"","sources":["../../src/notifications/reengagement.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"reengagement.js","sourceRoot":"","sources":["../../src/notifications/reengagement.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,6CAAoD;AAEpD,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAC7C,MAAM,WAAW,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAC5C,MAAM,wBAAwB,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAExD;;;;;;;;;;GAUG;AACU,QAAA,wBAAwB,GAAG,SAAS,CAAC,MAAM;KACrD,QAAQ,CAAC,YAAY,CAAC;KACtB,QAAQ,CAAC,iBAAiB,CAAC;KAC3B,KAAK,CAAC,KAAK,IAAI,EAAE;IAChB,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IACnC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IACtB,MAAM,YAAY,GAAG,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,GAAG,aAAa,CAAC,CAAA;IAC9E,MAAM,UAAU,GAAG,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,GAAG,WAAW,CAAC,CAAA;IAE1E,MAAM,IAAI,GAAG,MAAM,EAAE;SAClB,UAAU,CAAC,SAAS,CAAC;SACrB,KAAK,CAAC,gBAAgB,EAAE,GAAG,EAAE,UAAU,CAAC;SACxC,KAAK,CAAC,gBAAgB,EAAE,GAAG,EAAE,YAAY,CAAC;SAC1C,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,EAAE,CAAA;IAER,IAAI,QAAQ,GAAG,CAAC,CAAA;IAChB,IAAI,OAAO,GAAG,CAAC,CAAA;IAEf,MAAM,OAAO,CAAC,GAAG,CACf,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE;;QAChC,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,EAAE,CAAA;QAC7B,MAAM,QAAQ,GAAG,SAAS,CAAC,EAAE,CAAA;QAC7B,MAAM,OAAO,GAAG,CAAC,MAAA,IAAI,CAAC,OAAO,mCAAI,EAAE,CAAa,CAAA;QAChD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAAC,OAAO,EAAE,CAAC;YAAC,OAAM;QAAC,CAAC;QAE/C,kDAAkD;QAClD,MAAM,MAAM,GAAG,IAAI,CAAC,kBAA2D,CAAA;QAC/E,IAAI,MAAM,IAAI,GAAG,GAAG,MAAM,CAAC,QAAQ,EAAE,GAAG,wBAAwB,EAAE,CAAC;YACjE,OAAO,EAAE,CAAA;YACT,OAAM;QACR,CAAC;QAED,uDAAuD;QACvD,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;;YACnD,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAA;YACzC,MAAM,WAAW,GAAG,MAAA,KAAK,CAAC,IAAI,EAAE,0CAAE,kBAA2D,CAAA;YAC7F,IAAI,WAAW,IAAI,GAAG,GAAG,WAAW,CAAC,QAAQ,EAAE,GAAG,wBAAwB;gBAAE,OAAO,KAAK,CAAA;YACxF,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,EAAE;gBACvB,kBAAkB,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;aACjE,CAAC,CAAA;YACF,OAAO,IAAI,CAAA;QACb,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,OAAO,EAAE,CAAC;YAAC,OAAO,EAAE,CAAC;YAAC,OAAM;QAAC,CAAC;QAEnC,MAAM,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAA;QAChF,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAA;IAC5B,CAAC,CAAC,CACH,CAAA;IAED,OAAO,CAAC,GAAG,CAAC,sCAAsC,IAAI,CAAC,IAAI,cAAc,QAAQ,aAAa,OAAO,EAAE,CAAC,CAAA;AAC1G,CAAC,CAAC,CAAA;AAEJ,KAAK,UAAU,SAAS,CACtB,EAA6B,EAC7B,SAAoC,EACpC,MAAc,EACd,QAAgB;IAEhB,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAA;IAC9D,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,EAAE,CAAA;IAE/B,2FAA2F;IAC3F,IAAI,CAAA,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAE,gBAAgB,MAAK,KAAK,EAAE,CAAC;QACzC,OAAO,CAAC,GAAG,CAAC,mCAAmC,MAAM,oBAAoB,CAAC,CAAA;QAC1E,OAAM;IACR,CAAC;IACD,IAAI,IAAA,kCAAqB,EAAC,QAAQ,CAAC,EAAE,CAAC;QACpC,OAAO,CAAC,GAAG,CAAC,mCAAmC,MAAM,gBAAgB,CAAC,CAAA;QACtE,OAAM;IACR,CAAC;IAED,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC5E,IAAI,EAAE,cAAc;QACpB,KAAK,EAAE,oBAAoB;QAC3B,IAAI,EAAE,mDAAmD;QACzD,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxD,CAAC,CAAA;IAEF,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAA;IACxD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/B,MAAM,OAAO,CAAC,UAAU,CACtB,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,SAAS,CAAC,IAAI,CAAC;QACb,KAAK;QACL,YAAY,EAAE;YACZ,KAAK,EAAE,oBAAoB;YAC3B,IAAI,EAAE,mDAAmD;SAC1D;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,WAAW,EAAE,EAAE,EAAE,QAAQ;QAC/D,IAAI,EAAE;YACJ,IAAI,EAAE,cAAc;YACpB,SAAS,EAAE,QAAQ;SACpB;KACF,CAAC,CACH,CACF,CAAA;AACH,CAAC;AAED,KAAK,UAAU,aAAa,CAC1B,EAA6B,EAC7B,MAAc,EACd,QAAuC;IAEvC,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,IAAI,GAAG,QAAQ,aAAR,QAAQ,cAAR,QAAQ,GAAI,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IAChF,MAAM,MAAM,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,QAAQ,CAAA;IAC7B,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IAExE,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IACnF,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QACxB,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;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IACF,OAAO,MAAM,CAAA;AACf,CAAC"} \ No newline at end of file diff --git a/functions/dist/notifications/streakReminder.js b/functions/dist/notifications/streakReminder.js new file mode 100644 index 00000000..425021c8 --- /dev/null +++ b/functions/dist/notifications/streakReminder.js @@ -0,0 +1,178 @@ +"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.sendStreakReminder = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +const quietHours_1 = require("./quietHours"); +/** + * Streak reminder — an evening "don't lose your streak" nudge. + * + * Schedule: 7 PM America/Chicago. For couples with an active streak (`streakCount > 0`) who have NOT + * recorded a shared action today, nudge each partner to do something together before the day ends. + * + * Gating: per-user `notifStreakReminder` toggle (default on) + quiet hours. Deduped per local day via a + * transactional `couples/{id}/streak_reminders/{dateKey}` marker (scheduled jobs can fire more than once). + * + * Known limitation: the "today" boundary uses America/Chicago (the cron's timezone), not each couple's + * local day — true per-timezone firing is a future refinement; quiet-hours suppression keeps a mistimed + * fire from landing at a bad local hour. (Streak day-boundary note in the plan.) + */ +exports.sendStreakReminder = functions.pubsub + .schedule('0 19 * * *') + .timeZone('America/Chicago') + .onRun(async () => { + const db = admin.firestore(); + const messaging = admin.messaging(); + const todayKey = chicagoDateKey(new Date()); + const coupleSnap = await db.collection('couples').where('streakCount', '>', 0).get(); + let notified = 0; + let skipped = 0; + const results = await Promise.allSettled(coupleSnap.docs.map(async (coupleDoc) => { + var _a, _b; + const couple = coupleDoc.data(); + const streak = ((_a = couple.streakCount) !== null && _a !== void 0 ? _a : 0); + if (streak <= 0) { + skipped++; + return; + } + // Already did a shared action today → the streak is safe, no nudge needed. + const lastMs = toMillis(couple.lastAnsweredAt); + if (lastMs > 0 && chicagoDateKey(new Date(lastMs)) === todayKey) { + skipped++; + return; + } + const userIds = ((_b = couple.userIds) !== null && _b !== void 0 ? _b : []); + if (userIds.length === 0) { + skipped++; + return; + } + // Per-day dedupe (transactional create-if-absent) — idempotent across re-runs. + const markerRef = coupleDoc.ref.collection('streak_reminders').doc(todayKey); + try { + await db.runTransaction(async (tx) => { + const fresh = await tx.get(markerRef); + if (fresh.exists) + throw new Error('already_sent'); + tx.set(markerRef, { sentAt: admin.firestore.FieldValue.serverTimestamp(), streak }); + }); + } + catch (_c) { + skipped++; + return; + } + await Promise.allSettled(userIds.map((userId) => sendStreakNudge(db, messaging, userId, coupleDoc.id, streak, todayKey))); + notified += userIds.length; + })); + results.forEach((r) => { + if (r.status === 'rejected') + console.warn('[sendStreakReminder] couple failed:', r.reason); + }); + console.log(`[sendStreakReminder] scanned ${coupleSnap.size} streak couples; notified ${notified}; skipped ${skipped}`); +}); +async function sendStreakNudge(db, messaging, userId, coupleId, streak, dateKey) { + const userDoc = await db.collection('users').doc(userId).get(); + const userData = userDoc.data(); + // Respect the user's Streak Reminder toggle (default on) and quiet hours. + if ((userData === null || userData === void 0 ? void 0 : userData.notifStreakReminder) === false) { + console.log(`[sendStreakReminder] skip ${userId} — streak reminder off`); + return; + } + if ((0, quietHours_1.recipientInQuietHours)(userData)) { + console.log(`[sendStreakReminder] skip ${userId} — quiet hours`); + return; + } + const title = `🔥 Keep your ${streak}-day streak`; + const body = "Answer tonight's question together before the day ends."; + await db + .collection('users') + .doc(userId) + .collection('notification_queue') + .add({ + type: 'streak', + title, + body, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + const tokens = await getUserTokens(db, userId, userData); + if (tokens.length === 0) + return; + const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({ + token, + notification: { title, body }, + android: { notification: { channelId: 'reminders' } }, + data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey }, + }))); + sendResults.forEach((result, i) => { + if (result.status === 'rejected') { + console.warn(`[sendStreakReminder] FCM failed for token ${tokens[i]}:`, result.reason); + } + }); +} +/** YYYY-MM-DD for the given instant in America/Chicago (DST-safe; en-CA gives ISO date order). */ +function chicagoDateKey(d) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Chicago', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(d); +} +function toMillis(value) { + if (value instanceof admin.firestore.Timestamp) + return value.toMillis(); + if (typeof value === 'number') + return value; + return 0; +} +async function getUserTokens(db, userId, userData) { + const tokens = []; + const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data(); + const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken; + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken); + } + const tokenSnap = await db.collection('users').doc(userId).collection('fcmTokens').get(); + tokenSnap.docs.forEach((doc) => { + var _a; + const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token; + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { + tokens.push(t); + } + }); + return tokens; +} +//# sourceMappingURL=streakReminder.js.map \ No newline at end of file diff --git a/functions/dist/notifications/streakReminder.js.map b/functions/dist/notifications/streakReminder.js.map new file mode 100644 index 00000000..ca91a8e4 --- /dev/null +++ b/functions/dist/notifications/streakReminder.js.map @@ -0,0 +1 @@ +{"version":3,"file":"streakReminder.js","sourceRoot":"","sources":["../../src/notifications/streakReminder.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 index 4a069bc7..365175e6 100644 --- a/functions/src/couples/scheduledOutcomesReminder.ts +++ b/functions/src/couples/scheduledOutcomesReminder.ts @@ -1,5 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { recipientInQuietHours } from '../notifications/quietHours' /** * Cloud Function: scheduledOutcomesReminder @@ -97,6 +98,15 @@ async function sendOutcomeReminder( messaging: admin.messaging.Messaging, notification: { userId: string; coupleId: string; day: number; title: string; body: string } ): Promise { + const userDoc = await db.collection('users').doc(notification.userId).get() + const userData = userDoc.data() + + // Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate). + if (recipientInQuietHours(userData)) { + console.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`) + return + } + await db .collection('users') .doc(notification.userId) @@ -111,7 +121,7 @@ async function sendOutcomeReminder( createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - const tokens = await getUserTokens(db, notification.userId) + const tokens = await getUserTokens(db, notification.userId, userData) if (tokens.length === 0) { console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`) return @@ -145,10 +155,14 @@ async function sendOutcomeReminder( }) } -async function getUserTokens(db: admin.firestore.Firestore, userId: string): Promise { +async function getUserTokens( + db: admin.firestore.Firestore, + userId: string, + userData?: admin.firestore.DocumentData +): Promise { const tokens: string[] = [] - const userDoc = await db.collection('users').doc(userId).get() - const legacyToken = userDoc.data()?.fcmToken + const data = userData ?? (await db.collection('users').doc(userId).get()).data() + const legacyToken = data?.fcmToken if (typeof legacyToken === 'string' && legacyToken.length > 0) { tokens.push(legacyToken) } diff --git a/functions/src/index.ts b/functions/src/index.ts index eecb4f45..4abbaaef 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -20,6 +20,7 @@ export { unlockDueMemoryCapsules, } from './notifications/gameRetention' export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder' +export { sendStreakReminder } from './notifications/streakReminder' export { sendReengagementReminder } from './notifications/reengagement' export { checkDeviceIntegrity } from './security/checkDeviceIntegrity' export { notifyOnDateMatch } from './dates/createDateMatch' diff --git a/functions/src/notifications/dailyQuestionReminder.ts b/functions/src/notifications/dailyQuestionReminder.ts index 8fb8c51e..57b8f321 100644 --- a/functions/src/notifications/dailyQuestionReminder.ts +++ b/functions/src/notifications/dailyQuestionReminder.ts @@ -1,5 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { recipientInQuietHours } from './quietHours' /** * Proactive daily question reminder. @@ -101,6 +102,19 @@ async function sendReminder( coupleId: string, questionDate: string ): Promise { + const userDoc = await db.collection('users').doc(userId).get() + const userData = userDoc.data() + + // Respect the user's Daily Reminder toggle (default on) and quiet hours. + if (userData?.notifDailyReminder === false) { + console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`) + return + } + if (recipientInQuietHours(userData)) { + console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`) + return + } + // In-app notification record. await db .collection('users') @@ -115,7 +129,7 @@ async function sendReminder( }) // FCM push. - const tokens = await getUserTokens(db, userId) + const tokens = await getUserTokens(db, userId, userData) if (tokens.length === 0) return const sendResults = await Promise.allSettled( @@ -148,11 +162,12 @@ async function sendReminder( async function getUserTokens( db: admin.firestore.Firestore, - userId: string + userId: string, + userData?: admin.firestore.DocumentData ): Promise { const tokens: string[] = [] - const userDoc = await db.collection('users').doc(userId).get() - const legacyToken = userDoc.data()?.fcmToken + const data = userData ?? (await db.collection('users').doc(userId).get()).data() + const legacyToken = data?.fcmToken if (typeof legacyToken === 'string' && legacyToken.length > 0) { tokens.push(legacyToken) } diff --git a/functions/src/notifications/gameRetention.ts b/functions/src/notifications/gameRetention.ts index e8e8af1f..882be3b4 100644 --- a/functions/src/notifications/gameRetention.ts +++ b/functions/src/notifications/gameRetention.ts @@ -1,5 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { recipientInQuietHours } from './quietHours' const DAY_MS = 24 * 60 * 60 * 1000 @@ -167,6 +168,21 @@ async function sendNotification( messaging: admin.messaging.Messaging, notification: QueuedNotification ): Promise { + const userDoc = await db.collection('users').doc(notification.userId).get() + const userData = userDoc.data() + + // Challenge-day reminders are retention nudges → respect the promotional opt-out (default on). + // (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.) + if (notification.type === 'challenge_day_ready' && userData?.notifPromotional === false) { + console.log(`[sendNotification] skip ${notification.userId} — promotional off`) + return + } + // Honor the recipient's quiet hours for every scheduled push. + if (recipientInQuietHours(userData)) { + console.log(`[sendNotification] skip ${notification.userId} — quiet hours`) + return + } + await db .collection('users') .doc(notification.userId) @@ -179,7 +195,7 @@ async function sendNotification( createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - const tokens = await getUserTokens(db, notification.userId) + const tokens = await getUserTokens(db, notification.userId, userData) if (tokens.length === 0) { console.log(`[sendNotification] no FCM tokens for ${notification.userId}`) return @@ -219,10 +235,14 @@ async function sendNotification( } } -async function getUserTokens(db: admin.firestore.Firestore, userId: string): Promise { +async function getUserTokens( + db: admin.firestore.Firestore, + userId: string, + userData?: admin.firestore.DocumentData +): Promise { const tokens: string[] = [] - const userDoc = await db.collection('users').doc(userId).get() - const legacyToken = userDoc.data()?.fcmToken + const data = userData ?? (await db.collection('users').doc(userId).get()).data() + const legacyToken = data?.fcmToken if (typeof legacyToken === 'string' && legacyToken.length > 0) { tokens.push(legacyToken) } diff --git a/functions/src/notifications/quietHours.test.ts b/functions/src/notifications/quietHours.test.ts new file mode 100644 index 00000000..dcd992bf --- /dev/null +++ b/functions/src/notifications/quietHours.test.ts @@ -0,0 +1,79 @@ +import { recipientInQuietHours } from './quietHours' + +// recipientInQuietHours is a pure function (Intl + plain userData), so no firebase-admin mock is needed. +// All times below are expressed in UTC and the userData timezone is 'UTC' for determinism. Minutes: +// 22:00 = 1320, 08:00 = 480, 10:00 = 600, 12:00 = 720. +const at = (iso: string) => new Date(`2026-01-15T${iso}:00Z`) + +describe('recipientInQuietHours', () => { + describe('fail-open guards', () => { + it('returns false when quiet hours is not explicitly enabled', () => { + expect(recipientInQuietHours(undefined, at('23:30'))).toBe(false) + expect(recipientInQuietHours({}, at('23:30'))).toBe(false) + expect( + recipientInQuietHours( + { quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'UTC' }, + at('23:30') + ) + ).toBe(false) + }) + + it('returns false when window or timezone fields are missing/malformed', () => { + const base = { quietHoursEnabled: true, timezone: 'UTC' } + expect(recipientInQuietHours({ ...base, quietHoursEndMinutes: 480 }, at('23:30'))).toBe(false) + expect(recipientInQuietHours({ ...base, quietHoursStartMinutes: 1320 }, at('23:30'))).toBe(false) + expect( + recipientInQuietHours( + { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480 }, + at('23:30') + ) + ).toBe(false) + }) + + it('returns false for an unknown timezone id', () => { + expect( + recipientInQuietHours( + { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'Not/AZone' }, + at('23:30') + ) + ).toBe(false) + }) + }) + + describe('overnight window (22:00 → 08:00)', () => { + const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'UTC' } + it('suppresses inside the window, including both boundaries', () => { + expect(recipientInQuietHours(qh, at('23:30'))).toBe(true) + expect(recipientInQuietHours(qh, at('03:00'))).toBe(true) + expect(recipientInQuietHours(qh, at('22:00'))).toBe(true) // start inclusive + expect(recipientInQuietHours(qh, at('08:00'))).toBe(true) // end inclusive + }) + it('does not suppress outside the window', () => { + expect(recipientInQuietHours(qh, at('08:01'))).toBe(false) + expect(recipientInQuietHours(qh, at('12:00'))).toBe(false) + expect(recipientInQuietHours(qh, at('21:59'))).toBe(false) + }) + }) + + describe('same-day window (10:00 → 12:00)', () => { + const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 600, quietHoursEndMinutes: 720, timezone: 'UTC' } + it('suppresses inside the window, including both boundaries', () => { + expect(recipientInQuietHours(qh, at('10:30'))).toBe(true) + expect(recipientInQuietHours(qh, at('10:00'))).toBe(true) + expect(recipientInQuietHours(qh, at('12:00'))).toBe(true) + }) + it('does not suppress outside the window', () => { + expect(recipientInQuietHours(qh, at('09:59'))).toBe(false) + expect(recipientInQuietHours(qh, at('12:01'))).toBe(false) + }) + }) + + describe('start == end is "no window"', () => { + it('never suppresses (matches the client QuietHoursManager)', () => { + const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 1320, timezone: 'UTC' } + expect(recipientInQuietHours(qh, at('22:00'))).toBe(false) + expect(recipientInQuietHours(qh, at('03:00'))).toBe(false) + expect(recipientInQuietHours(qh, at('12:00'))).toBe(false) + }) + }) +}) diff --git a/functions/src/notifications/quietHours.ts b/functions/src/notifications/quietHours.ts index aa08b8ac..89e5aa31 100644 --- a/functions/src/notifications/quietHours.ts +++ b/functions/src/notifications/quietHours.ts @@ -42,8 +42,12 @@ export function recipientInQuietHours( return false } + // Start == end means "no window" (nothing suppressed) — kept in lockstep with the client's + // QuietHoursManager.isInQuietHours so both decide identically. + if (start === end) return false + // Window may cross midnight (e.g. 22:00 → 08:00). - return start <= end + return start < end ? nowMinutes >= start && nowMinutes <= end : nowMinutes >= start || nowMinutes <= end } diff --git a/functions/src/notifications/reengagement.ts b/functions/src/notifications/reengagement.ts index c124f9e2..131916e1 100644 --- a/functions/src/notifications/reengagement.ts +++ b/functions/src/notifications/reengagement.ts @@ -1,5 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { recipientInQuietHours } from './quietHours' const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000 @@ -77,6 +78,19 @@ async function sendNudge( userId: string, coupleId: string ): Promise { + const userDoc = await db.collection('users').doc(userId).get() + const userData = userDoc.data() + + // Re-engagement is a promotional nudge — respect the opt-out (default on) and quiet hours. + if (userData?.notifPromotional === false) { + console.log(`[sendReengagementReminder] skip ${userId} — promotional off`) + return + } + if (recipientInQuietHours(userData)) { + console.log(`[sendReengagementReminder] skip ${userId} — quiet hours`) + return + } + await db.collection('users').doc(userId).collection('notification_queue').add({ type: 'reengagement', title: "It's been a while.", @@ -85,7 +99,7 @@ async function sendNudge( createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - const tokens = await getUserTokens(db, userId) + const tokens = await getUserTokens(db, userId, userData) if (tokens.length === 0) return await Promise.allSettled( @@ -108,11 +122,12 @@ async function sendNudge( async function getUserTokens( db: admin.firestore.Firestore, - userId: string + userId: string, + userData?: admin.firestore.DocumentData ): Promise { const tokens: string[] = [] - const userDoc = await db.collection('users').doc(userId).get() - const legacy = userDoc.data()?.fcmToken + const data = userData ?? (await db.collection('users').doc(userId).get()).data() + const legacy = data?.fcmToken if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) const snap = await db.collection('users').doc(userId).collection('fcmTokens').get() diff --git a/functions/src/notifications/streakReminder.ts b/functions/src/notifications/streakReminder.ts new file mode 100644 index 00000000..8430c596 --- /dev/null +++ b/functions/src/notifications/streakReminder.ts @@ -0,0 +1,160 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { recipientInQuietHours } from './quietHours' + +/** + * Streak reminder — an evening "don't lose your streak" nudge. + * + * Schedule: 7 PM America/Chicago. For couples with an active streak (`streakCount > 0`) who have NOT + * recorded a shared action today, nudge each partner to do something together before the day ends. + * + * Gating: per-user `notifStreakReminder` toggle (default on) + quiet hours. Deduped per local day via a + * transactional `couples/{id}/streak_reminders/{dateKey}` marker (scheduled jobs can fire more than once). + * + * Known limitation: the "today" boundary uses America/Chicago (the cron's timezone), not each couple's + * local day — true per-timezone firing is a future refinement; quiet-hours suppression keeps a mistimed + * fire from landing at a bad local hour. (Streak day-boundary note in the plan.) + */ +export const sendStreakReminder = functions.pubsub + .schedule('0 19 * * *') + .timeZone('America/Chicago') + .onRun(async () => { + const db = admin.firestore() + const messaging = admin.messaging() + const todayKey = chicagoDateKey(new Date()) + + const coupleSnap = await db.collection('couples').where('streakCount', '>', 0).get() + + let notified = 0 + let skipped = 0 + + const results = await Promise.allSettled( + coupleSnap.docs.map(async (coupleDoc) => { + const couple = coupleDoc.data() + const streak = (couple.streakCount ?? 0) as number + if (streak <= 0) { skipped++; return } + + // Already did a shared action today → the streak is safe, no nudge needed. + const lastMs = toMillis(couple.lastAnsweredAt) + if (lastMs > 0 && chicagoDateKey(new Date(lastMs)) === todayKey) { skipped++; return } + + const userIds = (couple.userIds ?? []) as string[] + if (userIds.length === 0) { skipped++; return } + + // Per-day dedupe (transactional create-if-absent) — idempotent across re-runs. + const markerRef = coupleDoc.ref.collection('streak_reminders').doc(todayKey) + try { + await db.runTransaction(async (tx) => { + const fresh = await tx.get(markerRef) + if (fresh.exists) throw new Error('already_sent') + tx.set(markerRef, { sentAt: admin.firestore.FieldValue.serverTimestamp(), streak }) + }) + } catch { skipped++; return } + + await Promise.allSettled( + userIds.map((userId) => sendStreakNudge(db, messaging, userId, coupleDoc.id, streak, todayKey)) + ) + notified += userIds.length + }) + ) + results.forEach((r) => { + if (r.status === 'rejected') console.warn('[sendStreakReminder] couple failed:', r.reason) + }) + + console.log( + `[sendStreakReminder] scanned ${coupleSnap.size} streak couples; notified ${notified}; skipped ${skipped}` + ) + }) + +async function sendStreakNudge( + db: admin.firestore.Firestore, + messaging: admin.messaging.Messaging, + userId: string, + coupleId: string, + streak: number, + dateKey: string +): Promise { + const userDoc = await db.collection('users').doc(userId).get() + const userData = userDoc.data() + + // Respect the user's Streak Reminder toggle (default on) and quiet hours. + if (userData?.notifStreakReminder === false) { + console.log(`[sendStreakReminder] skip ${userId} — streak reminder off`) + return + } + if (recipientInQuietHours(userData)) { + console.log(`[sendStreakReminder] skip ${userId} — quiet hours`) + return + } + + const title = `🔥 Keep your ${streak}-day streak` + const body = "Answer tonight's question together before the day ends." + + await db + .collection('users') + .doc(userId) + .collection('notification_queue') + .add({ + type: 'streak', + title, + body, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + const tokens = await getUserTokens(db, userId, userData) + if (tokens.length === 0) return + + const sendResults = await Promise.allSettled( + tokens.map((token) => + messaging.send({ + token, + notification: { title, body }, + android: { notification: { channelId: 'reminders' } }, + data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey }, + }) + ) + ) + sendResults.forEach((result, i) => { + if (result.status === 'rejected') { + console.warn(`[sendStreakReminder] FCM failed for token ${tokens[i]}:`, result.reason) + } + }) +} + +/** YYYY-MM-DD for the given instant in America/Chicago (DST-safe; en-CA gives ISO date order). */ +function chicagoDateKey(d: Date): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Chicago', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(d) +} + +function toMillis(value: unknown): number { + if (value instanceof admin.firestore.Timestamp) return value.toMillis() + if (typeof value === 'number') return value + return 0 +} + +async function getUserTokens( + db: admin.firestore.Firestore, + userId: string, + userData?: admin.firestore.DocumentData +): Promise { + const tokens: string[] = [] + const data = userData ?? (await db.collection('users').doc(userId).get()).data() + const legacyToken = data?.fcmToken + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken) + } + const tokenSnap = await db.collection('users').doc(userId).collection('fcmTokens').get() + tokenSnap.docs.forEach((doc) => { + const t = doc.data()?.token + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { + tokens.push(t) + } + }) + return tokens +} diff --git a/scripts/wiring-scan.sh b/scripts/wiring-scan.sh index db257af1..1f3d244a 100755 --- a/scripts/wiring-scan.sh +++ b/scripts/wiring-scan.sh @@ -38,7 +38,7 @@ SCAN_OUTPUT="${1:-/tmp/claude-wiring-scan-$(date +%Y%m%d).md}" log() { echo "$1" | tee -a "$SCAN_OUTPUT"; } -crit=0; major=0; review=0 +crit=0; major=0; review=0; notifdead=0 log "# Wiring / dead-feature scan — $(date '+%Y-%m-%d %H:%M')" log "" @@ -97,6 +97,30 @@ done < <(grep -rEn 'if \([a-zA-Z0-9_.]+\.(isEmpty\(\)|isBlank\(\))\) return|[a-z [ "$review" -eq 0 ] && log "- none" log "" +# ── Tier 4: dead / unenforced notification settings (N-Notif class) ─────────── +log "## 🔴 CRITICAL — notification pref written to users/{uid} but read by NO Cloud Function" +log "" +log "A toggle whose mirrored field no function reads is a DEAD setting — the Daily-Reminder /" +log "Streak-Reminder class this check was added for: the UI flips it, it reaches Firestore, and" +log "nothing server-side enforces it. Every \`notif*\` field written in FirestoreUserDataSource MUST" +log "be read by at least one sender under functions/src (gate \`=== false\` / \`!== false\`)." +log "" +FUNCTIONS_SRC="$PROJECT_ROOT/functions/src" +USER_DS="$SRC_DIR/data/remote/FirestoreUserDataSource.kt" +if [ -d "$FUNCTIONS_SRC" ] && [ -f "$USER_DS" ]; then + for field in $(grep -oE '"notif[A-Za-z0-9]+"' "$USER_DS" 2>/dev/null | tr -d '"' | sort -u); do + readers="$(grep -rEl "\\b${field}\\b" "$FUNCTIONS_SRC" 2>/dev/null | grep -c .)" + if [ "${readers:-0}" -eq 0 ]; then + log "- 🔴 \`${field}\` — mirrored to users/{uid} but **no functions/src reader** → dead toggle" + notifdead=$((notifdead+1)) + fi + done + [ "$notifdead" -eq 0 ] && log "- none ✅" +else + log "- (skipped — functions/src or FirestoreUserDataSource.kt not found)" +fi +log "" + log "## Summary" log "" log "| Tier | Count |" @@ -104,8 +128,9 @@ log "|---|---|" log "| 🔴 CRITICAL (dead setters) | $crit |" log "| 🟠 MAJOR (orphan readers) | $major |" log "| 🟡 REVIEW (bail-guards) | $review |" +log "| 🔴 dead notif settings | $notifdead |" log "" log "_Record these counts in ClaudeQACoverage.md under Pass N before driving the interactive features._" -# Exit non-zero only on CRITICAL so CI/automation can gate on it. -[ "$crit" -gt 0 ] && exit 1 || exit 0 +# Exit non-zero on any CRITICAL class so CI/automation can gate on it. +{ [ "$crit" -gt 0 ] || [ "$notifdead" -gt 0 ]; } && exit 1 || exit 0