feat(notifications): QuietHoursManager + NotificationSettingsScreen rewrite, Cloud Functions (streakReminder, quietHours, reengagement, gameRetention), UserRepository E2EE wiring, SettingsDataStore, firestore rules, wiring-scan

This commit is contained in:
null 2026-06-30 00:38:06 -05:00
parent 7b1443e578
commit 2a5c40508e
38 changed files with 1020 additions and 80 deletions

1
.gitignore vendored
View File

@ -87,3 +87,4 @@ docs/brand/exports/
scratchpad/
SECURITY.md
Future.md
docs/strategy/positioning-vs-paired.md

View File

@ -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-<date>.md`,
> record counts in `ClaudeQACoverage.md`). Every 🔴 dead-setter / 🟠 orphan-reader is a likely silent dead feature —

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}
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.
/** 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()
}
private fun syncNotifPrefs(partnerAnswered: Boolean, chatMessage: Boolean) {
init {
// 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()
}
/** 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() {

View File

@ -46,7 +46,15 @@
<string name="notifications_chat_message">New chat message</string>
<string name="notifications_streak_reminder">Shared rhythm reminder</string>
<string name="notifications_quiet_hours">Quiet hours</string>
<string name="notifications_quiet_hours_desc">10 PM 8 AM, no notifications</string>
<string name="notifications_quiet_hours_desc">Silence notifications during set hours</string>
<string name="notifications_quiet_hours_window">Silenced %1$s %2$s</string>
<string name="notifications_quiet_start">Start</string>
<string name="notifications_quiet_end">End</string>
<string name="notifications_promotional">Tips &amp; nudges</string>
<string name="notifications_promotional_desc">Occasional ideas to reconnect — you can turn these off anytime</string>
<string name="notifications_off_title">Notifications are turned off</string>
<string name="notifications_off_body">Turn them on in system settings to get these.</string>
<string name="notifications_open_settings">Open settings</string>
<string name="notifications_footer">Your notification preferences are yours alone. Your partner cannot see or change them.</string>
<!-- ── Account screen ─────────────────────────────────────────── -->

View File

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

View File

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

View File

@ -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'
]);

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -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<void> {
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<string[]> {
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string,
userData?: admin.firestore.DocumentData
): Promise<string[]> {
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)
}

View File

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

View File

@ -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<void> {
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<string[]> {
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)
}

View File

@ -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<void> {
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<string[]> {
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string,
userData?: admin.firestore.DocumentData
): Promise<string[]> {
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)
}

View File

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

View File

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

View File

@ -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<void> {
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<string[]> {
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()

View File

@ -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<void> {
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<string[]> {
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
}

View File

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