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 } // The per-couple active-session lock lives at sessions/_active — it is a pointer, not a // game session, so it must never produce a partner notification. if (sessionId === '_active') return 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 const currentData = change.after.data() ?? {} if (!change.after.exists) return // deletion — nothing to notify const status = currentData.status as string | undefined const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId) // Detect start/finish from the session's CURRENT state + a one-time flag, NOT from the // change.before→change.after status diff. The atomic session start (F-RACE-001) writes the // session doc AND the sessions/_active pointer in a single transaction; transactional writes // can be delivered to onWrite with change.before === change.after (the "Snapshot has no // readTime" path), which made the inactive→active edge unreliable and intermittently dropped // the partner_started_game push. Claiming a flag inside a transaction makes each notification // fire exactly once no matter how the event is delivered (and prevents double-sends). // ── New session started ────────────────────────────────────────────── if (status === 'active' && !currentData.startNotifiedAt) { const claimed = await db.runTransaction(async (tx) => { const fresh = await tx.get(sessionRef) const d = fresh.data() if (!fresh.exists || !d || d.status !== 'active' || d.startNotifiedAt) return false tx.update(sessionRef, { startNotifiedAt: admin.firestore.FieldValue.serverTimestamp() }) return true }) if (claimed) { 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, sessionId ) } return } // ── Session completed (reveal ready for both) ──────────────────────── if (status === 'completed' && !currentData.finishNotifiedAt) { const claimed = await db.runTransaction(async (tx) => { const fresh = await tx.get(sessionRef) const d = fresh.data() if (!fresh.exists || !d || d.status !== 'completed' || d.finishNotifiedAt) return false tx.update(sessionRef, { finishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() }) return true }) if (claimed) { const gt = currentData.gameType ?? 'wheel' // Notify BOTH partners, each naming the OTHER. await notifyPartner( db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId ) await notifyPartner( db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId ) } 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, sessionId?: 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, }, // Put backgrounded notifications on the Games channel instead of the FCM fallback channel, // so importance/sound and the per-category toggle apply. E-OBS. android: { notification: { channelId: 'game_activity' } }, data: { type: notificationPayload.type, couple_id: coupleId, game_type: gameType, // Lets the client deep link a results-ready push to the per-session results/replay screen // (a completed session isn't returned by getActiveSession). E-003 results-ready. ...(sessionId ? { game_session_id: sessionId } : {}), ...(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})`) } }