feat: partner leave notification, real-time couple state sync, in-app unpair alerts
This commit is contained in:
parent
eaac8ffcc9
commit
c0696cfb80
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>) ?: listOf(userId)
|
||||
val partnerId = allUserIds.firstOrNull { it != userId }
|
||||
|
||||
suspendCancellableCoroutine<Unit> { 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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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<HomeAction> = emptyList()
|
||||
val secondaryActions: List<HomeAction> = 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<HomeUiState> = _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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;GAMG;AACU,QAAA,aAAa,GAAG,SAAS,CAAC,SAAS;KAC7C,QAAQ,CAAC,gBAAgB,CAAC;KAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;;IAClC,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAA4B,CAAA;IAEvD,MAAM,YAAY,GAAG,MAAA,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,mCAAI,EAAE,CAAA;IAC/C,MAAM,WAAW,GAAG,MAAA,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,mCAAI,EAAE,CAAA;IAE7C,MAAM,gBAAgB,GAAG,YAAY,CAAC,QAAQ,CAAA;IAC9C,MAAM,eAAe,GAAG,WAAW,CAAC,QAAQ,CAAA;IAE5C,sEAAsE;IACtE,IAAI,CAAC,gBAAgB,IAAI,OAAO,gBAAgB,KAAK,QAAQ,EAAE,CAAC;QAC9D,OAAM;IACR,CAAC;IACD,IAAI,eAAe,EAAE,CAAC;QACpB,OAAM;IACR,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAEnC,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,GAAG,EAAE,CAAA;IAC5E,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,OAAO,CAAC,IAAI,CAAC,0BAA0B,gBAAgB,YAAY,CAAC,CAAA;QACpE,OAAM;IACR,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,MAAM,CAAC,CAAA;IACvD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,CAAC,IAAI,CAAC,+CAA+C,gBAAgB,EAAE,CAAC,CAAA;QAC/E,OAAM;IACR,CAAC;IAED,wDAAwD;IACxD,mFAAmF;IACnF,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,MAAM,eAAe,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IACvD,IAAI,eAAe,KAAK,gBAAgB,EAAE,CAAC;QACzC,OAAO,CAAC,GAAG,CACT,2BAA2B,SAAS,2BAA2B,gBAAgB,yBAAyB,CACzG,CAAA;QACD,OAAM;IACR,CAAC;IAED,MAAM,mBAAmB,GAAG;QAC1B,IAAI,EAAE,cAAc;QACpB,KAAK,EAAE,uBAAuB;QAC9B,IAAI,EAAE,uDAAuD;KAC9D,CAAA;IAED,uDAAuD;IACvD,sEAAsE;IACtE,MAAM,EAAE;SACL,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,oBAAoB,CAAC;SAChC,GAAG,iCACC,mBAAmB,KACtB,IAAI,EAAE,KAAK,EACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE,IACvD,CAAA;IAEJ,6EAA6E;IAC7E,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,IAAI,cAAc,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,WAAW,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;QACnD,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC9D,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC1B,CAAC;IACH,CAAC;IAED,MAAM,aAAa,GAAG,MAAM,EAAE;SAC3B,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,WAAW,CAAC;SACvB,GAAG,EAAE,CAAA;IACR,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QACjC,MAAM,CAAC,GAAG,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QAC3B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACjE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAChB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,GAAG,CAAC,6CAA6C,SAAS,EAAE,CAAC,CAAA;QACrE,OAAM;IACR,CAAC;IAED,MAAM,UAAU,GAA4B;QAC1C,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;QAChB,YAAY,EAAE;YACZ,KAAK,EAAE,mBAAmB,CAAC,KAAK;YAChC,IAAI,EAAE,mBAAmB,CAAC,IAAI;SAC/B;QACD,IAAI,EAAE;YACJ,IAAI,EAAE,mBAAmB,CAAC,IAAI;SAC/B;KACF,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,UAAU,CAC1C,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,iCAAM,UAAU,KAAE,KAAK,IAAG,CAAC,CAChE,CAAA;IAED,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE;QACpC,IAAI,MAAM,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YACjC,QAAQ,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAC7D,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,KAAK,CAAC,4CAA4C,EAAE,QAAQ,CAAC,CAAA;IACvE,CAAC;IAED,OAAO,CAAC,GAAG,CACT,oCAAoC,SAAS,cAAc,MAAM,gBAAgB,gBAAgB,EAAE,CACpG,CAAA;AACH,CAAC,CAAC,CAAA"}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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}`
|
||||
)
|
||||
})
|
||||
|
|
@ -21,6 +21,7 @@ export {
|
|||
assignDailyQuestionCallable,
|
||||
} from './questions/assignDailyQuestion'
|
||||
export { onAnswerWritten } from './questions/onAnswerWritten'
|
||||
export { onCoupleLeave } from './couples/onCoupleLeave'
|
||||
|
||||
/**
|
||||
* Basic health check callable.
|
||||
|
|
|
|||
Loading…
Reference in New Issue