171 lines
5.5 KiB
TypeScript
171 lines
5.5 KiB
TypeScript
import * as functions from 'firebase-functions'
|
|
import * as admin from 'firebase-admin'
|
|
|
|
/**
|
|
* Proactive daily question reminder.
|
|
*
|
|
* Schedule: 4:00 PM America/Chicago (2 hours before the question expires at 6 PM).
|
|
*
|
|
* Logic:
|
|
* 1. Query all `daily_question` docs (collection group) with expiresAt in the
|
|
* next 3 hours — these are the ones expiring today.
|
|
* 2. For each, count the answers subcollection. If 0 answers (neither partner
|
|
* has responded), the couple needs a nudge.
|
|
* 3. Skip couples where a gentle_reminder or automated_daily_reminder was
|
|
* already sent today (avoid over-notifying couples where a partner already
|
|
* manually nudged the other).
|
|
* 4. Send FCM to all users in the couple + write to notification_queue.
|
|
* 5. Record automated_daily_reminder/{docId} so this function is idempotent
|
|
* on re-runs.
|
|
*/
|
|
export const sendDailyQuestionProactiveReminder = functions.pubsub
|
|
.schedule('0 16 * * *')
|
|
.timeZone('America/Chicago')
|
|
.onRun(async () => {
|
|
const db = admin.firestore()
|
|
const messaging = admin.messaging()
|
|
const now = Date.now()
|
|
const threeHoursMs = 3 * 60 * 60 * 1000
|
|
|
|
// Docs expiring within the next 3 hours — the active window for today's question.
|
|
const expiringSnap = await db
|
|
.collectionGroup('daily_question')
|
|
.where('expiresAt', '>', admin.firestore.Timestamp.fromMillis(now))
|
|
.where('expiresAt', '<', admin.firestore.Timestamp.fromMillis(now + threeHoursMs))
|
|
.get()
|
|
|
|
let notified = 0
|
|
let skipped = 0
|
|
|
|
await Promise.all(
|
|
expiringSnap.docs.map(async (questionDoc) => {
|
|
const coupleRef = questionDoc.ref.parent.parent
|
|
if (!coupleRef) return
|
|
|
|
const reminderId = `auto_${questionDoc.id}`
|
|
const reminderRef = coupleRef.collection('daily_reminders').doc(reminderId)
|
|
|
|
// Idempotency: skip if we already sent this reminder.
|
|
const alreadySent = await reminderRef.get()
|
|
if (alreadySent.exists) { skipped++; return }
|
|
|
|
// Skip if a manual gentle_reminder was sent today (same date key).
|
|
const dateKey = questionDoc.id // daily_question docs are keyed by date (YYYY-MM-DD)
|
|
const gentleRef = coupleRef.collection('gentle_reminders').doc(dateKey)
|
|
const gentleSent = await gentleRef.get()
|
|
if (gentleSent.exists) { skipped++; return }
|
|
|
|
// Check answer count — only remind if nobody has answered yet.
|
|
const answersSnap = await questionDoc.ref.collection('answers').limit(1).get()
|
|
if (!answersSnap.empty) { skipped++; return }
|
|
|
|
// Fetch couple members.
|
|
const coupleDoc = await coupleRef.get()
|
|
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
|
if (userIds.length === 0) { skipped++; return }
|
|
|
|
// Claim the reminder slot atomically.
|
|
try {
|
|
await db.runTransaction(async (tx) => {
|
|
const fresh = await tx.get(reminderRef)
|
|
if (fresh.exists) throw new Error('already_sent')
|
|
tx.set(reminderRef, {
|
|
sentAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
questionDate: dateKey,
|
|
})
|
|
})
|
|
} catch {
|
|
skipped++
|
|
return
|
|
}
|
|
|
|
// Send FCM + notification_queue entry to every user in the couple.
|
|
await Promise.all(
|
|
userIds.map((userId) =>
|
|
sendReminder(db, messaging, userId, coupleRef.id, dateKey)
|
|
)
|
|
)
|
|
notified += userIds.length
|
|
})
|
|
)
|
|
|
|
console.log(
|
|
`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`
|
|
)
|
|
})
|
|
|
|
async function sendReminder(
|
|
db: admin.firestore.Firestore,
|
|
messaging: admin.messaging.Messaging,
|
|
userId: string,
|
|
coupleId: string,
|
|
questionDate: string
|
|
): Promise<void> {
|
|
// In-app notification record.
|
|
await db
|
|
.collection('users')
|
|
.doc(userId)
|
|
.collection('notification_queue')
|
|
.add({
|
|
type: 'daily_question_reminder',
|
|
title: "Tonight's question is waiting.",
|
|
body: 'Answer together before it expires.',
|
|
read: false,
|
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
})
|
|
|
|
// FCM push.
|
|
const tokens = await getUserTokens(db, userId)
|
|
if (tokens.length === 0) return
|
|
|
|
const sendResults = await Promise.allSettled(
|
|
tokens.map((token) =>
|
|
messaging.send({
|
|
token,
|
|
notification: {
|
|
title: "Tonight's question is waiting.",
|
|
body: 'Answer together before it expires.',
|
|
},
|
|
data: {
|
|
type: 'daily_question_reminder',
|
|
couple_id: coupleId,
|
|
question_date: questionDate,
|
|
},
|
|
})
|
|
)
|
|
)
|
|
|
|
sendResults.forEach((result, i) => {
|
|
if (result.status === 'rejected') {
|
|
console.warn(
|
|
`[sendDailyQuestionProactiveReminder] FCM failed for token ${tokens[i]}:`,
|
|
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 tokenSnap = await db
|
|
.collection('users')
|
|
.doc(userId)
|
|
.collection('fcmTokens')
|
|
.get()
|
|
tokenSnap.docs.forEach((doc) => {
|
|
const t = doc.data()?.token
|
|
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
|
|
tokens.push(t)
|
|
}
|
|
})
|
|
return tokens
|
|
}
|