import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000 const REENGAGEMENT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000 /** * Re-engagement nudge for couples who went quiet. * * Schedule: 12:00 PM America/Chicago daily. * * Targets couples whose lastAnsweredAt is between 3 and 10 days ago — * recently lapsed but not completely inactive. Respects a 3-day cooldown * via reengagementSentAt to avoid spamming. * * Requires a Firestore composite index on couples: lastAnsweredAt ASC. */ export const sendReengagementReminder = functions.pubsub .schedule('0 12 * * *') .timeZone('America/Chicago') .onRun(async () => { const db = admin.firestore() const messaging = admin.messaging() const now = Date.now() const threeDaysAgo = admin.firestore.Timestamp.fromMillis(now - THREE_DAYS_MS) const tenDaysAgo = admin.firestore.Timestamp.fromMillis(now - TEN_DAYS_MS) const snap = await db .collection('couples') .where('lastAnsweredAt', '>', tenDaysAgo) .where('lastAnsweredAt', '<', threeDaysAgo) .limit(200) .get() let notified = 0 let skipped = 0 await Promise.all( snap.docs.map(async (coupleDoc) => { const data = coupleDoc.data() const coupleId = coupleDoc.id const userIds = (data.userIds ?? []) as string[] if (userIds.length === 0) { skipped++; return } // Respect cooldown — don't re-send within 3 days. const sentAt = data.reengagementSentAt as admin.firestore.Timestamp | undefined if (sentAt && now - sentAt.toMillis() < REENGAGEMENT_COOLDOWN_MS) { skipped++ return } // Claim atomically so parallel runs don't double-send. const claimed = await db.runTransaction(async (tx) => { const fresh = await tx.get(coupleDoc.ref) const freshSentAt = fresh.data()?.reengagementSentAt as admin.firestore.Timestamp | undefined if (freshSentAt && now - freshSentAt.toMillis() < REENGAGEMENT_COOLDOWN_MS) return false tx.update(coupleDoc.ref, { reengagementSentAt: admin.firestore.FieldValue.serverTimestamp(), }) return true }) if (!claimed) { skipped++; return } await Promise.all(userIds.map((uid) => sendNudge(db, messaging, uid, coupleId))) notified += userIds.length }) ) console.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`) }) async function sendNudge( db: admin.firestore.Firestore, messaging: admin.messaging.Messaging, userId: string, coupleId: string ): Promise { await db.collection('users').doc(userId).collection('notification_queue').add({ type: 'reengagement', title: "It's been a while.", body: "Tonight's question is a good reason to reconnect.", read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }) const tokens = await getUserTokens(db, userId) if (tokens.length === 0) return await Promise.allSettled( tokens.map((token) => messaging.send({ token, notification: { title: "It's been a while.", body: "Tonight's question is a good reason to reconnect.", }, android: { notification: { channelId: 'reminders' } }, // E-OBS data: { type: 'reengagement', couple_id: coupleId, }, }) ) ) } async function getUserTokens( db: admin.firestore.Firestore, userId: string ): Promise { const tokens: string[] = [] const userDoc = await db.collection('users').doc(userId).get() const legacy = userDoc.data()?.fcmToken if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) const snap = await db.collection('users').doc(userId).collection('fcmTokens').get() snap.docs.forEach((doc) => { const t = doc.data()?.token if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) }) return tokens }