Closer/functions/src/notifications/sendGentleReminderCallable.ts

135 lines
4.6 KiB
TypeScript
Raw Normal View History

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
/**
* Sends a gentle nudge from one partner to the other when the caller has
* already answered today's question but the partner hasn't.
*
* Rate limit: one reminder per couple per calendar day (UTC). The lock is
* stored in couples/{coupleId}/gentle_reminders/{date} so it survives
* function restarts and is visible to both partners.
*
* The notification is both an FCM push (for the system tray) and an entry in
* the partner's notification_queue (for in-app display).
*/
export const sendGentleReminderCallable = functions.https.onCall(async (_data, context) => {
const callerId = context.auth?.uid
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.')
}
const db = admin.firestore()
// ── 1. Resolve couple + partner ──────────────────────────────────────────
const userDoc = await db.collection('users').doc(callerId).get()
const coupleId = userDoc.data()?.coupleId as string | undefined
if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.')
}
const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.')
}
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== callerId)
if (!partnerId) {
throw new functions.https.HttpsError('failed-precondition', 'No partner found.')
}
// ── 2. Rate limit: one per couple per day ────────────────────────────────
const today = new Date().toISOString().slice(0, 10) // e.g. "2026-06-19"
const lockRef = db
.collection('couples')
.doc(coupleId)
.collection('gentle_reminders')
.doc(today)
const existingLock = await lockRef.get()
if (existingLock.exists) {
return { sent: false, reason: 'already_sent_today' }
}
// ── 3. Collect partner FCM tokens ────────────────────────────────────────
const tokens: string[] = []
const partnerDoc = await db.collection('users').doc(partnerId).get()
if (partnerDoc.exists) {
const legacyToken = partnerDoc.data()?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
}
const tokenSnap = await db
.collection('users')
.doc(partnerId)
.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)
}
})
// ── 4. Write in-app notification record ──────────────────────────────────
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
type: 'gentle_reminder',
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
read: false,
sent: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
// ── 5. Claim the daily rate-limit lock ───────────────────────────────────
await lockRef.set({
sentBy: callerId,
sentAt: admin.firestore.FieldValue.serverTimestamp(),
})
// ── 6. Send FCM push ─────────────────────────────────────────────────────
if (tokens.length > 0) {
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
token,
notification: {
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
},
data: {
type: 'gentle_reminder',
couple_id: coupleId,
},
})
)
)
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(
`[sendGentleReminderCallable] FCM failed for token ${tokens[i]}:`,
result.reason
)
}
})
}
console.log(
`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`
)
return { sent: true }
})