2026-06-18 01:28:43 -05:00
"use strict" ;
var _ _createBinding = ( this && this . _ _createBinding ) || ( Object . create ? ( function ( o , m , k , k2 ) {
if ( k2 === undefined ) k2 = k ;
var desc = Object . getOwnPropertyDescriptor ( m , k ) ;
if ( ! desc || ( "get" in desc ? ! m . _ _esModule : desc . writable || desc . configurable ) ) {
desc = { enumerable : true , get : function ( ) { return m [ k ] ; } } ;
}
Object . defineProperty ( o , k2 , desc ) ;
} ) : ( function ( o , m , k , k2 ) {
if ( k2 === undefined ) k2 = k ;
o [ k2 ] = m [ k ] ;
} ) ) ;
var _ _setModuleDefault = ( this && this . _ _setModuleDefault ) || ( Object . create ? ( function ( o , v ) {
Object . defineProperty ( o , "default" , { enumerable : true , value : v } ) ;
} ) : function ( o , v ) {
o [ "default" ] = v ;
} ) ;
var _ _importStar = ( this && this . _ _importStar ) || ( function ( ) {
var ownKeys = function ( o ) {
ownKeys = Object . getOwnPropertyNames || function ( o ) {
var ar = [ ] ;
for ( var k in o ) if ( Object . prototype . hasOwnProperty . call ( o , k ) ) ar [ ar . length ] = k ;
return ar ;
} ;
return ownKeys ( o ) ;
} ;
return function ( mod ) {
if ( mod && mod . _ _esModule ) return mod ;
var result = { } ;
if ( mod != null ) for ( var k = ownKeys ( mod ) , i = 0 ; i < k . length ; i ++ ) if ( k [ i ] !== "default" ) _ _createBinding ( result , mod , k [ i ] ) ;
_ _setModuleDefault ( result , mod ) ;
return result ;
} ;
} ) ( ) ;
Object . defineProperty ( exports , "__esModule" , { value : true } ) ;
2026-06-27 13:31:09 -05:00
exports . onGamePartFinished = exports . onGameSessionUpdate = void 0 ;
2026-06-18 01:28:43 -05:00
const functions = _ _importStar ( require ( "firebase-functions" ) ) ;
const admin = _ _importStar ( require ( "firebase-admin" ) ) ;
2026-06-28 10:00:25 -05:00
const quietHours _1 = require ( "../notifications/quietHours" ) ;
2026-06-18 01:28:43 -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 )
* /
exports . onGameSessionUpdate = functions . firestore
. document ( 'couples/{coupleId}/sessions/{sessionId}' )
. onWrite ( async ( change , context ) => {
2026-06-30 02:38:31 -05:00
var _a , _b , _c , _d , _e , _f , _g , _h ;
2026-06-18 01:28:43 -05:00
const { coupleId , sessionId } = context . params ;
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 01:28:43 -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 = ( _a = coupleDoc . data ( ) ) !== null && _a !== void 0 ? _a : { } ;
const userIds = ( ( _b = coupleData . userIds ) !== null && _b !== void 0 ? _b : [ ] ) ;
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' ;
const avatarA = ( _c = userA . data ( ) ) === null || _c === void 0 ? void 0 : _c . photoUrl ;
const avatarB = ( _d = userB . data ( ) ) === null || _d === void 0 ? void 0 : _d . photoUrl ;
2026-06-28 10:00:25 -05:00
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
const dataFor = ( uid ) => ( uid === partnerA ? userA . data ( ) : userB . data ( ) ) ;
2026-06-30 02:38:31 -05:00
const currentData = ( _e = change . after . data ( ) ) !== null && _e !== void 0 ? _e : { } ;
2026-06-26 20:04:05 -05:00
if ( ! change . after . exists )
return ; // deletion — nothing to notify
const status = currentData . status ;
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 ;
2026-06-30 02:38:31 -05:00
const gameType = ( _f = currentData . gameType ) !== null && _f !== void 0 ? _f : 'wheel' ;
2026-06-26 20:04:05 -05:00
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 ( ( 0 , quietHours _1 . 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 01:28:43 -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 . 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 : [ ] ;
if ( ! j . some ( ( u ) => u && u !== d . startedByUserId ) )
return false ;
tx . update ( sessionRef , { joinNotifiedAt : admin . firestore . FieldValue . serverTimestamp ( ) } ) ;
return true ;
} ) ;
if ( claimed ) {
2026-06-30 02:38:31 -05:00
const gameType = ( _g = currentData . gameType ) !== null && _g !== void 0 ? _g : 'wheel' ;
2026-06-28 22:24:46 -05:00
const joinerName = joiner === partnerA ? partnerAName : partnerBName ;
const joinerAvatar = joiner === partnerA ? avatarA : avatarB ;
if ( ( 0 , quietHours _1 . 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 ) {
2026-06-30 02:38:31 -05:00
const gt = ( _h = currentData . gameType ) !== null && _h !== void 0 ? _h : '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 ( ( 0 , quietHours _1 . 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 ( ( 0 , quietHours _1 . 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 01:28:43 -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' ] ;
exports . onGamePartFinished = functions . firestore
. document ( 'couples/{coupleId}/{gameType}/{sessionId}' )
. onWrite ( async ( change , context ) => {
2026-06-30 02:38:31 -05:00
var _a , _b , _c , _d , _e ;
2026-06-27 13:31:09 -05:00
const { coupleId , gameType , sessionId } = context . params ;
if ( ! ASYNC _GAME _COLLECTIONS . includes ( gameType ) )
return ; // ignore messages/reactions/etc.
if ( ! change . after . exists )
return ;
const answers = ( ( _b = ( _a = change . after . data ( ) ) === null || _a === void 0 ? void 0 : _a . answers ) !== null && _b !== void 0 ? _b : { } ) ;
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 = ( ( _d = ( _c = coupleDoc . data ( ) ) === null || _c === void 0 ? void 0 : _c . userIds ) !== null && _d !== void 0 ? _d : [ ] ) ;
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
const finisherAvatar = ( _e = finisher . data ( ) ) === null || _e === void 0 ? void 0 : _e . photoUrl ;
2026-06-27 13:31:09 -05:00
await notifyPartner ( db , messaging , recipient , finisherName , gameType , 'partner_completed_part' , ` ${ finisherName } finished their part — your turn to play! ` , coupleId , finisherAvatar , sessionId ) ;
} ) ;
2026-06-18 01:28:43 -05:00
/ * *
* Send notification to partner via FCM and write to notification _queue .
* /
2026-06-25 12:40:38 -05:00
async function notifyPartner ( db , messaging , partnerId , partnerName , gameType , notificationType , body , coupleId , senderAvatarUrl , sessionId ) {
2026-06-18 01:28:43 -05:00
var _a ;
2026-06-24 16:15:30 -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 01:28:43 -05:00
const notificationPayload = {
type : notificationType ,
2026-06-24 16:15:30 -05:00
title ,
2026-06-18 01:28:43 -05:00
body : body ,
} ;
// Write an in-app notification record for the partner
await db
. collection ( 'users' )
. doc ( partnerId )
. collection ( 'notification_queue' )
. add ( Object . assign ( Object . assign ( { } , notificationPayload ) , { read : false , createdAt : admin . firestore . FieldValue . serverTimestamp ( ) } ) ) ;
// Collect the partner's FCM tokens
const tokens = [ ] ;
const partnerUserDoc = await db . collection ( 'users' ) . doc ( partnerId ) . get ( ) ;
if ( partnerUserDoc . exists ) {
const legacyToken = ( _a = partnerUserDoc . data ( ) ) === null || _a === void 0 ? void 0 : _a . 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 ) => {
var _a ;
const t = ( _a = doc . data ( ) ) === null || _a === void 0 ? void 0 : _a . 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 = {
token : tokens [ 0 ] ,
notification : {
title : notificationPayload . title ,
body : notificationPayload . body ,
} ,
2026-06-25 12:40:38 -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-28 22:24:46 -05:00
data : Object . assign ( Object . assign ( { 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 } , ( sessionId ? { game _session _id : sessionId } : { } ) ) , ( senderAvatarUrl && senderAvatarUrl . length > 0
2026-06-24 16:15:30 -05:00
? { sender _avatar _url : senderAvatarUrl }
: { } ) ) ,
2026-06-18 01:28:43 -05:00
} ;
const sendResults = await Promise . allSettled ( tokens . map ( ( token ) => messaging . send ( Object . assign ( Object . assign ( { } , fcmMessage ) , { token } ) ) ) ) ;
const failures = [ ] ;
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 } ) ` ) ;
}
}
//# sourceMappingURL=onGameSessionUpdate.js.map