2026-06-18 00:56:21 -05:00
|
|
|
import * as functions from 'firebase-functions'
|
|
|
|
|
import * as admin from 'firebase-admin'
|
2026-06-28 10:00:25 -05:00
|
|
|
import { recipientInQuietHours } from '../notifications/quietHours'
|
2026-06-18 00:56:21 -05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 }
|
|
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// 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
|
|
|
|
|
|
2026-06-18 00:56:21 -05:00
|
|
|
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()
|
2026-06-30 02:38:31 -05:00
|
|
|
// 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'
|
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-28 10:00:25 -05:00
|
|
|
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
|
|
|
|
|
const dataFor = (uid: string) => (uid === partnerA ? userA.data() : userB.data())
|
2026-06-18 00:56:21 -05:00
|
|
|
|
|
|
|
|
const currentData = change.after.data() ?? {}
|
2026-06-26 20:04:05 -05:00
|
|
|
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)
|
2026-06-18 00:56:21 -05:00
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// 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).
|
2026-06-18 00:56:21 -05:00
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// ── 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
|
2026-06-28 10:00:25 -05:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-26 20:04:05 -05:00
|
|
|
}
|
2026-06-18 00:56:21 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 22:24:46 -05:00
|
|
|
// ── 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// ── 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'
|
2026-06-28 10:00:25 -05:00
|
|
|
// 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
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-26 20:04:05 -05:00
|
|
|
}
|
2026-06-18 00:56:21 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-27 13:31:09 -05:00
|
|
|
/**
|
|
|
|
|
* 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<string, unknown>
|
|
|
|
|
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()
|
2026-06-30 02:38:31 -05:00
|
|
|
const finisherName = 'Your partner' // displayName is E2EE; the app shows the real name in-app
|
2026-06-27 13:31:09 -05:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-18 00:56:21 -05:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-06-25 12:34:43 -05:00
|
|
|
senderAvatarUrl?: string,
|
|
|
|
|
sessionId?: 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`
|
2026-06-27 13:31:09 -05:00
|
|
|
: notificationType === 'partner_completed_part'
|
|
|
|
|
? `${partnerName} finished their part`
|
2026-06-28 22:24:46 -05:00
|
|
|
: notificationType === 'partner_joined_game'
|
|
|
|
|
? `${partnerName} joined your 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,
|
|
|
|
|
},
|
2026-06-25 12:34:43 -05:00
|
|
|
// 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' } },
|
2026-06-18 00:56:21 -05:00
|
|
|
data: {
|
|
|
|
|
type: notificationPayload.type,
|
2026-06-22 08:53:23 -05:00
|
|
|
couple_id: coupleId,
|
|
|
|
|
game_type: gameType,
|
2026-06-28 22:24:46 -05:00
|
|
|
// 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,
|
2026-06-25 12:34:43 -05:00
|
|
|
// 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 } : {}),
|
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})`)
|
|
|
|
|
}
|
|
|
|
|
}
|