Closer/functions/src/questions/onAnswerRevealed.ts

113 lines
4.4 KiB
TypeScript

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
/**
* Firestore trigger: when one partner OPENS (reveals) the shared answers — i.e. their own
* daily answer doc flips isRevealed false → true — notify the other partner that they've
* looked, so the pair stays in sync ("[Name] just opened your answers").
*
* Path: couples/{coupleId}/daily_question/{date}/answers/{userId}
*/
export const onAnswerRevealed = functions.firestore
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}')
.onUpdate(async (change, context) => {
const { coupleId, date, userId } = context.params as {
coupleId: string
date: string
userId: string
}
const before = change.before.data() as Partial<Record<string, unknown>>
const after = change.after.data() as Partial<Record<string, unknown>>
// Only fire on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true) return
const db = admin.firestore()
const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) {
console.warn(`[onAnswerRevealed] couple ${coupleId} not found`)
return
}
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(userId)) {
console.warn(`[onAnswerRevealed] revealer ${userId} not a member of ${coupleId}`)
return
}
const partnerId = userIds.find((uid) => uid !== userId)
if (!partnerId) {
console.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`)
return
}
// Partner FCM tokens (legacy field + multi-device subcollection).
const tokens: string[] = []
const partnerUserDoc = await db.collection('users').doc(partnerId).get()
if (partnerUserDoc.exists) {
const legacyToken = partnerUserDoc.data()?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) tokens.push(legacyToken)
}
const tokenSnapshot = await db.collection('users').doc(partnerId).collection('fcmTokens').get()
tokenSnapshot.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
if (tokens.length === 0) {
console.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`)
return
}
// Respect the same partner-activity opt-out as the answered ping.
if (partnerUserDoc.data()?.notifPartnerAnswered === false) {
console.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`)
return
}
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if (recipientInQuietHours(partnerUserDoc.data())) {
console.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`)
return
}
const questionId = typeof after.questionId === 'string' ? after.questionId : ''
// displayName is E2EE in users/{uid} → generic title; the app shows the real name in-app. Avatar
// (photoUrl) stays plaintext, so it's still sent.
const revealerDoc = await db.collection('users').doc(userId).get()
const revealerAvatar = revealerDoc.data()?.photoUrl
const payload: admin.messaging.MessagingPayload = {
notification: {
title: 'Your partner opened your answers',
body: 'Open to see what you each said.',
},
data: {
type: 'partner_opened_answer',
couple_id: coupleId,
question_id: questionId,
date,
...(typeof revealerAvatar === 'string' && revealerAvatar.length > 0
? { sender_avatar_url: revealerAvatar }
: {}),
},
}
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
...payload,
token,
android: { notification: { channelId: 'partner_activity' } },
} as admin.messaging.Message)
)
)
const failures: string[] = []
sendResults.forEach((r, i) => {
if (r.status === 'rejected') failures.push(`${tokens[i]}: ${String(r.reason)}`)
})
if (failures.length > 0) console.error('[onAnswerRevealed] some notifications failed:', failures)
console.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`)
})