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> const after = change.after.data() as Partial> // 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}`) })