feat(notifications): QuietHoursManager + NotificationSettingsScreen rewrite, Cloud Functions (streakReminder, quietHours, reengagement, gameRetention), UserRepository E2EE wiring, SettingsDataStore, firestore rules, wiring-scan
This commit is contained in:
parent
7b1443e578
commit
2a5c40508e
|
|
@ -87,3 +87,4 @@ docs/brand/exports/
|
|||
scratchpad/
|
||||
SECURITY.md
|
||||
Future.md
|
||||
docs/strategy/positioning-vs-paired.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-<date>.md`,
|
||||
> record counts in `ClaudeQACoverage.md`). Every 🔴 dead-setter / 🟠 orphan-reader is a likely silent dead feature —
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 & 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 ─────────────────────────────────────────── -->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"quietHours.test.js","sourceRoot":"","sources":["../../src/notifications/quietHours.test.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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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"}
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue