158 lines
4.8 KiB
TypeScript
158 lines
4.8 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 = 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 = `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}`)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
}
|