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/
|
scratchpad/
|
||||||
SECURITY.md
|
SECURITY.md
|
||||||
Future.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).
|
still no private content in any event (D6).
|
||||||
- Every toggle survives **process death + reinstall-with-data** (overlaps F).
|
- 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
|
### 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`,
|
> **⛔ 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 —
|
> 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 PARTNER_ANSWERED = booleanPreferencesKey("partner_answered")
|
||||||
private val CHAT_MESSAGE = booleanPreferencesKey("chat_message")
|
private val CHAT_MESSAGE = booleanPreferencesKey("chat_message")
|
||||||
private val STREAK_REMINDER = booleanPreferencesKey("streak_reminder")
|
private val STREAK_REMINDER = booleanPreferencesKey("streak_reminder")
|
||||||
|
private val PROMOTIONAL = booleanPreferencesKey("promotional_notifications")
|
||||||
private val QUIET_HOURS = booleanPreferencesKey("quiet_hours")
|
private val QUIET_HOURS = booleanPreferencesKey("quiet_hours")
|
||||||
private val QUIET_HOURS_START_HOUR = intPreferencesKey("quiet_hours_start_hour")
|
private val QUIET_HOURS_START_HOUR = intPreferencesKey("quiet_hours_start_hour")
|
||||||
private val QUIET_HOURS_START_MINUTE = intPreferencesKey("quiet_hours_start_minute")
|
private val QUIET_HOURS_START_MINUTE = intPreferencesKey("quiet_hours_start_minute")
|
||||||
|
|
@ -46,6 +47,7 @@ class SettingsDataStore @Inject constructor(
|
||||||
partnerAnsweredEnabled = prefs[PARTNER_ANSWERED] ?: true,
|
partnerAnsweredEnabled = prefs[PARTNER_ANSWERED] ?: true,
|
||||||
chatMessageEnabled = prefs[CHAT_MESSAGE] ?: true,
|
chatMessageEnabled = prefs[CHAT_MESSAGE] ?: true,
|
||||||
streakReminderEnabled = prefs[STREAK_REMINDER] ?: false,
|
streakReminderEnabled = prefs[STREAK_REMINDER] ?: false,
|
||||||
|
promotionalEnabled = prefs[PROMOTIONAL] ?: true,
|
||||||
quietHoursEnabled = prefs[QUIET_HOURS] ?: false,
|
quietHoursEnabled = prefs[QUIET_HOURS] ?: false,
|
||||||
quietHours = QuietHours(
|
quietHours = QuietHours(
|
||||||
enabled = prefs[QUIET_HOURS] ?: false,
|
enabled = prefs[QUIET_HOURS] ?: false,
|
||||||
|
|
@ -89,6 +91,9 @@ class SettingsDataStore @Inject constructor(
|
||||||
override suspend fun setStreakReminder(enabled: Boolean) =
|
override suspend fun setStreakReminder(enabled: Boolean) =
|
||||||
dataStore.edit { it[STREAK_REMINDER] = enabled }.let {}
|
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) =
|
override suspend fun setQuietHours(enabled: Boolean) =
|
||||||
dataStore.edit { it[QUIET_HOURS] = enabled }.let {}
|
dataStore.edit { it[QUIET_HOURS] = enabled }.let {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,12 +151,18 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest
|
||||||
suspend fun updateNotificationPrefs(
|
suspend fun updateNotificationPrefs(
|
||||||
uid: String,
|
uid: String,
|
||||||
partnerAnswered: Boolean,
|
partnerAnswered: Boolean,
|
||||||
chatMessage: Boolean
|
chatMessage: Boolean,
|
||||||
|
dailyReminder: Boolean,
|
||||||
|
streakReminder: Boolean,
|
||||||
|
promotional: Boolean
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
userRef(uid).set(
|
userRef(uid).set(
|
||||||
mapOf(
|
mapOf(
|
||||||
"notifPartnerAnswered" to partnerAnswered,
|
"notifPartnerAnswered" to partnerAnswered,
|
||||||
"notifChatMessage" to chatMessage
|
"notifChatMessage" to chatMessage,
|
||||||
|
"notifDailyReminder" to dailyReminder,
|
||||||
|
"notifStreakReminder" to streakReminder,
|
||||||
|
"notifPromotional" to promotional
|
||||||
),
|
),
|
||||||
SetOptions.merge()
|
SetOptions.merge()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,16 @@ class UserRepositoryImpl @Inject constructor(
|
||||||
metadata: TokenRegistrar.DeviceMetadata
|
metadata: TokenRegistrar.DeviceMetadata
|
||||||
) = dataSource.storeTokenMetadata(uid, token, metadata)
|
) = dataSource.storeTokenMetadata(uid, token, metadata)
|
||||||
|
|
||||||
override suspend fun updateNotificationPrefs(uid: String, partnerAnswered: Boolean, chatMessage: Boolean) =
|
override suspend fun updateNotificationPrefs(
|
||||||
dataSource.updateNotificationPrefs(uid, partnerAnswered, chatMessage)
|
uid: String,
|
||||||
|
partnerAnswered: Boolean,
|
||||||
|
chatMessage: Boolean,
|
||||||
|
dailyReminder: Boolean,
|
||||||
|
streakReminder: Boolean,
|
||||||
|
promotional: Boolean
|
||||||
|
) = dataSource.updateNotificationPrefs(
|
||||||
|
uid, partnerAnswered, chatMessage, dailyReminder, streakReminder, promotional
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun updateQuietHours(
|
override suspend fun updateQuietHours(
|
||||||
uid: String,
|
uid: String,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ data class AppSettings(
|
||||||
val partnerAnsweredEnabled: Boolean = true,
|
val partnerAnsweredEnabled: Boolean = true,
|
||||||
val chatMessageEnabled: Boolean = true,
|
val chatMessageEnabled: Boolean = true,
|
||||||
val streakReminderEnabled: Boolean = false,
|
val streakReminderEnabled: Boolean = false,
|
||||||
|
/** Non-essential nudges (re-engagement + retention). Opt-out; default on. */
|
||||||
|
val promotionalEnabled: Boolean = true,
|
||||||
val quietHoursEnabled: Boolean = false,
|
val quietHoursEnabled: Boolean = false,
|
||||||
val quietHours: QuietHours = QuietHours(),
|
val quietHours: QuietHours = QuietHours(),
|
||||||
val onboardingComplete: Boolean = false,
|
val onboardingComplete: Boolean = false,
|
||||||
|
|
@ -43,6 +45,7 @@ interface SettingsRepository {
|
||||||
suspend fun setPartnerAnswered(enabled: Boolean)
|
suspend fun setPartnerAnswered(enabled: Boolean)
|
||||||
suspend fun setChatMessage(enabled: Boolean)
|
suspend fun setChatMessage(enabled: Boolean)
|
||||||
suspend fun setStreakReminder(enabled: Boolean)
|
suspend fun setStreakReminder(enabled: Boolean)
|
||||||
|
suspend fun setPromotional(enabled: Boolean)
|
||||||
suspend fun setQuietHours(enabled: Boolean)
|
suspend fun setQuietHours(enabled: Boolean)
|
||||||
suspend fun setQuietHours(quietHours: QuietHours)
|
suspend fun setQuietHours(quietHours: QuietHours)
|
||||||
suspend fun setOnboardingComplete(complete: Boolean)
|
suspend fun setOnboardingComplete(complete: Boolean)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,14 @@ interface UserRepository {
|
||||||
suspend fun hasProfile(uid: String): Boolean
|
suspend fun hasProfile(uid: String): Boolean
|
||||||
suspend fun storeFcmToken(uid: String, token: String)
|
suspend fun storeFcmToken(uid: String, token: String)
|
||||||
suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata)
|
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 updateQuietHours(uid: String, enabled: Boolean, startMinutes: Int, endMinutes: Int, timezone: String)
|
||||||
suspend fun clearCoupleId(uid: String)
|
suspend fun clearCoupleId(uid: String)
|
||||||
suspend fun deleteUserData(uid: String)
|
suspend fun deleteUserData(uid: String)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,11 @@ class QuietHoursManager {
|
||||||
val startMinutes = quietHours.startHour * 60 + quietHours.startMinute
|
val startMinutes = quietHours.startHour * 60 + quietHours.startMinute
|
||||||
val endMinutes = quietHours.endHour * 60 + quietHours.endMinute
|
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
|
currentMinutes in startMinutes..endMinutes
|
||||||
} else {
|
} else {
|
||||||
currentMinutes >= startMinutes || currentMinutes <= endMinutes
|
currentMinutes >= startMinutes || currentMinutes <= endMinutes
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer.ui.settings
|
package app.closer.ui.settings
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.domain.repository.AuthRepository
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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.R
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
||||||
|
|
@ -60,7 +80,12 @@ data class NotificationSettingsUiState(
|
||||||
val partnerAnsweredEnabled: Boolean = true,
|
val partnerAnsweredEnabled: Boolean = true,
|
||||||
val chatMessageEnabled: Boolean = true,
|
val chatMessageEnabled: Boolean = true,
|
||||||
val streakReminderEnabled: Boolean = false,
|
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
|
@HiltViewModel
|
||||||
|
|
@ -77,27 +102,39 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||||
partnerAnsweredEnabled = s.partnerAnsweredEnabled,
|
partnerAnsweredEnabled = s.partnerAnsweredEnabled,
|
||||||
chatMessageEnabled = s.chatMessageEnabled,
|
chatMessageEnabled = s.chatMessageEnabled,
|
||||||
streakReminderEnabled = s.streakReminderEnabled,
|
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())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NotificationSettingsUiState())
|
||||||
|
|
||||||
fun toggleDailyReminder(on: Boolean) = viewModelScope.launch {
|
fun toggleDailyReminder(on: Boolean) = viewModelScope.launch {
|
||||||
settingsRepository.setDailyReminder(on)
|
settingsRepository.setDailyReminder(on)
|
||||||
|
syncNotifPrefs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePartnerAnswered(on: Boolean) = viewModelScope.launch {
|
fun togglePartnerAnswered(on: Boolean) = viewModelScope.launch {
|
||||||
settingsRepository.setPartnerAnswered(on)
|
settingsRepository.setPartnerAnswered(on)
|
||||||
syncNotifPrefs(partnerAnswered = on, chatMessage = uiState.value.chatMessageEnabled)
|
syncNotifPrefs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleChatMessage(on: Boolean) = viewModelScope.launch {
|
fun toggleChatMessage(on: Boolean) = viewModelScope.launch {
|
||||||
settingsRepository.setChatMessage(on)
|
settingsRepository.setChatMessage(on)
|
||||||
syncNotifPrefs(partnerAnswered = uiState.value.partnerAnsweredEnabled, chatMessage = on)
|
syncNotifPrefs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleStreakReminder(on: Boolean) = viewModelScope.launch {
|
fun toggleStreakReminder(on: Boolean) = viewModelScope.launch {
|
||||||
settingsRepository.setStreakReminder(on)
|
settingsRepository.setStreakReminder(on)
|
||||||
|
syncNotifPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePromotional(on: Boolean) = viewModelScope.launch {
|
||||||
|
settingsRepository.setPromotional(on)
|
||||||
|
syncNotifPrefs()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleQuietHours(on: Boolean) = viewModelScope.launch {
|
fun toggleQuietHours(on: Boolean) = viewModelScope.launch {
|
||||||
|
|
@ -105,17 +142,43 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||||
syncQuietHours()
|
syncQuietHours()
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
/** Set a custom quiet-hours window (keeping the current on/off) and mirror it to the server. */
|
||||||
// Backfill the recipient-side quiet-hours window/timezone to Firestore so the server can
|
fun setQuietHoursWindow(startHour: Int, startMinute: Int, endHour: Int, endMinute: Int) =
|
||||||
// honor it for backgrounded/killed delivery (M-001) — covers users who enabled quiet hours
|
viewModelScope.launch {
|
||||||
// before this build, the next time they open Notification settings.
|
val current = settingsRepository.settings.first().quietHours
|
||||||
|
settingsRepository.setQuietHours(
|
||||||
|
current.copy(
|
||||||
|
startHour = startHour,
|
||||||
|
startMinute = startMinute,
|
||||||
|
endHour = endHour,
|
||||||
|
endMinute = endMinute
|
||||||
|
)
|
||||||
|
)
|
||||||
syncQuietHours()
|
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
|
val uid = authRepository.currentUserId ?: return
|
||||||
viewModelScope.launch {
|
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,
|
endMinutes = qh.endHour * 60 + qh.endMinute,
|
||||||
timezone = TimeZone.getDefault().id
|
timezone = TimeZone.getDefault().id
|
||||||
)
|
)
|
||||||
|
}.onFailure { Log.w(TAG, "syncQuietHours failed", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private companion object { const val TAG = "NotifSettingsVM" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -174,6 +239,8 @@ fun NotificationSettingsScreen(
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
NotificationsOffBanner()
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.notifications_reminders_section),
|
text = stringResource(R.string.notifications_reminders_section),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
|
@ -214,25 +281,23 @@ fun NotificationSettingsScreen(
|
||||||
checked = state.streakReminderEnabled,
|
checked = state.streakReminderEnabled,
|
||||||
onCheckedChange = viewModel::toggleStreakReminder
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
Card(
|
QuietHoursSection(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
state = state,
|
||||||
shape = RoundedCornerShape(16.dp),
|
onToggle = viewModel::toggleQuietHours,
|
||||||
colors = CardDefaults.cardColors(containerColor = SettingsCard)
|
onSetWindow = viewModel::setQuietHoursWindow
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
NotifToggleRow(
|
|
||||||
label = stringResource(R.string.notifications_quiet_hours),
|
|
||||||
description = stringResource(R.string.notifications_quiet_hours_desc),
|
|
||||||
checked = state.quietHoursEnabled,
|
|
||||||
onCheckedChange = viewModel::toggleQuietHours
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationSettingsScreenPreview() {
|
fun NotificationSettingsScreenPreview() {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,15 @@
|
||||||
<string name="notifications_chat_message">New chat message</string>
|
<string name="notifications_chat_message">New chat message</string>
|
||||||
<string name="notifications_streak_reminder">Shared rhythm reminder</string>
|
<string name="notifications_streak_reminder">Shared rhythm reminder</string>
|
||||||
<string name="notifications_quiet_hours">Quiet hours</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>
|
<string name="notifications_footer">Your notification preferences are yours alone. Your partner cannot see or change them.</string>
|
||||||
|
|
||||||
<!-- ── Account screen ─────────────────────────────────────────── -->
|
<!-- ── Account screen ─────────────────────────────────────────── -->
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,23 @@ class QuietHoursManagerTest {
|
||||||
assertFalse(manager.isInQuietHours(quietHours, calendarAt(8, 31)))
|
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 {
|
private fun calendarAt(hour: Int, minute: Int): Calendar {
|
||||||
return Calendar.getInstance().apply {
|
return Calendar.getInstance().apply {
|
||||||
set(Calendar.HOUR_OF_DAY, hour)
|
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)
|
### 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.
|
**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.
|
**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
|
### 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.
|
**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',
|
'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId',
|
||||||
'plan', 'createdAt', 'lastActiveAt', 'fcmToken',
|
'plan', 'createdAt', 'lastActiveAt', 'fcmToken',
|
||||||
'notifPartnerAnswered', 'notifChatMessage',
|
'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.
|
// M-001: quiet-hours window mirrored for server-side push suppression.
|
||||||
'quietHoursEnabled', 'quietHoursStartMinutes', 'quietHoursEndMinutes', 'timezone'
|
'quietHoursEnabled', 'quietHoursStartMinutes', 'quietHoursEndMinutes', 'timezone'
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.scheduledOutcomesReminder = void 0;
|
exports.scheduledOutcomesReminder = void 0;
|
||||||
const functions = __importStar(require("firebase-functions"));
|
const functions = __importStar(require("firebase-functions"));
|
||||||
const admin = __importStar(require("firebase-admin"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
const quietHours_1 = require("../notifications/quietHours");
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const REMINDER_DAYS = [30, 60, 90];
|
const REMINDER_DAYS = [30, 60, 90];
|
||||||
const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' };
|
const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' };
|
||||||
|
|
@ -95,6 +96,13 @@ function millisFromFirestoreValue(value) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
async function sendOutcomeReminder(db, messaging, notification) {
|
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
|
await db
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(notification.userId)
|
.doc(notification.userId)
|
||||||
|
|
@ -108,7 +116,7 @@ async function sendOutcomeReminder(db, messaging, notification) {
|
||||||
read: false,
|
read: false,
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
const tokens = await getUserTokens(db, notification.userId);
|
const tokens = await getUserTokens(db, notification.userId, userData);
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`);
|
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -133,11 +141,10 @@ async function sendOutcomeReminder(db, messaging, notification) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function getUserTokens(db, userId) {
|
async function getUserTokens(db, userId, userData) {
|
||||||
var _a;
|
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
const userDoc = await db.collection('users').doc(userId).get();
|
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
|
||||||
const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
|
const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken;
|
||||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
tokens.push(legacyToken);
|
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 });
|
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"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
// Initialize the Admin SDK once for every function in this codebase.
|
// Initialize the Admin SDK once for every function in this codebase.
|
||||||
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a
|
// 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; } });
|
Object.defineProperty(exports, "unlockDueMemoryCapsules", { enumerable: true, get: function () { return gameRetention_1.unlockDueMemoryCapsules; } });
|
||||||
var dailyQuestionReminder_1 = require("./notifications/dailyQuestionReminder");
|
var dailyQuestionReminder_1 = require("./notifications/dailyQuestionReminder");
|
||||||
Object.defineProperty(exports, "sendDailyQuestionProactiveReminder", { enumerable: true, get: function () { return dailyQuestionReminder_1.sendDailyQuestionProactiveReminder; } });
|
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");
|
var reengagement_1 = require("./notifications/reengagement");
|
||||||
Object.defineProperty(exports, "sendReengagementReminder", { enumerable: true, get: function () { return reengagement_1.sendReengagementReminder; } });
|
Object.defineProperty(exports, "sendReengagementReminder", { enumerable: true, get: function () { return reengagement_1.sendReengagementReminder; } });
|
||||||
var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity");
|
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;
|
exports.sendDailyQuestionProactiveReminder = void 0;
|
||||||
const functions = __importStar(require("firebase-functions"));
|
const functions = __importStar(require("firebase-functions"));
|
||||||
const admin = __importStar(require("firebase-admin"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
const quietHours_1 = require("./quietHours");
|
||||||
/**
|
/**
|
||||||
* Proactive daily question reminder.
|
* Proactive daily question reminder.
|
||||||
*
|
*
|
||||||
|
|
@ -126,6 +127,17 @@ exports.sendDailyQuestionProactiveReminder = functions.pubsub
|
||||||
console.log(`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`);
|
console.log(`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`);
|
||||||
});
|
});
|
||||||
async function sendReminder(db, messaging, userId, coupleId, questionDate) {
|
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.
|
// In-app notification record.
|
||||||
await db
|
await db
|
||||||
.collection('users')
|
.collection('users')
|
||||||
|
|
@ -139,7 +151,7 @@ async function sendReminder(db, messaging, userId, coupleId, questionDate) {
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
// FCM push.
|
// FCM push.
|
||||||
const tokens = await getUserTokens(db, userId);
|
const tokens = await getUserTokens(db, userId, userData);
|
||||||
if (tokens.length === 0)
|
if (tokens.length === 0)
|
||||||
return;
|
return;
|
||||||
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({
|
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) {
|
async function getUserTokens(db, userId, userData) {
|
||||||
var _a;
|
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
const userDoc = await db.collection('users').doc(userId).get();
|
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
|
||||||
const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
|
const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken;
|
||||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
tokens.push(legacyToken);
|
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;
|
exports.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0;
|
||||||
const functions = __importStar(require("firebase-functions"));
|
const functions = __importStar(require("firebase-functions"));
|
||||||
const admin = __importStar(require("firebase-admin"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
const quietHours_1 = require("./quietHours");
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const CHALLENGE_TITLES = {
|
const CHALLENGE_TITLES = {
|
||||||
gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
|
gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
|
||||||
|
|
@ -171,6 +172,19 @@ function reminderKey(userId, day) {
|
||||||
return `${userId.replace(/[^\w-]/g, '_')}_${day}`;
|
return `${userId.replace(/[^\w-]/g, '_')}_${day}`;
|
||||||
}
|
}
|
||||||
async function sendNotification(db, messaging, notification) {
|
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
|
await db
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(notification.userId)
|
.doc(notification.userId)
|
||||||
|
|
@ -182,7 +196,7 @@ async function sendNotification(db, messaging, notification) {
|
||||||
read: false,
|
read: false,
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
const tokens = await getUserTokens(db, notification.userId);
|
const tokens = await getUserTokens(db, notification.userId, userData);
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`);
|
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -212,11 +226,10 @@ async function sendNotification(db, messaging, notification) {
|
||||||
console.error(`[sendNotification] some notifications failed:`, failures);
|
console.error(`[sendNotification] some notifications failed:`, failures);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function getUserTokens(db, userId) {
|
async function getUserTokens(db, userId, userData) {
|
||||||
var _a;
|
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
const userDoc = await db.collection('users').doc(userId).get();
|
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
|
||||||
const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
|
const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken;
|
||||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
tokens.push(legacyToken);
|
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.
|
// Unknown/invalid timezone id → fail open.
|
||||||
return false;
|
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).
|
// Window may cross midnight (e.g. 22:00 → 08:00).
|
||||||
return start <= end
|
return start < end
|
||||||
? nowMinutes >= start && nowMinutes <= end
|
? nowMinutes >= start && nowMinutes <= 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;
|
exports.sendReengagementReminder = void 0;
|
||||||
const functions = __importStar(require("firebase-functions"));
|
const functions = __importStar(require("firebase-functions"));
|
||||||
const admin = __importStar(require("firebase-admin"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
|
const quietHours_1 = require("./quietHours");
|
||||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000;
|
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000;
|
||||||
const REENGAGEMENT_COOLDOWN_MS = 3 * 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}`);
|
console.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`);
|
||||||
});
|
});
|
||||||
async function sendNudge(db, messaging, userId, coupleId) {
|
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({
|
await db.collection('users').doc(userId).collection('notification_queue').add({
|
||||||
type: 'reengagement',
|
type: 'reengagement',
|
||||||
title: "It's been a while.",
|
title: "It's been a while.",
|
||||||
|
|
@ -111,7 +123,7 @@ async function sendNudge(db, messaging, userId, coupleId) {
|
||||||
read: false,
|
read: false,
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
const tokens = await getUserTokens(db, userId);
|
const tokens = await getUserTokens(db, userId, userData);
|
||||||
if (tokens.length === 0)
|
if (tokens.length === 0)
|
||||||
return;
|
return;
|
||||||
await Promise.allSettled(tokens.map((token) => messaging.send({
|
await Promise.allSettled(tokens.map((token) => messaging.send({
|
||||||
|
|
@ -127,11 +139,10 @@ async function sendNudge(db, messaging, userId, coupleId) {
|
||||||
},
|
},
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
async function getUserTokens(db, userId) {
|
async function getUserTokens(db, userId, userData) {
|
||||||
var _a;
|
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
const userDoc = await db.collection('users').doc(userId).get();
|
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
|
||||||
const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
|
const legacy = data === null || data === void 0 ? void 0 : data.fcmToken;
|
||||||
if (typeof legacy === 'string' && legacy.length > 0)
|
if (typeof legacy === 'string' && legacy.length > 0)
|
||||||
tokens.push(legacy);
|
tokens.push(legacy);
|
||||||
const snap = await db.collection('users').doc(userId).collection('fcmTokens').get();
|
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 functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { recipientInQuietHours } from '../notifications/quietHours'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cloud Function: scheduledOutcomesReminder
|
* Cloud Function: scheduledOutcomesReminder
|
||||||
|
|
@ -97,6 +98,15 @@ async function sendOutcomeReminder(
|
||||||
messaging: admin.messaging.Messaging,
|
messaging: admin.messaging.Messaging,
|
||||||
notification: { userId: string; coupleId: string; day: number; title: string; body: string }
|
notification: { userId: string; coupleId: string; day: number; title: string; body: string }
|
||||||
): Promise<void> {
|
): 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
|
await db
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(notification.userId)
|
.doc(notification.userId)
|
||||||
|
|
@ -111,7 +121,7 @@ async function sendOutcomeReminder(
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const tokens = await getUserTokens(db, notification.userId)
|
const tokens = await getUserTokens(db, notification.userId, userData)
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`)
|
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`)
|
||||||
return
|
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 tokens: string[] = []
|
||||||
const userDoc = await db.collection('users').doc(userId).get()
|
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
|
||||||
const legacyToken = userDoc.data()?.fcmToken
|
const legacyToken = data?.fcmToken
|
||||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
tokens.push(legacyToken)
|
tokens.push(legacyToken)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export {
|
||||||
unlockDueMemoryCapsules,
|
unlockDueMemoryCapsules,
|
||||||
} from './notifications/gameRetention'
|
} from './notifications/gameRetention'
|
||||||
export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder'
|
export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder'
|
||||||
|
export { sendStreakReminder } from './notifications/streakReminder'
|
||||||
export { sendReengagementReminder } from './notifications/reengagement'
|
export { sendReengagementReminder } from './notifications/reengagement'
|
||||||
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
||||||
export { notifyOnDateMatch } from './dates/createDateMatch'
|
export { notifyOnDateMatch } from './dates/createDateMatch'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { recipientInQuietHours } from './quietHours'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proactive daily question reminder.
|
* Proactive daily question reminder.
|
||||||
|
|
@ -101,6 +102,19 @@ async function sendReminder(
|
||||||
coupleId: string,
|
coupleId: string,
|
||||||
questionDate: string
|
questionDate: string
|
||||||
): Promise<void> {
|
): 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.
|
// In-app notification record.
|
||||||
await db
|
await db
|
||||||
.collection('users')
|
.collection('users')
|
||||||
|
|
@ -115,7 +129,7 @@ async function sendReminder(
|
||||||
})
|
})
|
||||||
|
|
||||||
// FCM push.
|
// FCM push.
|
||||||
const tokens = await getUserTokens(db, userId)
|
const tokens = await getUserTokens(db, userId, userData)
|
||||||
if (tokens.length === 0) return
|
if (tokens.length === 0) return
|
||||||
|
|
||||||
const sendResults = await Promise.allSettled(
|
const sendResults = await Promise.allSettled(
|
||||||
|
|
@ -148,11 +162,12 @@ async function sendReminder(
|
||||||
|
|
||||||
async function getUserTokens(
|
async function getUserTokens(
|
||||||
db: admin.firestore.Firestore,
|
db: admin.firestore.Firestore,
|
||||||
userId: string
|
userId: string,
|
||||||
|
userData?: admin.firestore.DocumentData
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const tokens: string[] = []
|
const tokens: string[] = []
|
||||||
const userDoc = await db.collection('users').doc(userId).get()
|
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
|
||||||
const legacyToken = userDoc.data()?.fcmToken
|
const legacyToken = data?.fcmToken
|
||||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
tokens.push(legacyToken)
|
tokens.push(legacyToken)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { recipientInQuietHours } from './quietHours'
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
@ -167,6 +168,21 @@ async function sendNotification(
|
||||||
messaging: admin.messaging.Messaging,
|
messaging: admin.messaging.Messaging,
|
||||||
notification: QueuedNotification
|
notification: QueuedNotification
|
||||||
): Promise<void> {
|
): 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
|
await db
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(notification.userId)
|
.doc(notification.userId)
|
||||||
|
|
@ -179,7 +195,7 @@ async function sendNotification(
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const tokens = await getUserTokens(db, notification.userId)
|
const tokens = await getUserTokens(db, notification.userId, userData)
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`)
|
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`)
|
||||||
return
|
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 tokens: string[] = []
|
||||||
const userDoc = await db.collection('users').doc(userId).get()
|
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
|
||||||
const legacyToken = userDoc.data()?.fcmToken
|
const legacyToken = data?.fcmToken
|
||||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||||
tokens.push(legacyToken)
|
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
|
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).
|
// Window may cross midnight (e.g. 22:00 → 08:00).
|
||||||
return start <= end
|
return start < end
|
||||||
? nowMinutes >= start && nowMinutes <= end
|
? nowMinutes >= start && nowMinutes <= end
|
||||||
: nowMinutes >= start || nowMinutes <= end
|
: nowMinutes >= start || nowMinutes <= end
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { recipientInQuietHours } from './quietHours'
|
||||||
|
|
||||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000
|
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000
|
||||||
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000
|
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000
|
||||||
|
|
@ -77,6 +78,19 @@ async function sendNudge(
|
||||||
userId: string,
|
userId: string,
|
||||||
coupleId: string
|
coupleId: string
|
||||||
): Promise<void> {
|
): 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({
|
await db.collection('users').doc(userId).collection('notification_queue').add({
|
||||||
type: 'reengagement',
|
type: 'reengagement',
|
||||||
title: "It's been a while.",
|
title: "It's been a while.",
|
||||||
|
|
@ -85,7 +99,7 @@ async function sendNudge(
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const tokens = await getUserTokens(db, userId)
|
const tokens = await getUserTokens(db, userId, userData)
|
||||||
if (tokens.length === 0) return
|
if (tokens.length === 0) return
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
|
|
@ -108,11 +122,12 @@ async function sendNudge(
|
||||||
|
|
||||||
async function getUserTokens(
|
async function getUserTokens(
|
||||||
db: admin.firestore.Firestore,
|
db: admin.firestore.Firestore,
|
||||||
userId: string
|
userId: string,
|
||||||
|
userData?: admin.firestore.DocumentData
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const tokens: string[] = []
|
const tokens: string[] = []
|
||||||
const userDoc = await db.collection('users').doc(userId).get()
|
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
|
||||||
const legacy = userDoc.data()?.fcmToken
|
const legacy = data?.fcmToken
|
||||||
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
|
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
|
||||||
|
|
||||||
const snap = await db.collection('users').doc(userId).collection('fcmTokens').get()
|
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"; }
|
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 "# Wiring / dead-feature scan — $(date '+%Y-%m-%d %H:%M')"
|
||||||
log ""
|
log ""
|
||||||
|
|
@ -97,6 +97,30 @@ done < <(grep -rEn 'if \([a-zA-Z0-9_.]+\.(isEmpty\(\)|isBlank\(\))\) return|[a-z
|
||||||
[ "$review" -eq 0 ] && log "- none"
|
[ "$review" -eq 0 ] && log "- none"
|
||||||
log ""
|
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 "## Summary"
|
||||||
log ""
|
log ""
|
||||||
log "| Tier | Count |"
|
log "| Tier | Count |"
|
||||||
|
|
@ -104,8 +128,9 @@ log "|---|---|"
|
||||||
log "| 🔴 CRITICAL (dead setters) | $crit |"
|
log "| 🔴 CRITICAL (dead setters) | $crit |"
|
||||||
log "| 🟠 MAJOR (orphan readers) | $major |"
|
log "| 🟠 MAJOR (orphan readers) | $major |"
|
||||||
log "| 🟡 REVIEW (bail-guards) | $review |"
|
log "| 🟡 REVIEW (bail-guards) | $review |"
|
||||||
|
log "| 🔴 dead notif settings | $notifdead |"
|
||||||
log ""
|
log ""
|
||||||
log "_Record these counts in ClaudeQACoverage.md under Pass N before driving the interactive features._"
|
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.
|
# Exit non-zero on any CRITICAL class so CI/automation can gate on it.
|
||||||
[ "$crit" -gt 0 ] && exit 1 || exit 0
|
{ [ "$crit" -gt 0 ] || [ "$notifdead" -gt 0 ]; } && exit 1 || exit 0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue