feat: partner leave notification, real-time couple state sync, in-app unpair alerts

This commit is contained in:
null 2026-06-18 00:25:52 -05:00
parent eaac8ffcc9
commit c0696cfb80
10 changed files with 387 additions and 5 deletions

View File

@ -79,6 +79,7 @@ class AppMessagingService : FirebaseMessagingService() {
val channelId = when (type) { val channelId = when (type) {
"partner_answered" -> NotificationHelper.CHANNEL_PARTNER "partner_answered" -> NotificationHelper.CHANNEL_PARTNER
"partner_left" -> NotificationHelper.CHANNEL_PARTNER
"daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS "daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS
else -> NotificationHelper.CHANNEL_REMINDERS else -> NotificationHelper.CHANNEL_REMINDERS
} }
@ -97,6 +98,7 @@ class AppMessagingService : FirebaseMessagingService() {
private fun resolveTitle(type: String): String? = when (type) { private fun resolveTitle(type: String): String? = when (type) {
"daily_question" -> "Your daily question is waiting!" "daily_question" -> "Your daily question is waiting!"
"partner_answered" -> "Your partner just answered!" "partner_answered" -> "Your partner just answered!"
"partner_left" -> "Your partner has left"
"streak" -> "Keep your streak going — answer today's question!" "streak" -> "Keep your streak going — answer today's question!"
else -> null else -> null
} }
@ -104,6 +106,7 @@ class AppMessagingService : FirebaseMessagingService() {
private fun resolveBody(type: String): String? = when (type) { private fun resolveBody(type: String): String? = when (type) {
"daily_question" -> "Tap to answer today's question together." "daily_question" -> "Tap to answer today's question together."
"partner_answered" -> "See what your partner shared." "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." "streak" -> "Don't break the chain. Open the app now."
else -> null else -> null
} }

View File

@ -2,6 +2,7 @@ package app.closer.data.remote
import app.closer.domain.model.Couple import app.closer.domain.model.Couple
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -99,6 +100,8 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val allUserIds = (coupleSnap.get("userIds") as? List<String>) ?: listOf(userId) val allUserIds = (coupleSnap.get("userIds") as? List<String>) ?: listOf(userId)
val partnerId = allUserIds.firstOrNull { it != userId }
suspendCancellableCoroutine<Unit> { cont -> suspendCancellableCoroutine<Unit> { cont ->
val batch = db.batch() val batch = db.batch()
allUserIds.forEach { uid -> allUserIds.forEach { uid ->
@ -108,8 +111,32 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
.addOnSuccessListener { cont.resume(Unit) } .addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) } .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") @Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toCouple() = Couple( private fun DocumentSnapshot.toCouple() = Couple(
id = id, id = id,

View File

@ -42,6 +42,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.core.navigation.AppRoute
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory import app.closer.domain.model.QuestionCategory
@ -54,9 +58,24 @@ fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel() viewModel: HomeViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() 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( HomeContent(
state = state, state = state,
snackbarHostState = snackbarHostState,
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) }, onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
onPacks = { onNavigate(AppRoute.QUESTION_PACKS) }, onPacks = { onNavigate(AppRoute.QUESTION_PACKS) },
onCategory = { categoryId -> onNavigate(AppRoute.questionCategory(categoryId)) }, onCategory = { categoryId -> onNavigate(AppRoute.questionCategory(categoryId)) },
@ -70,6 +89,7 @@ fun HomeScreen(
@Composable @Composable
private fun HomeContent( private fun HomeContent(
state: HomeUiState, state: HomeUiState,
snackbarHostState: SnackbarHostState,
onDailyQuestion: () -> Unit, onDailyQuestion: () -> Unit,
onPacks: () -> Unit, onPacks: () -> Unit,
onCategory: (String) -> Unit, onCategory: (String) -> Unit,
@ -85,6 +105,12 @@ private fun HomeContent(
closerBackgroundBrush() closerBackgroundBrush()
) )
) { ) {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 48.dp)
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -706,6 +732,7 @@ fun HomeScreenPreview() {
) )
) )
), ),
snackbarHostState = remember { SnackbarHostState() },
onDailyQuestion = {}, onDailyQuestion = {},
onPacks = {}, onPacks = {},
onCategory = {}, onCategory = {},

View File

@ -11,6 +11,7 @@ import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import com.google.firebase.firestore.FirebaseFirestore
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -71,7 +72,8 @@ data class HomeUiState(
val streakCount: Int = 0, val streakCount: Int = 0,
val isPaired: Boolean = false, val isPaired: Boolean = false,
val primaryAction: HomeAction? = null, val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList() val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -80,15 +82,25 @@ class HomeViewModel @Inject constructor(
private val localAnswerRepository: LocalAnswerRepository, private val localAnswerRepository: LocalAnswerRepository,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository private val userRepository: UserRepository,
private val db: FirebaseFirestore
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
init { init {
loadHome() loadHome()
observeAnswers() observeAnswers()
observeCoupleState()
}
override fun onCleared() {
super.onCleared()
coupleStateListener?.remove()
coupleStateListener = null
} }
fun loadHome() { fun loadHome() {
@ -118,7 +130,8 @@ class HomeViewModel @Inject constructor(
categories = categories, categories = categories,
partnerName = partnerName, partnerName = partnerName,
streakCount = couple?.streakCount ?: 0, streakCount = couple?.streakCount ?: 0,
isPaired = couple != null isPaired = couple != null,
partnerLeftEvent = false
).withHomeActions() ).withHomeActions()
} }
} catch (e: Exception) { } 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() { private fun observeAnswers() {
viewModelScope.launch { viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers -> localAnswerRepository.observeAnswers().collect { answers ->

141
functions/dist/couples/onCoupleLeave.js vendored Normal file
View File

@ -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

View File

@ -0,0 +1 @@
{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":"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"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); 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 functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase. // 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; } }); Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } });
var onAnswerWritten_1 = require("./questions/onAnswerWritten"); var onAnswerWritten_1 = require("./questions/onAnswerWritten");
Object.defineProperty(exports, "onAnswerWritten", { enumerable: true, get: function () { return onAnswerWritten_1.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. * Basic health check callable.
* Useful for verifying function deployment and firebase-tools wiring. * Useful for verifying function deployment and firebase-tools wiring.

View File

@ -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"} {"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"}

View File

@ -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}`
)
})

View File

@ -21,6 +21,7 @@ export {
assignDailyQuestionCallable, assignDailyQuestionCallable,
} from './questions/assignDailyQuestion' } from './questions/assignDailyQuestion'
export { onAnswerWritten } from './questions/onAnswerWritten' export { onAnswerWritten } from './questions/onAnswerWritten'
export { onCoupleLeave } from './couples/onCoupleLeave'
/** /**
* Basic health check callable. * Basic health check callable.