From 91667212823ca5430b4f4d7d413388d8b26e6041 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 08:34:15 -0500 Subject: [PATCH] feat: notification improvements + daily question reminder cloud function --- .../PartnerNotificationManager.kt | 12 +- functions/src/index.ts | 1 + .../notifications/dailyQuestionReminder.ts | 170 ++++++++++++++++++ .../Notifications/NotificationService.swift | 14 +- iphone/Closer/Navigation/ContentView.swift | 18 ++ 5 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 functions/src/notifications/dailyQuestionReminder.ts diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index 7673db4e..d217339c 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -180,6 +180,12 @@ enum class PartnerNotificationType( body = "They left tonight's question open. Answer when you're ready.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER + ), + DAILY_QUESTION_REMINDER( + title = "Tonight's question is waiting.", + body = "Answer together before it expires.", + channelId = NotificationChannelSetup.CHANNEL_REMINDERS, + rateType = NotificationRateLimiter.Type.REMINDER ); /** @@ -193,13 +199,10 @@ enum class PartnerNotificationType( CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE GENTLE_REMINDER -> AppRoute.DAILY_QUESTION + DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION } companion object { - /** - * Maps backend FCM message types to local notification types. - * Returns null for unknown types so arbitrary backend text cannot be shown. - */ fun fromRemoteType(type: String): PartnerNotificationType? = when (type) { "partner_answered" -> PARTNER_ANSWERED "reveal_ready" -> REVEAL_READY @@ -208,6 +211,7 @@ enum class PartnerNotificationType( "challenge_waiting" -> CHALLENGE_WAITING "memory_capsule_unlocked" -> CAPSULE_UNLOCKED "gentle_reminder" -> GENTLE_REMINDER + "daily_question_reminder" -> DAILY_QUESTION_REMINDER else -> null } } diff --git a/functions/src/index.ts b/functions/src/index.ts index 0477ba06..8696219b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -19,6 +19,7 @@ export { sendChallengeDayReminders, unlockDueMemoryCapsules, } from './notifications/gameRetention' +export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder' export { checkDeviceIntegrity } from './security/checkDeviceIntegrity' export { createDateMatchOnMutualLove } from './dates/createDateMatch' export { diff --git a/functions/src/notifications/dailyQuestionReminder.ts b/functions/src/notifications/dailyQuestionReminder.ts new file mode 100644 index 00000000..cf7064a9 --- /dev/null +++ b/functions/src/notifications/dailyQuestionReminder.ts @@ -0,0 +1,170 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * Proactive daily question reminder. + * + * Schedule: 4:00 PM America/Chicago (2 hours before the question expires at 6 PM). + * + * Logic: + * 1. Query all `daily_question` docs (collection group) with expiresAt in the + * next 3 hours — these are the ones expiring today. + * 2. For each, count the answers subcollection. If 0 answers (neither partner + * has responded), the couple needs a nudge. + * 3. Skip couples where a gentle_reminder or automated_daily_reminder was + * already sent today (avoid over-notifying couples where a partner already + * manually nudged the other). + * 4. Send FCM to all users in the couple + write to notification_queue. + * 5. Record automated_daily_reminder/{docId} so this function is idempotent + * on re-runs. + */ +export const sendDailyQuestionProactiveReminder = functions.pubsub + .schedule('0 16 * * *') + .timeZone('America/Chicago') + .onRun(async () => { + const db = admin.firestore() + const messaging = admin.messaging() + const now = Date.now() + const threeHoursMs = 3 * 60 * 60 * 1000 + + // Docs expiring within the next 3 hours — the active window for today's question. + const expiringSnap = await db + .collectionGroup('daily_question') + .where('expiresAt', '>', admin.firestore.Timestamp.fromMillis(now)) + .where('expiresAt', '<', admin.firestore.Timestamp.fromMillis(now + threeHoursMs)) + .get() + + let notified = 0 + let skipped = 0 + + await Promise.all( + expiringSnap.docs.map(async (questionDoc) => { + const coupleRef = questionDoc.ref.parent.parent + if (!coupleRef) return + + const reminderId = `auto_${questionDoc.id}` + const reminderRef = coupleRef.collection('daily_reminders').doc(reminderId) + + // Idempotency: skip if we already sent this reminder. + const alreadySent = await reminderRef.get() + if (alreadySent.exists) { skipped++; return } + + // Skip if a manual gentle_reminder was sent today (same date key). + const dateKey = questionDoc.id // daily_question docs are keyed by date (YYYY-MM-DD) + const gentleRef = coupleRef.collection('gentle_reminders').doc(dateKey) + const gentleSent = await gentleRef.get() + if (gentleSent.exists) { skipped++; return } + + // Check answer count — only remind if nobody has answered yet. + const answersSnap = await questionDoc.ref.collection('answers').limit(1).get() + if (!answersSnap.empty) { skipped++; return } + + // Fetch couple members. + const coupleDoc = await coupleRef.get() + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + if (userIds.length === 0) { skipped++; return } + + // Claim the reminder slot atomically. + try { + await db.runTransaction(async (tx) => { + const fresh = await tx.get(reminderRef) + if (fresh.exists) throw new Error('already_sent') + tx.set(reminderRef, { + sentAt: admin.firestore.FieldValue.serverTimestamp(), + questionDate: dateKey, + }) + }) + } catch { + skipped++ + return + } + + // Send FCM + notification_queue entry to every user in the couple. + await Promise.all( + userIds.map((userId) => + sendReminder(db, messaging, userId, coupleRef.id, dateKey) + ) + ) + notified += userIds.length + }) + ) + + console.log( + `[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}` + ) + }) + +async function sendReminder( + db: admin.firestore.Firestore, + messaging: admin.messaging.Messaging, + userId: string, + coupleId: string, + questionDate: string +): Promise { + // In-app notification record. + await db + .collection('users') + .doc(userId) + .collection('notification_queue') + .add({ + type: 'daily_question_reminder', + title: "Tonight's question is waiting.", + body: 'Answer together before it expires.', + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + // FCM push. + const tokens = await getUserTokens(db, userId) + if (tokens.length === 0) return + + const sendResults = await Promise.allSettled( + tokens.map((token) => + messaging.send({ + token, + notification: { + title: "Tonight's question is waiting.", + body: 'Answer together before it expires.', + }, + data: { + type: 'daily_question_reminder', + coupleId, + questionDate, + }, + }) + ) + ) + + sendResults.forEach((result, i) => { + if (result.status === 'rejected') { + console.warn( + `[sendDailyQuestionProactiveReminder] FCM failed for token ${tokens[i]}:`, + result.reason + ) + } + }) +} + +async function getUserTokens( + db: admin.firestore.Firestore, + userId: string +): Promise { + const tokens: string[] = [] + const userDoc = await db.collection('users').doc(userId).get() + const legacyToken = userDoc.data()?.fcmToken + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken) + } + const tokenSnap = await db + .collection('users') + .doc(userId) + .collection('fcmTokens') + .get() + tokenSnap.docs.forEach((doc) => { + const t = doc.data()?.token + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { + tokens.push(t) + } + }) + return tokens +} diff --git a/iphone/Closer/Core/Notifications/NotificationService.swift b/iphone/Closer/Core/Notifications/NotificationService.swift index a812b48e..55a45b83 100644 --- a/iphone/Closer/Core/Notifications/NotificationService.swift +++ b/iphone/Closer/Core/Notifications/NotificationService.swift @@ -68,16 +68,21 @@ extension NotificationService: UNUserNotificationCenterDelegate { } private func handleDeepLink(_ userInfo: [AnyHashable: Any]) { - // Parse deep link from notification payload guard let type = userInfo["type"] as? String else { return } - + switch type { - case "daily_question": + case "daily_question", "gentle_reminder", "daily_question_reminder": NotificationCenter.default.post(name: .navigateToDailyQuestion, object: nil) case "partner_answered": NotificationCenter.default.post(name: .navigateToReveal, object: userInfo["questionId"]) case "streak": NotificationCenter.default.post(name: .navigateToHome, object: nil) + case "partner_started_game", "partner_completed_part", "partner_finished_game": + NotificationCenter.default.post(name: .navigateToPlay, object: nil) + case "memory_capsule_unlocked": + NotificationCenter.default.post(name: .navigateToMemoryLane, object: nil) + case "challenge_day_ready", "challenge_waiting": + NotificationCenter.default.post(name: .navigateToConnectionChallenges, object: nil) default: break } @@ -90,6 +95,9 @@ extension Notification.Name { static let navigateToDailyQuestion = Notification.Name("navigateToDailyQuestion") static let navigateToReveal = Notification.Name("navigateToReveal") static let navigateToHome = Notification.Name("navigateToHome") + static let navigateToPlay = Notification.Name("navigateToPlay") + static let navigateToMemoryLane = Notification.Name("navigateToMemoryLane") + static let navigateToConnectionChallenges = Notification.Name("navigateToConnectionChallenges") } typealias FieldValue = FirebaseFirestore.FieldValue \ No newline at end of file diff --git a/iphone/Closer/Navigation/ContentView.swift b/iphone/Closer/Navigation/ContentView.swift index 9371f563..5166ee39 100644 --- a/iphone/Closer/Navigation/ContentView.swift +++ b/iphone/Closer/Navigation/ContentView.swift @@ -72,6 +72,24 @@ struct MainTabView: View { .tag(Tab.settings) } .tint(.closerPrimary) + .onReceive(NotificationCenter.default.publisher(for: .navigateToDailyQuestion)) { _ in + selectedTab = .dailyQuestion + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToReveal)) { _ in + selectedTab = .dailyQuestion + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in + selectedTab = .home + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToPlay)) { _ in + selectedTab = .play + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToMemoryLane)) { _ in + selectedTab = .play + } + .onReceive(NotificationCenter.default.publisher(for: .navigateToConnectionChallenges)) { _ in + selectedTab = .play + } } }