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'
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-06-18 01:28:43 -05:00
|
|
|
const completingPartnerName = completedBy === partnerA ? partnerAName : partnerBName
|
2026-06-18 00:56:21 -05:00
|
|
|
|
|
|
|
|
// 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,
|
2026-06-18 01:28:43 -05:00
|
|
|
completingPartnerName,
|
2026-06-18 00:56:21 -05:00
|
|
|
currentData.gameType ?? 'wheel',
|
|
|
|
|
'partner_finished_game',
|
2026-06-18 01:28:43 -05:00
|
|
|
`${completingPartnerName} has finished. Tap to continue playing!`
|
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,
|
|
|
|
|
body: string
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
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})`)
|
|
|
|
|
}
|
|
|
|
|
}
|