import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { recipientInQuietHours } from '../notifications/quietHours' import { pruneDeadTokens } from '../notifications/pruneTokens' /** * 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] const userA = await db.collection('users').doc(partnerA).get() const userB = await db.collection('users').doc(partnerB).get() // displayName is E2EE in users/{uid}, so the OS-rendered push uses a generic label; the app shows // the real name in-app (resolved locally). Avatar (photoUrl) stays plaintext and is still sent. const partnerAName = 'Your partner' const partnerBName = 'Your partner' const avatarA = userA.data()?.photoUrl as string | undefined const avatarB = userB.data()?.photoUrl as string | undefined // M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open. const dataFor = (uid: string) => (uid === partnerA ? userA.data() : userB.data()) 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 if (recipientInQuietHours(dataFor(recipientId))) { console.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`) } else { await notifyPartner( db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId ) } } return } // ── Partner joined an active session ───────────────────────────────── // The non-starter opening the session writes their uid into `joinedByUsers` (client, rule-gated). // Notify the STARTER once, with the joiner's name + avatar. One-time via `joinNotifiedAt` // (server-only flag, claimed in a transaction — same pattern as start/finishNotifiedAt). if (status === 'active' && !currentData.joinNotifiedAt && Array.isArray(currentData.joinedByUsers)) { const startedBy = currentData.startedByUserId const joiner = (currentData.joinedByUsers as string[]).find((u) => u && u !== startedBy) if (joiner) { 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.joinNotifiedAt) return false const j = Array.isArray(d.joinedByUsers) ? (d.joinedByUsers as string[]) : [] if (!j.some((u) => u && u !== d.startedByUserId)) return false tx.update(sessionRef, { joinNotifiedAt: admin.firestore.FieldValue.serverTimestamp() }) return true }) if (claimed) { const gameType = currentData.gameType ?? 'wheel' const joinerName = joiner === partnerA ? partnerAName : partnerBName const joinerAvatar = joiner === partnerA ? avatarA : avatarB if (recipientInQuietHours(dataFor(startedBy))) { console.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`) } else { await notifyPartner( db, messaging, startedBy, joinerName, gameType, 'partner_joined_game', `${joinerName} joined your game — tap to play together.`, coupleId, joinerAvatar, 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. M-001: skip a recipient in quiet hours. if (recipientInQuietHours(dataFor(partnerA))) { console.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`) } else { await notifyPartner( db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId ) } if (recipientInQuietHours(dataFor(partnerB))) { console.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`) } else { await notifyPartner( db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId ) } } return } }) /** * Notify the WAITING partner the moment the FIRST player finishes their part of an async game. * * Async games (this_or_that / wheel / how_well / desire_sync) write each player's answers to * couples/{coupleId}/{gameType}/{sessionId}.answers[uid]; the SESSION doc only flips to * 'completed' once BOTH have answered (which onGameSessionUpdate turns into partner_finished_game). * Between first-finish and both-finish the waiting partner got NOTHING — they never learned it was * their turn (the symptom: "X finished a game but the partner was never notified"). The * PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it. * * Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc). */ const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync'] export const onGamePartFinished = functions.firestore .document('couples/{coupleId}/{gameType}/{sessionId}') .onWrite(async (change, context) => { const { coupleId, gameType, sessionId } = context.params as { coupleId: string; gameType: string; sessionId: string } if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return // ignore messages/reactions/etc. if (!change.after.exists) return const answers = (change.after.data()?.answers ?? {}) as Record const answerUids = Object.keys(answers) // Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just // created; two = both done → the session flips to completed and onGameSessionUpdate sends // partner_finished_game instead. if (answerUids.length !== 1) return const finisherUid = answerUids[0] const db = admin.firestore() const messaging = admin.messaging() const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId) // Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe; // writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session). const claimed = await db.runTransaction(async (tx) => { const fresh = await tx.get(sessionRef) const d = fresh.data() if (!fresh.exists || !d) return false if (d.status === 'completed' || d.partFinishNotifiedAt) return false tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() }) return true }) if (!claimed) return const coupleDoc = await db.collection('couples').doc(coupleId).get() const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const recipient = userIds.find((u) => u !== finisherUid) if (!recipient) return const finisher = await db.collection('users').doc(finisherUid).get() const finisherName = 'Your partner' // displayName is E2EE; the app shows the real name in-app const finisherAvatar = finisher.data()?.photoUrl as string | undefined await notifyPartner( db, messaging, recipient, finisherName, gameType, 'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId, finisherAvatar, sessionId ) }) /** * 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` : notificationType === 'partner_completed_part' ? `${partnerName} finished their part` : notificationType === 'partner_joined_game' ? `${partnerName} joined your 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, // The acting partner's display name (public; also in the title) so the in-app foreground // banner can name them instead of a generic "Your partner". sender_name: partnerName, // 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})`) } await pruneDeadTokens(db, partnerId, tokens, sendResults) }