113 lines
4.4 KiB
TypeScript
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}`)
|
|
})
|