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 question thread. * * Path: couples/{coupleId}/question_threads/{threadId}/messages/{messageId} * * Respects the recipient's `notifChatMessage` preference (default: enabled). */ export const onMessageWritten = functions.firestore .document('couples/{coupleId}/question_threads/{threadId}/messages/{messageId}') .onCreate(async (snap, context) => { const { coupleId, threadId, messageId } = context.params as { coupleId: string threadId: string messageId: string } const db = admin.firestore() const messageData = snap.data() as Partial> 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 } // The conversation deep link + the client's "am I already in this thread?" suppression both // key off questionId, so resolve it from the thread doc and pass it through. const threadDoc = await db .collection('couples').doc(coupleId) .collection('question_threads').doc(threadId) .get() const questionId = (threadDoc.data()?.questionId as string | undefined) ?? '' 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, thread_id: threadId, ...(questionId ? { question_id: questionId } : {}), ...(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 thread ${threadId} in couple ${coupleId}` ) })