Closer/functions/src/questions/onMessageWritten.ts

116 lines
4.0 KiB
TypeScript

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
/**
* Firestore trigger that notifies the other partner when a chat message is
* sent in a conversation (the couple chat or a per-question discussion).
*
* Path: couples/{coupleId}/conversations/{conversationId}/messages/{messageId}
*
* Respects the recipient's `notifChatMessage` preference (default: enabled).
*/
export const onMessageWritten = functions.firestore
.document('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}')
.onCreate(async (snap, context) => {
const { coupleId, conversationId, messageId } = context.params as {
coupleId: string
conversationId: string
messageId: string
}
const db = admin.firestore()
const messageData = snap.data() as Partial<Record<string, unknown>>
const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null
if (!authorId) {
console.warn(`[onMessageWritten] no authorUserId on message ${messageId}`)
return
}
const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) {
console.warn(`[onMessageWritten] couple ${coupleId} not found`)
return
}
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((uid) => uid !== authorId)
if (!partnerId) {
console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`)
return
}
const partnerUserDoc = await db.collection('users').doc(partnerId).get()
// Respect the partner's notification preference (opt-out; default is enabled).
const notifEnabled = partnerUserDoc.data()?.notifChatMessage
if (notifEnabled === false) {
console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`)
return
}
const tokens: string[] = []
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(`[onMessageWritten] no FCM tokens for partner ${partnerId}`)
return
}
// The recipient sees the message from the author (their partner), so surface the author's
// photo/name — the in-app chat bubble uses sender_avatar_url to show the partner's face.
const authorDoc = await db.collection('users').doc(authorId).get()
const authorPhotoUrl = (authorDoc.data()?.photoUrl as string | undefined) ?? ''
const authorName = (authorDoc.data()?.displayName as string | undefined) ?? ''
const payload: admin.messaging.MessagingPayload = {
notification: {
title: authorName ? `${authorName} sent a message` : 'Your partner sent a message',
body: 'Tap to read and reply.',
},
data: {
type: 'chat_message',
couple_id: coupleId,
conversation_id: conversationId,
...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}),
},
}
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({ ...payload, token } as admin.messaging.Message)
)
)
const failures: string[] = []
sendResults.forEach((result, index) => {
if (result.status === 'rejected') {
failures.push(`${tokens[index]}: ${String(result.reason)}`)
}
})
if (failures.length > 0) {
console.error(`[onMessageWritten] some notifications failed:`, failures)
}
console.log(
`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`
)
})