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 } ) ;
exports . onGameSessionUpdate = void 0 ;
const functions = _ _importStar ( require ( "firebase-functions" ) ) ;
const admin = _ _importStar ( require ( "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 )
* /
exports . onGameSessionUpdate = functions . firestore
. document ( 'couples/{coupleId}/sessions/{sessionId}' )
. onWrite ( async ( change , context ) => {
2026-06-26 20:04:05 -05:00
var _a , _b , _c , _d , _e , _f , _g , _h , _j , _k , _l ;
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 ] ;
// 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 = ( _d = ( _c = userA . data ( ) ) === null || _c === void 0 ? void 0 : _c . displayName ) !== null && _d !== void 0 ? _d : 'Partner A' ;
const partnerBName = ( _f = ( _e = userB . data ( ) ) === null || _e === void 0 ? void 0 : _e . displayName ) !== null && _f !== void 0 ? _f : 'Partner B' ;
2026-06-24 16:15:30 -05:00
const avatarA = ( _g = userA . data ( ) ) === null || _g === void 0 ? void 0 : _g . photoUrl ;
const avatarB = ( _h = userB . data ( ) ) === null || _h === void 0 ? void 0 : _h . photoUrl ;
2026-06-26 20:04:05 -05:00
const currentData = ( _j = change . after . data ( ) ) !== null && _j !== void 0 ? _j : { } ;
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 ;
const gameType = ( _k = currentData . gameType ) !== null && _k !== void 0 ? _k : 'wheel' ;
const recipientId = startedBy === partnerA ? partnerB : partnerA ;
const starterName = startedBy === partnerA ? partnerAName : partnerBName ;
const starterAvatar = startedBy === partnerA ? avatarA : avatarB ;
await notifyPartner ( db , messaging , recipientId , starterName , gameType , 'partner_started_game' , ` ${ starterName } has started a game. Tap to join! ` , coupleId , starterAvatar , sessionId ) ;
}
2026-06-18 01:28:43 -05:00
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 = ( _l = currentData . gameType ) !== null && _l !== void 0 ? _l : 'wheel' ;
// Notify BOTH partners, each naming the OTHER.
await notifyPartner ( db , messaging , partnerA , partnerBName , gt , 'partner_finished_game' , ` ${ partnerBName } finished — tap to see your results! ` , coupleId , avatarB , sessionId ) ;
await notifyPartner ( db , messaging , partnerB , partnerAName , gt , 'partner_finished_game' , ` ${ partnerAName } finished — tap to see your results! ` , coupleId , avatarA , sessionId ) ;
}
2026-06-18 01:28:43 -05:00
return ;
}
} ) ;
/ * *
* 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 `
: ` ${ 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' } } ,
data : Object . assign ( Object . assign ( { type : notificationPayload . type , couple _id : coupleId , game _type : gameType } , ( 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