feat: notification improvements + daily question reminder cloud function
This commit is contained in:
parent
7c04416013
commit
9166721282
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// 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<string[]> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue