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 = { 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 { 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 { 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 }