Closer/functions/src/notifications/gameRetention.ts

265 lines
8.9 KiB
TypeScript

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { recipientInQuietHours } from './quietHours'
const DAY_MS = 24 * 60 * 60 * 1000
const CHALLENGE_TITLES: Record<string, { title: string; durationDays: number }> = {
gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
appreciation_notes: { title: 'Appreciation Notes', durationDays: 7 },
quality_time: { title: 'Quality Time', durationDays: 7 },
deep_conversations: { title: 'Deep Conversations', durationDays: 7 },
}
type NotificationType = 'memory_capsule_unlocked' | 'challenge_day_ready'
interface QueuedNotification {
userId: string
type: NotificationType
title: string
body: string
data: Record<string, string>
}
export const unlockDueMemoryCapsules = functions.pubsub
.schedule('every 1 hours')
.onRun(async () => {
const db = admin.firestore()
const messaging = admin.messaging()
const now = Date.now()
const snapshot = await db
.collectionGroup('capsules')
.where('status', '==', 'sealed')
.where('unlockAt', '<=', now)
.limit(100)
.get()
const notifications: QueuedNotification[] = []
for (const capsuleDoc of snapshot.docs) {
const capsuleRef = capsuleDoc.ref
const coupleRef = capsuleRef.parent.parent
if (!coupleRef) continue
const capsuleNotifications = await db.runTransaction(async (tx) => {
const freshCapsule = await tx.get(capsuleRef)
const capsule = freshCapsule.data() ?? {}
if (capsule.status !== 'sealed' || Number(capsule.unlockAt ?? 0) > now) {
return [] as QueuedNotification[]
}
const coupleDoc = await tx.get(coupleRef)
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (userIds.length === 0) return [] as QueuedNotification[]
tx.update(capsuleRef, {
status: 'unlocked',
unlockedAt: now,
unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(),
})
const capsuleId = capsuleRef.id
const coupleId = coupleRef.id
const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0
? capsule.title.trim()
: 'A memory capsule'
return userIds.map((userId) => ({
userId,
type: 'memory_capsule_unlocked' as const,
title: 'Your memory capsule opened',
body: `${title} is ready to read together.`,
data: { couple_id: coupleId, capsule_id: capsuleId },
}))
})
notifications.push(...capsuleNotifications)
}
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)))
console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`)
})
export const sendChallengeDayReminders = functions.pubsub
.schedule('every 24 hours')
.onRun(async () => {
const db = admin.firestore()
const messaging = admin.messaging()
const now = Date.now()
const snapshot = await db
.collectionGroup('challenges')
.where('status', '==', 'active')
.limit(100)
.get()
const notifications: QueuedNotification[] = []
for (const challengeDoc of snapshot.docs) {
const challengeRef = challengeDoc.ref
const coupleRef = challengeRef.parent.parent
if (!coupleRef) continue
const challengeNotifications = await db.runTransaction(async (tx) => {
const freshChallenge = await tx.get(challengeRef)
const challenge = freshChallenge.data() ?? {}
if (challenge.status !== 'active') return [] as QueuedNotification[]
const startedAt = Number(challenge.startedAt ?? 0)
if (startedAt <= 0 || startedAt > now) return [] as QueuedNotification[]
const challengeId = typeof challenge.challengeId === 'string'
? challenge.challengeId
: challengeRef.id
const catalogEntry = CHALLENGE_TITLES[challengeId] ?? {
title: 'Connection Challenge',
durationDays: 7,
}
const day = Math.floor((now - startedAt) / DAY_MS) + 1
if (day < 1 || day > catalogEntry.durationDays) return [] as QueuedNotification[]
const coupleDoc = await tx.get(coupleRef)
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (userIds.length === 0) return [] as QueuedNotification[]
const completions = (challenge.completions ?? {}) as Record<string, number[]>
const reminderSent = (challenge.challengeReminderSent ?? {}) as Record<string, boolean>
const dueUserIds = userIds.filter((userId) => {
const completedDays = completions[userId] ?? []
const alreadyCompleted = completedDays.map(Number).includes(day)
const alreadySent = reminderSent[reminderKey(userId, day)] === true
return !alreadyCompleted && !alreadySent
})
if (dueUserIds.length === 0) return [] as QueuedNotification[]
const updates: Record<string, unknown> = {
lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(),
}
dueUserIds.forEach((userId) => {
updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true
})
tx.update(challengeRef, updates)
const coupleId = coupleRef.id
return dueUserIds.map((userId) => ({
userId,
type: 'challenge_day_ready' as const,
title: `Day ${day} is ready`,
body: `${catalogEntry.title}: today's connection prompt is waiting.`,
data: { couple_id: coupleId, challenge_id: challengeId, day: String(day) },
}))
})
notifications.push(...challengeNotifications)
}
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)))
console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`)
})
function reminderKey(userId: string, day: number): string {
return `${userId.replace(/[^\w-]/g, '_')}_${day}`
}
async function sendNotification(
db: admin.firestore.Firestore,
messaging: admin.messaging.Messaging,
notification: QueuedNotification
): Promise<void> {
const userDoc = await db.collection('users').doc(notification.userId).get()
const userData = userDoc.data()
// Challenge-day reminders are retention nudges → respect the promotional opt-out (default on).
// (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.)
if (notification.type === 'challenge_day_ready' && userData?.notifPromotional === false) {
console.log(`[sendNotification] skip ${notification.userId} — promotional off`)
return
}
// Honor the recipient's quiet hours for every scheduled push.
if (recipientInQuietHours(userData)) {
console.log(`[sendNotification] skip ${notification.userId} — quiet hours`)
return
}
await db
.collection('users')
.doc(notification.userId)
.collection('notification_queue')
.add({
type: notification.type,
title: notification.title,
body: notification.body,
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
const tokens = await getUserTokens(db, notification.userId, userData)
if (tokens.length === 0) {
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`)
return
}
const message: admin.messaging.Message = {
token: tokens[0],
notification: {
title: notification.title,
body: notification.body,
},
// E-OBS: challenge reminders → Reminders channel; capsule-unlocked → partner-activity channel.
android: {
notification: {
channelId: notification.type === 'challenge_day_ready' ? 'reminders' : 'partner_activity',
},
},
data: {
type: notification.type,
...notification.data,
},
}
const sendResults = await Promise.allSettled(
tokens.map((token) => messaging.send({ ...message, token }))
)
const failures: string[] = []
sendResults.forEach((result, index) => {
if (result.status === 'rejected') {
failures.push(`${tokens[index]}: ${String(result.reason)}`)
}
})
if (failures.length > 0) {
console.error(`[sendNotification] some notifications failed:`, failures)
}
}
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 tokenSnapshot = await db
.collection('users')
.doc(userId)
.collection('fcmTokens')
.get()
tokenSnapshot.docs.forEach((doc) => {
const token = doc.data()?.token
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
tokens.push(token)
}
})
return tokens
}