2026-06-18 00:56:21 -05:00
|
|
|
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'
|
2026-06-23 18:23:49 -05:00
|
|
|
const avatarA = userA.data()?.photoUrl as string | undefined
|
|
|
|
|
const avatarB = userB.data()?.photoUrl as string | undefined
|
2026-06-18 00:56:21 -05:00
|
|
|
|
|
|
|
|
// 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) {
|
2026-06-24 11:47:49 -05:00
|
|
|
// New session started — notify the OTHER partner, naming the person who started it.
|
2026-06-18 00:56:21 -05:00
|
|
|
const startedBy = currentData.startedByUserId
|
|
|
|
|
const gameType = currentData.gameType ?? 'wheel'
|
2026-06-24 11:47:49 -05:00
|
|
|
const recipientId = startedBy === partnerA ? partnerB : partnerA
|
|
|
|
|
const starterName = startedBy === partnerA ? partnerAName : partnerBName
|
2026-06-23 18:23:49 -05:00
|
|
|
const starterAvatar = startedBy === partnerA ? avatarA : avatarB
|
2026-06-18 00:56:21 -05:00
|
|
|
await notifyPartner(
|
2026-06-24 11:47:49 -05:00
|
|
|
db, messaging, recipientId, starterName, gameType,
|
|
|
|
|
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
|
2026-06-23 18:23:49 -05:00
|
|
|
starterAvatar
|
2026-06-18 00:56:21 -05:00
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if session was completed
|
|
|
|
|
const wasActive = (previousData.status ?? '') === 'active'
|
|
|
|
|
const isCompletedNow = currentData.status === 'completed'
|
|
|
|
|
|
|
|
|
|
if (wasActive && isCompletedNow) {
|
2026-06-24 11:47:49 -05:00
|
|
|
// The session is complete (both partners have answered) — the reveal is ready for each of
|
|
|
|
|
// them, so notify BOTH, each naming the OTHER partner.
|
2026-06-22 08:53:23 -05:00
|
|
|
const gt = currentData.gameType ?? 'wheel'
|
2026-06-24 11:47:49 -05:00
|
|
|
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
|
|
|
|
|
)
|
2026-06-18 00:56:21 -05:00
|
|
|
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,
|
2026-06-22 08:53:23 -05:00
|
|
|
body: string,
|
2026-06-23 18:23:49 -05:00
|
|
|
coupleId: string,
|
|
|
|
|
senderAvatarUrl?: string
|
2026-06-18 00:56:21 -05:00
|
|
|
): Promise<void> {
|
2026-06-24 11:47:49 -05:00
|
|
|
const title =
|
|
|
|
|
notificationType === 'partner_finished_game'
|
|
|
|
|
? `${partnerName} finished the game`
|
|
|
|
|
: `${partnerName} is playing`
|
2026-06-18 00:56:21 -05:00
|
|
|
const notificationPayload = {
|
|
|
|
|
type: notificationType,
|
2026-06-24 11:47:49 -05:00
|
|
|
title,
|
2026-06-18 00:56:21 -05:00
|
|
|
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,
|
2026-06-22 08:53:23 -05:00
|
|
|
couple_id: coupleId,
|
|
|
|
|
game_type: gameType,
|
2026-06-23 18:23:49 -05:00
|
|
|
...(senderAvatarUrl && senderAvatarUrl.length > 0
|
|
|
|
|
? { sender_avatar_url: senderAvatarUrl }
|
|
|
|
|
: {}),
|
2026-06-18 00:56:21 -05:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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})`)
|
|
|
|
|
}
|
|
|
|
|
}
|