2026-06-20 23:59:24 -05:00
|
|
|
|
import * as functions from 'firebase-functions'
|
|
|
|
|
|
import * as admin from 'firebase-admin'
|
2026-06-30 00:38:06 -05:00
|
|
|
|
import { recipientInQuietHours } from '../notifications/quietHours'
|
2026-06-30 23:34:39 -05:00
|
|
|
|
import { pruneDeadTokens } from '../notifications/pruneTokens'
|
2026-06-20 23:59:24 -05:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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() ?? {}
|
2026-06-23 12:40:00 -05:00
|
|
|
|
const createdAt = millisFromFirestoreValue(data.createdAt)
|
2026-06-20 23:59:24 -05:00
|
|
|
|
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}`)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-23 12:40:00 -05:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 23:59:24 -05:00
|
|
|
|
async function sendOutcomeReminder(
|
|
|
|
|
|
db: admin.firestore.Firestore,
|
|
|
|
|
|
messaging: admin.messaging.Messaging,
|
|
|
|
|
|
notification: { userId: string; coupleId: string; day: number; title: string; body: string }
|
|
|
|
|
|
): Promise<void> {
|
2026-06-30 00:38:06 -05:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 23:59:24 -05:00
|
|
|
|
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(),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-30 00:38:06 -05:00
|
|
|
|
const tokens = await getUserTokens(db, notification.userId, userData)
|
2026-06-20 23:59:24 -05:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
2026-06-25 12:39:59 -05:00
|
|
|
|
android: { notification: { channelId: 'reminders' } }, // E-OBS
|
2026-06-20 23:59:24 -05:00
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-06-30 23:34:39 -05:00
|
|
|
|
|
|
|
|
|
|
await pruneDeadTokens(db, notification.userId, tokens, sendResults)
|
2026-06-20 23:59:24 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 00:38:06 -05:00
|
|
|
|
async function getUserTokens(
|
|
|
|
|
|
db: admin.firestore.Firestore,
|
|
|
|
|
|
userId: string,
|
|
|
|
|
|
userData?: admin.firestore.DocumentData
|
|
|
|
|
|
): Promise<string[]> {
|
2026-06-20 23:59:24 -05:00
|
|
|
|
const tokens: string[] = []
|
2026-06-30 00:38:06 -05:00
|
|
|
|
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
|
|
|
|
|
|
const legacyToken = data?.fcmToken
|
2026-06-20 23:59:24 -05:00
|
|
|
|
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
|
|
|
|
|
|
}
|