From 0e75b3b536ac6fb1be6234e882c0952d50da5456 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 23:47:01 -0500 Subject: [PATCH] feat: add gentle reminder callable and wire into HomeViewModel (batch v1.0.12) - GENTLE_REMINDER notification type with warm copy - sendGentleReminderCallable Cloud Function - HomeViewModel.sendGentleReminder() calls function, shows snackbar - Snackbar event consumed after display --- .../PartnerNotificationManager.kt | 8 + .../java/app/closer/ui/home/HomeScreen.kt | 7 + .../java/app/closer/ui/home/HomeViewModel.kt | 21 ++- functions/dist/index.js | 4 +- functions/dist/index.js.map | 2 +- .../sendGentleReminderCallable.js | 144 ++++++++++++++++++ .../sendGentleReminderCallable.js.map | 1 + functions/src/index.ts | 1 + .../sendGentleReminderCallable.ts | 134 ++++++++++++++++ 9 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 functions/dist/notifications/sendGentleReminderCallable.js create mode 100644 functions/dist/notifications/sendGentleReminderCallable.js.map create mode 100644 functions/src/notifications/sendGentleReminderCallable.ts diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index ed6eb078..7673db4e 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -174,6 +174,12 @@ enum class PartnerNotificationType( body = "Something you sealed together is ready to open.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER + ), + GENTLE_REMINDER( + title = "Your partner is thinking about you.", + body = "They left tonight's question open. Answer when you're ready.", + channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, + rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ); /** @@ -186,6 +192,7 @@ enum class PartnerNotificationType( PARTNER_COMPLETED_PART -> AppRoute.PLAY CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE + GENTLE_REMINDER -> AppRoute.DAILY_QUESTION } companion object { @@ -200,6 +207,7 @@ enum class PartnerNotificationType( "partner_completed_part" -> PARTNER_COMPLETED_PART "challenge_waiting" -> CHALLENGE_WAITING "memory_capsule_unlocked" -> CAPSULE_UNLOCKED + "gentle_reminder" -> GENTLE_REMINDER else -> null } } 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 7707639c..e302a715 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -77,6 +77,13 @@ fun HomeScreen( } } + LaunchedEffect(state.reminderSentEvent) { + if (state.reminderSentEvent) { + snackbarHostState.showSnackbar("Reminder sent to your partner.") + viewModel.consumeReminderSentEvent() + } + } + LaunchedEffect(state.needsRecovery) { if (state.needsRecovery) { onNavigate(app.closer.core.navigation.AppRoute.RECOVERY) 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 5b081421..e0d7e41f 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -20,6 +20,7 @@ import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.UserRepository import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.functions.FirebaseFunctions import dagger.hilt.android.lifecycle.HiltViewModel import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput import app.closer.ui.home.HomePriorityEngine.Priority @@ -28,6 +29,7 @@ import java.time.LocalDate import javax.inject.Inject import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.tasks.await import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -122,7 +124,8 @@ data class HomeUiState( val hasActiveChallenge: Boolean = false, val hasUpcomingDatePlan: Boolean = false, val hasUnlockedCapsule: Boolean = false, - val weeklyRecapReady: Boolean = false + val weeklyRecapReady: Boolean = false, + val reminderSentEvent: Boolean = false ) @HiltViewModel @@ -134,6 +137,7 @@ class HomeViewModel @Inject constructor( private val userRepository: UserRepository, private val encryptionManager: CoupleEncryptionManager, private val db: FirebaseFirestore, + private val functions: FirebaseFunctions, private val questionSessionRepository: QuestionSessionRepository, private val challengeDataSource: FirestoreChallengeDataSource, private val capsuleDataSource: FirestoreCapsuleDataSource, @@ -309,9 +313,22 @@ class HomeViewModel @Inject constructor( * Notification wiring is intentionally a no-op until Batch 6. */ fun sendGentleReminder() { - // TODO(Batch 6): wire partner-triggered notification via FCM. + // Only meaningful when we have answered but partner hasn't. + val state = _uiState.value + if (state.dailyQuestionState != DailyQuestionState.USER_ANSWERED_PARTNER_PENDING) return + viewModelScope.launch { + runCatching { + functions.getHttpsCallable("sendGentleReminderCallable").call().await() + }.onSuccess { + _uiState.update { it.copy(reminderSentEvent = true) } + }.onFailure { e -> + Log.w(TAG, "Gentle reminder failed", e) + } + } } + fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) } + private fun observeAnswers() { viewModelScope.launch { localAnswerRepository.observeAnswers().collect { answers -> diff --git a/functions/dist/index.js b/functions/dist/index.js index 53993f1b..1202b19a 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.onGameSessionUpdate = exports.onUserDelete = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = 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. @@ -49,6 +49,8 @@ Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: funct var reminders_1 = require("./notifications/reminders"); Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } }); Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } }); +var sendGentleReminderCallable_1 = require("./notifications/sendGentleReminderCallable"); +Object.defineProperty(exports, "sendGentleReminderCallable", { enumerable: true, get: function () { return sendGentleReminderCallable_1.sendGentleReminderCallable; } }); var gameRetention_1 = require("./notifications/gameRetention"); Object.defineProperty(exports, "sendChallengeDayReminders", { enumerable: true, get: function () { return gameRetention_1.sendChallengeDayReminders; } }); Object.defineProperty(exports, "unlockDueMemoryCapsules", { enumerable: true, get: function () { return gameRetention_1.unlockDueMemoryCapsules; } }); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 00e23b56..99515f6a 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,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,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;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;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,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,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;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;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/dist/notifications/sendGentleReminderCallable.js b/functions/dist/notifications/sendGentleReminderCallable.js new file mode 100644 index 00000000..b773cc8d --- /dev/null +++ b/functions/dist/notifications/sendGentleReminderCallable.js @@ -0,0 +1,144 @@ +"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.sendGentleReminderCallable = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +/** + * Sends a gentle nudge from one partner to the other when the caller has + * already answered today's question but the partner hasn't. + * + * Rate limit: one reminder per couple per calendar day (UTC). The lock is + * stored in couples/{coupleId}/gentle_reminders/{date} so it survives + * function restarts and is visible to both partners. + * + * The notification is both an FCM push (for the system tray) and an entry in + * the partner's notification_queue (for in-app display). + */ +exports.sendGentleReminderCallable = functions.https.onCall(async (_data, context) => { + var _a, _b, _c, _d, _e; + const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); + } + const db = admin.firestore(); + // ── 1. Resolve couple + partner ────────────────────────────────────────── + const userDoc = await db.collection('users').doc(callerId).get(); + const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId; + if (!coupleId) { + throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.'); + } + const coupleDoc = await db.collection('couples').doc(coupleId).get(); + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.'); + } + const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); + const partnerId = userIds.find((id) => id !== callerId); + if (!partnerId) { + throw new functions.https.HttpsError('failed-precondition', 'No partner found.'); + } + // ── 2. Rate limit: one per couple per day ──────────────────────────────── + const today = new Date().toISOString().slice(0, 10); // e.g. "2026-06-19" + const lockRef = db + .collection('couples') + .doc(coupleId) + .collection('gentle_reminders') + .doc(today); + const existingLock = await lockRef.get(); + if (existingLock.exists) { + return { sent: false, reason: 'already_sent_today' }; + } + // ── 3. Collect partner FCM tokens ──────────────────────────────────────── + const tokens = []; + const partnerDoc = await db.collection('users').doc(partnerId).get(); + if (partnerDoc.exists) { + const legacyToken = (_e = partnerDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken; + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken); + } + } + const tokenSnap = await db + .collection('users') + .doc(partnerId) + .collection('fcmTokens') + .get(); + tokenSnap.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); + } + }); + // ── 4. Write in-app notification record ────────────────────────────────── + await db + .collection('users') + .doc(partnerId) + .collection('notification_queue') + .add({ + type: 'gentle_reminder', + title: 'Your partner is thinking about you.', + body: "They left tonight's question open. Answer when you're ready.", + read: false, + sent: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + // ── 5. Claim the daily rate-limit lock ─────────────────────────────────── + await lockRef.set({ + sentBy: callerId, + sentAt: admin.firestore.FieldValue.serverTimestamp(), + }); + // ── 6. Send FCM push ───────────────────────────────────────────────────── + if (tokens.length > 0) { + const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send({ + token, + notification: { + title: 'Your partner is thinking about you.', + body: "They left tonight's question open. Answer when you're ready.", + }, + data: { + type: 'gentle_reminder', + couple_id: coupleId, + }, + }))); + sendResults.forEach((result, i) => { + if (result.status === 'rejected') { + console.warn(`[sendGentleReminderCallable] FCM failed for token ${tokens[i]}:`, result.reason); + } + }); + } + console.log(`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`); + return { sent: true }; +}); +//# sourceMappingURL=sendGentleReminderCallable.js.map \ No newline at end of file diff --git a/functions/dist/notifications/sendGentleReminderCallable.js.map b/functions/dist/notifications/sendGentleReminderCallable.js.map new file mode 100644 index 00000000..a004d54b --- /dev/null +++ b/functions/dist/notifications/sendGentleReminderCallable.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sendGentleReminderCallable.js","sourceRoot":"","sources":["../../src/notifications/sendGentleReminderCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts index 56688f5a..295e89bb 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -14,6 +14,7 @@ export { sendDailyQuestionReminder, sendPartnerAnsweredNotification, } from './notifications/reminders' +export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable' export { sendChallengeDayReminders, unlockDueMemoryCapsules, diff --git a/functions/src/notifications/sendGentleReminderCallable.ts b/functions/src/notifications/sendGentleReminderCallable.ts new file mode 100644 index 00000000..a39c3a11 --- /dev/null +++ b/functions/src/notifications/sendGentleReminderCallable.ts @@ -0,0 +1,134 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * Sends a gentle nudge from one partner to the other when the caller has + * already answered today's question but the partner hasn't. + * + * Rate limit: one reminder per couple per calendar day (UTC). The lock is + * stored in couples/{coupleId}/gentle_reminders/{date} so it survives + * function restarts and is visible to both partners. + * + * The notification is both an FCM push (for the system tray) and an entry in + * the partner's notification_queue (for in-app display). + */ +export const sendGentleReminderCallable = functions.https.onCall(async (_data, context) => { + const callerId = context.auth?.uid + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') + } + + const db = admin.firestore() + + // ── 1. Resolve couple + partner ────────────────────────────────────────── + + const userDoc = await db.collection('users').doc(callerId).get() + const coupleId = userDoc.data()?.coupleId as string | undefined + if (!coupleId) { + throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.') + } + + const coupleDoc = await db.collection('couples').doc(coupleId).get() + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.') + } + + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + const partnerId = userIds.find((id) => id !== callerId) + if (!partnerId) { + throw new functions.https.HttpsError('failed-precondition', 'No partner found.') + } + + // ── 2. Rate limit: one per couple per day ──────────────────────────────── + + const today = new Date().toISOString().slice(0, 10) // e.g. "2026-06-19" + const lockRef = db + .collection('couples') + .doc(coupleId) + .collection('gentle_reminders') + .doc(today) + + const existingLock = await lockRef.get() + if (existingLock.exists) { + return { sent: false, reason: 'already_sent_today' } + } + + // ── 3. Collect partner FCM tokens ──────────────────────────────────────── + + const tokens: string[] = [] + const partnerDoc = await db.collection('users').doc(partnerId).get() + if (partnerDoc.exists) { + const legacyToken = partnerDoc.data()?.fcmToken + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken) + } + } + + const tokenSnap = await db + .collection('users') + .doc(partnerId) + .collection('fcmTokens') + .get() + tokenSnap.docs.forEach((doc) => { + const t = doc.data()?.token + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { + tokens.push(t) + } + }) + + // ── 4. Write in-app notification record ────────────────────────────────── + + await db + .collection('users') + .doc(partnerId) + .collection('notification_queue') + .add({ + type: 'gentle_reminder', + title: 'Your partner is thinking about you.', + body: "They left tonight's question open. Answer when you're ready.", + read: false, + sent: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + // ── 5. Claim the daily rate-limit lock ─────────────────────────────────── + + await lockRef.set({ + sentBy: callerId, + sentAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + // ── 6. Send FCM push ───────────────────────────────────────────────────── + + if (tokens.length > 0) { + const sendResults = await Promise.allSettled( + tokens.map((token) => + admin.messaging().send({ + token, + notification: { + title: 'Your partner is thinking about you.', + body: "They left tonight's question open. Answer when you're ready.", + }, + data: { + type: 'gentle_reminder', + couple_id: coupleId, + }, + }) + ) + ) + + sendResults.forEach((result, i) => { + if (result.status === 'rejected') { + console.warn( + `[sendGentleReminderCallable] FCM failed for token ${tokens[i]}:`, + result.reason + ) + } + }) + } + + console.log( + `[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}` + ) + return { sent: true } +})