Closer/functions/src/couples/scheduledOutcomesReminder.ts

170 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = millisFromFirestoreValue(data.createdAt)
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}`)
})
function millisFromFirestoreValue(value: unknown): number {
if (typeof value === 'number') return value
if (value instanceof admin.firestore.Timestamp) return value.toMillis()
if (
value != null &&
typeof (value as { toMillis?: unknown }).toMillis === 'function'
) {
return (value as { toMillis: () => number }).toMillis()
}
return 0
}
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
}