170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
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 = `You’ve 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
|
||
}
|