Closer/functions/src/couples/scheduledOutcomesReminder.ts

158 lines
4.8 KiB
TypeScript
Raw Normal View History

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
/**
* Cloud Function: scheduledOutcomesReminder
*
* Manual test flow:
* 1. Create a test couple with createdAt = ~31 days ago.
* 2. Ensure no day_0/day_30 outcomes exist (or only day_0 exists to test day_30).
* 3. Use the Firebase Functions shell or temporarily change the schedule to test.
* 4. Verify FCM notification sent and notification_queue records written.
* 5. Verify day_30 not sent if day_30 outcome already exists.
*
* Cron: every 24 hours. Iterates couples and nudges both members when a 30/60/90
* day outcome is due and has not yet been submitted.
*/
export type OutcomeDayKey = 'day_0' | 'day_30' | 'day_60' | 'day_90'
const DAY_MS = 24 * 60 * 60 * 1000
const REMINDER_DAYS = [30, 60, 90] as const
const DAY_KEY_MAP: Record<number, OutcomeDayKey> = { 30: 'day_30', 60: 'day_60', 90: 'day_90' }
export const scheduledOutcomesReminder = functions.pubsub
.schedule('every 24 hours')
.onRun(async () => {
const db = admin.firestore()
const messaging = admin.messaging()
const now = Date.now()
const couplesSnap = await db.collection('couples').limit(200).get()
const notifications: {
userId: string
coupleId: string
day: number
title: string
body: string
}[] = []
for (const coupleDoc of couplesSnap.docs) {
const coupleId = coupleDoc.id
const data = coupleDoc.data() ?? {}
const createdAt = Number(data.createdAt ?? 0)
if (createdAt <= 0) continue
const ageDays = Math.floor((now - createdAt) / DAY_MS)
const dueDays = REMINDER_DAYS.filter((day) => ageDays >= day && ageDays <= day + 2)
if (dueDays.length === 0) continue
const userIds = (data.userIds ?? []) as string[]
if (userIds.length === 0) continue
// Check each due checkpoint; only remind for the first one without an outcome.
let remindedDay: number | null = null
for (const day of dueDays) {
const dayKey = DAY_KEY_MAP[day]
const outcomeSnap = await coupleDoc.ref.collection('outcomes').doc(dayKey).get()
if (!outcomeSnap.exists) {
remindedDay = day
break
}
}
if (remindedDay == null) continue
const dayLabel = remindedDay
const title = 'How are you feeling together?'
const body = `Youve been connected for ${dayLabel} days. Take a quick check-in to see how things have changed.`
for (const userId of userIds) {
notifications.push({ userId, coupleId, day: dayLabel, title, body })
}
}
await Promise.all(
notifications.map((notification) =>
sendOutcomeReminder(db, messaging, notification)
)
)
console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`)
})
async function sendOutcomeReminder(
db: admin.firestore.Firestore,
messaging: admin.messaging.Messaging,
notification: { userId: string; coupleId: string; day: number; title: string; body: string }
): Promise<void> {
await db
.collection('users')
.doc(notification.userId)
.collection('notification_queue')
.add({
type: 'outcome_reminder',
title: notification.title,
body: notification.body,
coupleId: notification.coupleId,
day: notification.day,
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
const tokens = await getUserTokens(db, notification.userId)
if (tokens.length === 0) {
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`)
return
}
const message: admin.messaging.Message = {
token: tokens[0],
notification: {
title: notification.title,
body: notification.body,
},
data: {
type: 'outcome_reminder',
coupleId: notification.coupleId,
day: String(notification.day),
},
}
const sendResults = await Promise.allSettled(
tokens.map((token) => messaging.send({ ...message, token }))
)
sendResults.forEach((result, index) => {
if (result.status === 'rejected') {
console.warn(
`[sendOutcomeReminder] FCM send to ${tokens[index]} failed:`,
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 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
}