Closer/functions/src/notifications/reengagement.ts

125 lines
4.0 KiB
TypeScript

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<void> {
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<string[]> {
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
}