diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index 4b4f4928..2629e78b 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -79,6 +79,7 @@ class AppMessagingService : FirebaseMessagingService() { val channelId = when (type) { "partner_answered" -> NotificationHelper.CHANNEL_PARTNER + "partner_left" -> NotificationHelper.CHANNEL_PARTNER "daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS else -> NotificationHelper.CHANNEL_REMINDERS } @@ -97,6 +98,7 @@ class AppMessagingService : FirebaseMessagingService() { private fun resolveTitle(type: String): String? = when (type) { "daily_question" -> "Your daily question is waiting!" "partner_answered" -> "Your partner just answered!" + "partner_left" -> "Your partner has left" "streak" -> "Keep your streak going — answer today's question!" else -> null } @@ -104,6 +106,7 @@ class AppMessagingService : FirebaseMessagingService() { private fun resolveBody(type: String): String? = when (type) { "daily_question" -> "Tap to answer today's question together." "partner_answered" -> "See what your partner shared." + "partner_left" -> "You are no longer paired. Tap to create a new invite." "streak" -> "Don't break the chain. Open the app now." else -> null } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt index 2533694b..b6e9ff8d 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt @@ -2,6 +2,7 @@ package app.closer.data.remote import app.closer.domain.model.Couple import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions import kotlinx.coroutines.suspendCancellableCoroutine @@ -99,6 +100,8 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire } @Suppress("UNCHECKED_CAST") val allUserIds = (coupleSnap.get("userIds") as? List) ?: listOf(userId) + val partnerId = allUserIds.firstOrNull { it != userId } + suspendCancellableCoroutine { cont -> val batch = db.batch() allUserIds.forEach { uid -> @@ -108,8 +111,32 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire .addOnSuccessListener { cont.resume(Unit) } .addOnFailureListener { cont.resumeWithException(it) } } + + // After successfully clearing the couple state, queue an in-app notification + // for the partner so they see the unpair event even if FCM is delayed. + if (!partnerId.isNullOrBlank()) { + writePartnerLeftNotification(partnerId) + } } + private suspend fun writePartnerLeftNotification(partnerId: String): Unit = + suspendCancellableCoroutine { cont -> + db.collection(FirestoreCollections.USERS) + .document(partnerId) + .collection("notification_queue") + .add( + mapOf( + "type" to "partner_left", + "title" to "Your partner has left", + "body" to "You are no longer paired.", + "read" to false, + "createdAt" to FieldValue.serverTimestamp() + ) + ) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + @Suppress("UNCHECKED_CAST") private fun DocumentSnapshot.toCouple() = Couple( id = id, diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index a0bca2a1..ca11fe0a 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -42,6 +42,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import app.closer.core.navigation.AppRoute import app.closer.domain.model.Question import app.closer.domain.model.QuestionCategory @@ -54,9 +58,24 @@ fun HomeScreen( viewModel: HomeViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.partnerLeftEvent, state.partnerName) { + if (state.partnerLeftEvent) { + val partner = state.partnerName + val message = if (!partner.isNullOrBlank()) { + "You are no longer paired with $partner." + } else { + "You are no longer paired." + } + snackbarHostState.showSnackbar(message) + viewModel.consumePartnerLeftEvent() + } + } HomeContent( state = state, + snackbarHostState = snackbarHostState, onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) }, onPacks = { onNavigate(AppRoute.QUESTION_PACKS) }, onCategory = { categoryId -> onNavigate(AppRoute.questionCategory(categoryId)) }, @@ -70,6 +89,7 @@ fun HomeScreen( @Composable private fun HomeContent( state: HomeUiState, + snackbarHostState: SnackbarHostState, onDailyQuestion: () -> Unit, onPacks: () -> Unit, onCategory: (String) -> Unit, @@ -85,6 +105,12 @@ private fun HomeContent( closerBackgroundBrush() ) ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp) + ) Column( modifier = Modifier .fillMaxSize() @@ -706,6 +732,7 @@ fun HomeScreenPreview() { ) ) ), + snackbarHostState = remember { SnackbarHostState() }, onDailyQuestion = {}, onPacks = {}, onCategory = {}, diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index bf97e01c..94c778b6 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -11,6 +11,7 @@ import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.UserRepository +import com.google.firebase.firestore.FirebaseFirestore import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -71,7 +72,8 @@ data class HomeUiState( val streakCount: Int = 0, val isPaired: Boolean = false, val primaryAction: HomeAction? = null, - val secondaryActions: List = emptyList() + val secondaryActions: List = emptyList(), + val partnerLeftEvent: Boolean = false ) @HiltViewModel @@ -80,15 +82,25 @@ class HomeViewModel @Inject constructor( private val localAnswerRepository: LocalAnswerRepository, private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val db: FirebaseFirestore ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null + init { loadHome() observeAnswers() + observeCoupleState() + } + + override fun onCleared() { + super.onCleared() + coupleStateListener?.remove() + coupleStateListener = null } fun loadHome() { @@ -118,7 +130,8 @@ class HomeViewModel @Inject constructor( categories = categories, partnerName = partnerName, streakCount = couple?.streakCount ?: 0, - isPaired = couple != null + isPaired = couple != null, + partnerLeftEvent = false ).withHomeActions() } } catch (e: Exception) { @@ -132,6 +145,43 @@ class HomeViewModel @Inject constructor( } } + private fun observeCoupleState() { + val uid = authRepository.currentUserId ?: return + coupleStateListener?.remove() + coupleStateListener = db.collection("users").document(uid) + .addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error -> + if (error != null || snapshot == null || !snapshot.exists()) { + return@addSnapshotListener + } + val newCoupleId = snapshot.getString("coupleId") + val oldIsPaired = _uiState.value.isPaired + val oldPartnerName = _uiState.value.partnerName + val newIsPaired = !newCoupleId.isNullOrBlank() + + if (newIsPaired != oldIsPaired) { + // Capture partner name before reload so the banner can reference it. + val showPartnerLeftBanner = oldIsPaired && !newIsPaired + if (showPartnerLeftBanner) { + _uiState.update { + it.copy( + isPaired = false, + partnerName = null, + partnerLeftEvent = true + ).withHomeActions() + } + } + loadHome() + } + } + } + + /** + * Consumes the partner-left banner event. Call from the UI after showing it. + */ + fun consumePartnerLeftEvent() { + _uiState.update { it.copy(partnerLeftEvent = false) } + } + private fun observeAnswers() { viewModelScope.launch { localAnswerRepository.observeAnswers().collect { answers -> diff --git a/functions/dist/couples/onCoupleLeave.js b/functions/dist/couples/onCoupleLeave.js new file mode 100644 index 00000000..7ce5ee32 --- /dev/null +++ b/functions/dist/couples/onCoupleLeave.js @@ -0,0 +1,141 @@ +"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.onCoupleLeave = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +/** + * Firestore trigger that notifies the remaining partner when a user's coupleId + * field is cleared (i.e. the user left the couple or was removed). + * + * Path: users/{userId} + * Condition: previous coupleId was non-empty and new coupleId is null/missing. + */ +exports.onCoupleLeave = functions.firestore + .document('users/{userId}') + .onUpdate(async (change, context) => { + var _a, _b, _c, _d, _e, _f; + const { userId } = context.params; + const previousData = (_a = change.before.data()) !== null && _a !== void 0 ? _a : {}; + const currentData = (_b = change.after.data()) !== null && _b !== void 0 ? _b : {}; + const previousCoupleId = previousData.coupleId; + const currentCoupleId = currentData.coupleId; + // Only act when coupleId transitions from a real value to null/empty. + if (!previousCoupleId || typeof previousCoupleId !== 'string') { + return; + } + if (currentCoupleId) { + return; + } + const db = admin.firestore(); + const messaging = admin.messaging(); + const coupleDoc = await db.collection('couples').doc(previousCoupleId).get(); + if (!coupleDoc.exists) { + console.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`); + return; + } + const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); + const partnerId = userIds.find((uid) => uid !== userId); + if (!partnerId) { + console.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`); + return; + } + // Make sure the partner is still paired in this couple. + // If both users are leaving simultaneously, avoid duplicate/phantom notifications. + const partnerUserDoc = await db.collection('users').doc(partnerId).get(); + const partnerCoupleId = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.coupleId; + if (partnerCoupleId !== previousCoupleId) { + console.log(`[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification`); + return; + } + const notificationPayload = { + type: 'partner_left', + title: 'Your partner has left', + body: 'You are no longer paired. Tap to create a new invite.', + }; + // Write an in-app notification record for the partner. + // This is read-only denied for clients; the Cloud Function writes it. + 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 (legacy field + fcmTokens subcollection). + const tokens = []; + if (partnerUserDoc.exists) { + const legacyToken = (_f = partnerUserDoc.data()) === null || _f === void 0 ? void 0 : _f.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(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`); + return; + } + const fcmMessage = { + token: tokens[0], + notification: { + title: notificationPayload.title, + body: notificationPayload.body, + }, + data: { + type: notificationPayload.type, + }, + }; + 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(`[onCoupleLeave] some notifications failed:`, failures); + } + console.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`); +}); +//# sourceMappingURL=onCoupleLeave.js.map \ No newline at end of file diff --git a/functions/dist/couples/onCoupleLeave.js.map b/functions/dist/couples/onCoupleLeave.js.map new file mode 100644 index 00000000..99519e73 --- /dev/null +++ b/functions/dist/couples/onCoupleLeave.js.map @@ -0,0 +1 @@ +{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index 173ae4a6..563b64aa 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.health = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.health = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); // Initialize the Admin SDK once for every function in this codebase. @@ -58,6 +58,8 @@ Object.defineProperty(exports, "assignDailyQuestion", { enumerable: true, get: f Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } }); var onAnswerWritten_1 = require("./questions/onAnswerWritten"); Object.defineProperty(exports, "onAnswerWritten", { enumerable: true, get: function () { return onAnswerWritten_1.onAnswerWritten; } }); +var onCoupleLeave_1 = require("./couples/onCoupleLeave"); +Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.onCoupleLeave; } }); /** * Basic health check callable. * Useful for verifying function deployment and firebase-tools wiring. diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 444809b3..50fa23ba 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AAExB;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AAEtB;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/src/couples/onCoupleLeave.ts b/functions/src/couples/onCoupleLeave.ts new file mode 100644 index 00000000..ea2ac4bf --- /dev/null +++ b/functions/src/couples/onCoupleLeave.ts @@ -0,0 +1,130 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * Firestore trigger that notifies the remaining partner when a user's coupleId + * field is cleared (i.e. the user left the couple or was removed). + * + * Path: users/{userId} + * Condition: previous coupleId was non-empty and new coupleId is null/missing. + */ +export const onCoupleLeave = functions.firestore + .document('users/{userId}') + .onUpdate(async (change, context) => { + const { userId } = context.params as { userId: string } + + const previousData = change.before.data() ?? {} + const currentData = change.after.data() ?? {} + + const previousCoupleId = previousData.coupleId + const currentCoupleId = currentData.coupleId + + // Only act when coupleId transitions from a real value to null/empty. + if (!previousCoupleId || typeof previousCoupleId !== 'string') { + return + } + if (currentCoupleId) { + return + } + + const db = admin.firestore() + const messaging = admin.messaging() + + const coupleDoc = await db.collection('couples').doc(previousCoupleId).get() + if (!coupleDoc.exists) { + console.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`) + return + } + + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + const partnerId = userIds.find((uid) => uid !== userId) + if (!partnerId) { + console.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`) + return + } + + // Make sure the partner is still paired in this couple. + // If both users are leaving simultaneously, avoid duplicate/phantom notifications. + const partnerUserDoc = await db.collection('users').doc(partnerId).get() + const partnerCoupleId = partnerUserDoc.data()?.coupleId + if (partnerCoupleId !== previousCoupleId) { + console.log( + `[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification` + ) + return + } + + const notificationPayload = { + type: 'partner_left', + title: 'Your partner has left', + body: 'You are no longer paired. Tap to create a new invite.', + } + + // Write an in-app notification record for the partner. + // This is read-only denied for clients; the Cloud Function writes it. + await db + .collection('users') + .doc(partnerId) + .collection('notification_queue') + .add({ + ...notificationPayload, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + // Collect the partner's FCM tokens (legacy field + fcmTokens subcollection). + const tokens: string[] = [] + 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(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`) + return + } + + const fcmMessage: admin.messaging.Message = { + token: tokens[0], + notification: { + title: notificationPayload.title, + body: notificationPayload.body, + }, + data: { + type: notificationPayload.type, + }, + } + + 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(`[onCoupleLeave] some notifications failed:`, failures) + } + + console.log( + `[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}` + ) + }) diff --git a/functions/src/index.ts b/functions/src/index.ts index e65f70c5..b033894b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,6 +21,7 @@ export { assignDailyQuestionCallable, } from './questions/assignDailyQuestion' export { onAnswerWritten } from './questions/onAnswerWritten' +export { onCoupleLeave } from './couples/onCoupleLeave' /** * Basic health check callable.