import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' /** * Firestore trigger that notifies partners when a game session is created or completed. * * Path: couples/{coupleId}/sessions/{sessionId} * Condition: onWrite (create, update, delete) */ export const onGameSessionUpdate = functions.firestore .document('couples/{coupleId}/sessions/{sessionId}') .onWrite(async (change, context) => { const { coupleId, sessionId } = context.params as { coupleId: string; sessionId: string } const db = admin.firestore() const messaging = admin.messaging() // Get the session document const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get() const session = sessionDoc.data() if (!session) { console.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`) return } // Get couple info const coupleDoc = await db.collection('couples').doc(coupleId).get() if (!coupleDoc.exists) { console.warn(`[onGameSessionUpdate] couple ${coupleId} not found`) return } const coupleData = coupleDoc.data() ?? {} const userIds = (coupleData.userIds ?? []) as string[] if (userIds.length !== 2) { console.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`) return } const partnerA = userIds[0] const partnerB = userIds[1] // Get user display names for notifications const userA = await db.collection('users').doc(partnerA).get() const userB = await db.collection('users').doc(partnerB).get() const partnerAName = userA.data()?.displayName ?? 'Partner A' const partnerBName = userB.data()?.displayName ?? 'Partner B' const avatarA = userA.data()?.photoUrl as string | undefined const avatarB = userB.data()?.photoUrl as string | undefined // Check if session was just created (status = "active") const previousData = change.before.data() ?? {} const currentData = change.after.data() ?? {} const wasInactive = (previousData.status ?? '') !== 'active' const isActiveNow = currentData.status === 'active' if (wasInactive && isActiveNow) { // New session started — notify the OTHER partner, naming the person who started it. const startedBy = currentData.startedByUserId const gameType = currentData.gameType ?? 'wheel' const recipientId = startedBy === partnerA ? partnerB : partnerA const starterName = startedBy === partnerA ? partnerAName : partnerBName const starterAvatar = startedBy === partnerA ? avatarA : avatarB await notifyPartner( db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar ) return } // Check if session was completed const wasActive = (previousData.status ?? '') === 'active' const isCompletedNow = currentData.status === 'completed' if (wasActive && isCompletedNow) { // The session is complete (both partners have answered) — the reveal is ready for each of // them, so notify BOTH, each naming the OTHER partner. const gt = currentData.gameType ?? 'wheel' await notifyPartner( db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB ) await notifyPartner( db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA ) return } }) /** * Send notification to partner via FCM and write to notification_queue. */ async function notifyPartner( db: admin.firestore.Firestore, messaging: admin.messaging.Messaging, partnerId: string, partnerName: string, gameType: string, notificationType: string, body: string, coupleId: string, senderAvatarUrl?: string ): Promise { const title = notificationType === 'partner_finished_game' ? `${partnerName} finished the game` : `${partnerName} is playing` const notificationPayload = { type: notificationType, title, body: body, } // Write an in-app notification record for the partner await db .collection('users') .doc(partnerId) .collection('notification_queue') .add({ ...notificationPayload, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }) // Collect the partner's FCM tokens 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(`[notifyPartner] no FCM tokens for ${partnerId}`) return } const fcmMessage: admin.messaging.Message = { token: tokens[0], notification: { title: notificationPayload.title, body: notificationPayload.body, }, data: { type: notificationPayload.type, couple_id: coupleId, game_type: gameType, ...(senderAvatarUrl && senderAvatarUrl.length > 0 ? { sender_avatar_url: senderAvatarUrl } : {}), }, } const sendResults = await Promise.allSettled( tokens.map((token) => messaging.send({ ...fcmMessage, token })) ) const failures: string[] = [] sendResults.forEach((result, index) => { if (result.status === 'rejected') { failures.push(`${tokens[index]}: ${String(result.reason)}`) } }) if (failures.length > 0) { console.error(`[notifyPartner] some notifications failed:`, failures) } else { console.log(`[notifyPartner] notified ${partnerId} (${notificationType})`) } }