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' // 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 const startedBy = currentData.startedByUserId const gameType = currentData.gameType ?? 'wheel' const partnerId = startedBy === partnerA ? partnerB : partnerA const partnerName = startedBy === partnerA ? partnerBName : partnerAName await notifyPartner( db, messaging, partnerId, partnerName, gameType, 'partner_started_game', `${partnerName} has started a game. Tap to join!` ) return } // Check if session was completed const wasActive = (previousData.status ?? '') === 'active' const isCompletedNow = currentData.status === 'completed' if (wasActive && isCompletedNow) { const completedBy = currentData.startedByUserId const partnerId = completedBy === partnerA ? partnerB : partnerA // Check if partner has also completed const partnerCompletedAt = currentData.partnerCompletedAt if (partnerCompletedAt) { // Both completed - notify both await notifyPartner( db, messaging, partnerA, partnerAName, currentData.gameType ?? 'wheel', 'partner_finished_game', `${partnerBName} has finished the game. Tap to see the results!` ) await notifyPartner( db, messaging, partnerB, partnerBName, currentData.gameType ?? 'wheel', 'partner_finished_game', `${partnerAName} has finished the game. Tap to see the results!` ) } else { // Only one completed - notify the other to continue await notifyPartner( db, messaging, partnerId, partnerName, currentData.gameType ?? 'wheel', 'partner_finished_game', `${partnerName} has finished. Tap to continue playing!` ) } 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 ): Promise { const notificationPayload = { type: notificationType, title: `${partnerName} is playing`, 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, gameType: gameType, partnerId: partnerId, }, } 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})`) } }