Closer/functions/src/couples/scheduledOutcomesReminder.ts

188 lines
5.9 KiB
TypeScript
Raw Normal View History

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
import { pruneDeadTokens } from '../notifications/pruneTokens'
/**
* 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> {
const userDoc = await db.collection('users').doc(notification.userId).get()
const userData = userDoc.data()
// Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate).
if (recipientInQuietHours(userData)) {
console.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`)
return
}
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, userData)
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,
},
android: { notification: { channelId: 'reminders' } }, // E-OBS
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
)
}
})
await pruneDeadTokens(db, notification.userId, tokens, sendResults)
}
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
}