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 2f1ac2a7..f3d30389 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -81,6 +81,7 @@ class AppMessagingService : FirebaseMessagingService() { "partner_answered" -> NotificationHelper.CHANNEL_PARTNER "partner_left" -> NotificationHelper.CHANNEL_PARTNER "partner_started_game", "partner_finished_game", "partner_waiting" -> NotificationHelper.CHANNEL_PARTNER + "memory_capsule_unlocked", "challenge_day_ready" -> NotificationHelper.CHANNEL_REMINDERS "daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS else -> NotificationHelper.CHANNEL_REMINDERS } @@ -104,6 +105,8 @@ class AppMessagingService : FirebaseMessagingService() { "partner_started_game" -> "Partner is playing!" "partner_finished_game" -> "Partner finished!" "partner_waiting" -> "Partner waiting" + "memory_capsule_unlocked" -> "Your memory capsule opened" + "challenge_day_ready" -> "Today's challenge is ready" else -> null } @@ -115,6 +118,8 @@ class AppMessagingService : FirebaseMessagingService() { "partner_started_game" -> "Your partner has started a game. Tap to join!" "partner_finished_game" -> "Your partner has finished. Tap to see results!" "partner_waiting" -> "Your partner is waiting for you to finish." + "memory_capsule_unlocked" -> "Open Memory Lane to read it together." + "challenge_day_ready" -> "Open your connection challenge for today's prompt." else -> null } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 728215d4..6bf8e6ab 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -85,7 +85,34 @@ import kotlinx.coroutines.launch // ── ViewModel ──────────────────────────────────────────────────────────────── -enum class TotPhase { LOADING, PLAYING, WAITING, REVEAL, ERROR } +enum class TotPhase { LOADING, PICK_MOOD, PLAYING, WAITING, REVEAL, ERROR } + +enum class TotMood( + val title: String, + val subtitle: String, + val categoryIds: Set? +) { + LIGHT( + title = "Light", + subtitle = "Playful, warm, easy picks", + categoryIds = setOf("fun", "date_night", "gratitude", "home_life") + ), + EVERYDAY( + title = "Everyday", + subtitle = "Home, values, plans, small rhythms", + categoryIds = setOf("communication", "home_life", "values", "future", "money") + ), + DEEP( + title = "Deep", + subtitle = "Trust, repair, commitment, closeness", + categoryIds = setOf("emotional_intimacy", "trust", "conflict_repair", "difficult_conversations", "marriage") + ), + ALL( + title = "All topics", + subtitle = "Shuffle the whole question bank", + categoryIds = null + ) +} /** One prompt's outcome: what each partner picked and whether they matched. */ data class RevealCard( @@ -156,16 +183,27 @@ class ThisOrThatViewModel @Inject constructor( // A different game is already in progress — respect the one-game lock. _uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) } else -> - createSession(uid) + _uiState.update { it.copy(phase = TotPhase.PICK_MOOD) } } } } + fun chooseMood(mood: TotMood) { + val uid = userId ?: return fail("You need to be signed in to play.") + _uiState.update { it.copy(phase = TotPhase.LOADING, error = null) } + viewModelScope.launch { createSession(uid, mood) } + } + /** First partner: pick a fixed set of prompts and open the shared session. */ - private suspend fun createSession(uid: String) { - val picked = runCatching { repository.getQuestionsByType("this_or_that") } + private suspend fun createSession(uid: String, mood: TotMood) { + val allQuestions = runCatching { repository.getQuestionsByType("this_or_that") } .onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) } .getOrElse { emptyList() } + val moodQuestions = mood.categoryIds + ?.let { categoryIds -> allQuestions.filter { it.category in categoryIds } } + ?: allQuestions + val pool = moodQuestions.takeIf { it.size >= SESSION_SIZE } ?: allQuestions + val picked = pool .shuffled() .take(SESSION_SIZE) if (picked.isEmpty()) return fail("No questions available.") @@ -377,6 +415,10 @@ fun ThisOrThatScreen( onBack = viewModel::quit, onAbandon = viewModel::abandon ) + TotPhase.PICK_MOOD -> ThisOrThatMoodPicker( + onMoodSelected = viewModel::chooseMood, + onBack = viewModel::quit + ) TotPhase.REVEAL -> ThisOrThatReveal( matched = state.matchedCount, total = state.revealCards.size, @@ -409,6 +451,101 @@ fun ThisOrThatScreen( } } +@Composable +private fun ThisOrThatMoodPicker( + onMoodSelected: (TotMood) -> Unit, + onBack: () -> Unit +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(bottom = 20.dp) + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Choose a mood", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = "Your partner will join the same set.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(onClick = onBack) { + Text("Back", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + items(TotMood.values().toList()) { mood -> + Card( + onClick = { onMoodSelected(mood) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.9f)), + elevation = CardDefaults.cardElevation(defaultElevation = 5.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(44.dp), + shape = CircleShape, + color = CloserPalette.PurpleMist + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = when (mood) { + TotMood.LIGHT -> "1" + TotMood.EVERYDAY -> "2" + TotMood.DEEP -> "3" + TotMood.ALL -> "All" + }, + style = MaterialTheme.typography.titleMedium, + color = CloserPalette.PurpleDeep, + fontWeight = FontWeight.Bold + ) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = mood.title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = mood.subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } +} + @Composable private fun ThisOrThatContent( question: Question, diff --git a/functions/dist/index.js b/functions/dist/index.js index 5a785117..f79aaac7 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.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.health = exports.onGameSessionUpdate = 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; 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,9 @@ 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 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; } }); var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity"); Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } }); var createDateMatch_1 = require("./dates/createDateMatch"); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 0c60bb53..9af30036 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;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,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,+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,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/gameRetention.js b/functions/dist/notifications/gameRetention.js new file mode 100644 index 00000000..1f43d8fc --- /dev/null +++ b/functions/dist/notifications/gameRetention.js @@ -0,0 +1,231 @@ +"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.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +const DAY_MS = 24 * 60 * 60 * 1000; +const CHALLENGE_TITLES = { + gratitude_week: { title: 'Gratitude Week', durationDays: 7 }, + appreciation_notes: { title: 'Appreciation Notes', durationDays: 7 }, + quality_time: { title: 'Quality Time', durationDays: 7 }, + deep_conversations: { title: 'Deep Conversations', durationDays: 7 }, +}; +exports.unlockDueMemoryCapsules = functions.pubsub + .schedule('every 1 hours') + .onRun(async () => { + const db = admin.firestore(); + const messaging = admin.messaging(); + const now = Date.now(); + const snapshot = await db + .collectionGroup('capsules') + .where('status', '==', 'sealed') + .where('unlockAt', '<=', now) + .limit(100) + .get(); + const notifications = []; + for (const capsuleDoc of snapshot.docs) { + const capsuleRef = capsuleDoc.ref; + const coupleRef = capsuleRef.parent.parent; + if (!coupleRef) + continue; + const capsuleNotifications = await db.runTransaction(async (tx) => { + var _a, _b, _c, _d; + const freshCapsule = await tx.get(capsuleRef); + const capsule = (_a = freshCapsule.data()) !== null && _a !== void 0 ? _a : {}; + if (capsule.status !== 'sealed' || Number((_b = capsule.unlockAt) !== null && _b !== void 0 ? _b : 0) > now) { + return []; + } + const coupleDoc = await tx.get(coupleRef); + const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); + if (userIds.length === 0) + return []; + tx.update(capsuleRef, { + status: 'unlocked', + unlockedAt: now, + unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + const capsuleId = capsuleRef.id; + const coupleId = coupleRef.id; + const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0 + ? capsule.title.trim() + : 'A memory capsule'; + return userIds.map((userId) => ({ + userId, + type: 'memory_capsule_unlocked', + title: 'Your memory capsule opened', + body: `${title} is ready to read together.`, + data: { coupleId, capsuleId }, + })); + }); + notifications.push(...capsuleNotifications); + } + await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))); + console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`); +}); +exports.sendChallengeDayReminders = functions.pubsub + .schedule('every 24 hours') + .onRun(async () => { + const db = admin.firestore(); + const messaging = admin.messaging(); + const now = Date.now(); + const snapshot = await db + .collectionGroup('challenges') + .where('status', '==', 'active') + .limit(100) + .get(); + const notifications = []; + for (const challengeDoc of snapshot.docs) { + const challengeRef = challengeDoc.ref; + const coupleRef = challengeRef.parent.parent; + if (!coupleRef) + continue; + const challengeNotifications = await db.runTransaction(async (tx) => { + var _a, _b, _c, _d, _e, _f, _g; + const freshChallenge = await tx.get(challengeRef); + const challenge = (_a = freshChallenge.data()) !== null && _a !== void 0 ? _a : {}; + if (challenge.status !== 'active') + return []; + const startedAt = Number((_b = challenge.startedAt) !== null && _b !== void 0 ? _b : 0); + if (startedAt <= 0 || startedAt > now) + return []; + const challengeId = typeof challenge.challengeId === 'string' + ? challenge.challengeId + : challengeRef.id; + const catalogEntry = (_c = CHALLENGE_TITLES[challengeId]) !== null && _c !== void 0 ? _c : { + title: 'Connection Challenge', + durationDays: 7, + }; + const day = Math.floor((now - startedAt) / DAY_MS) + 1; + if (day < 1 || day > catalogEntry.durationDays) + return []; + const coupleDoc = await tx.get(coupleRef); + const userIds = ((_e = (_d = coupleDoc.data()) === null || _d === void 0 ? void 0 : _d.userIds) !== null && _e !== void 0 ? _e : []); + if (userIds.length === 0) + return []; + const completions = ((_f = challenge.completions) !== null && _f !== void 0 ? _f : {}); + const reminderSent = ((_g = challenge.challengeReminderSent) !== null && _g !== void 0 ? _g : {}); + const dueUserIds = userIds.filter((userId) => { + var _a; + const completedDays = (_a = completions[userId]) !== null && _a !== void 0 ? _a : []; + const alreadyCompleted = completedDays.map(Number).includes(day); + const alreadySent = reminderSent[reminderKey(userId, day)] === true; + return !alreadyCompleted && !alreadySent; + }); + if (dueUserIds.length === 0) + return []; + const updates = { + lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(), + }; + dueUserIds.forEach((userId) => { + updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true; + }); + tx.update(challengeRef, updates); + const coupleId = coupleRef.id; + return dueUserIds.map((userId) => ({ + userId, + type: 'challenge_day_ready', + title: `Day ${day} is ready`, + body: `${catalogEntry.title}: today's connection prompt is waiting.`, + data: { coupleId, challengeId, day: String(day) }, + })); + }); + notifications.push(...challengeNotifications); + } + await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))); + console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`); +}); +function reminderKey(userId, day) { + return `${userId.replace(/[^\w-]/g, '_')}_${day}`; +} +async function sendNotification(db, messaging, notification) { + await db + .collection('users') + .doc(notification.userId) + .collection('notification_queue') + .add({ + type: notification.type, + title: notification.title, + body: notification.body, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + const tokens = await getUserTokens(db, notification.userId); + if (tokens.length === 0) { + console.log(`[sendNotification] no FCM tokens for ${notification.userId}`); + return; + } + const message = { + token: tokens[0], + notification: { + title: notification.title, + body: notification.body, + }, + data: Object.assign({ type: notification.type }, notification.data), + }; + const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token })))); + const failures = []; + sendResults.forEach((result, index) => { + if (result.status === 'rejected') { + failures.push(`${tokens[index]}: ${String(result.reason)}`); + } + }); + if (failures.length > 0) { + console.error(`[sendNotification] some notifications failed:`, failures); + } +} +async function getUserTokens(db, userId) { + var _a; + const tokens = []; + const userDoc = await db.collection('users').doc(userId).get(); + const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken); + } + const tokenSnapshot = await db + .collection('users') + .doc(userId) + .collection('fcmTokens') + .get(); + tokenSnapshot.docs.forEach((doc) => { + var _a; + const token = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token; + if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) { + tokens.push(token); + } + }); + return tokens; +} +//# sourceMappingURL=gameRetention.js.map \ No newline at end of file diff --git a/functions/dist/notifications/gameRetention.js.map b/functions/dist/notifications/gameRetention.js.map new file mode 100644 index 00000000..8d883b6a --- /dev/null +++ b/functions/dist/notifications/gameRetention.js.map @@ -0,0 +1 @@ +{"version":3,"file":"gameRetention.js","sourceRoot":"","sources":["../../src/notifications/gameRetention.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 7ff7369e..8d8c464b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -14,6 +14,10 @@ export { sendDailyQuestionReminder, sendPartnerAnsweredNotification, } from './notifications/reminders' +export { + sendChallengeDayReminders, + unlockDueMemoryCapsules, +} from './notifications/gameRetention' export { checkDeviceIntegrity } from './security/checkDeviceIntegrity' export { createDateMatchOnMutualLove } from './dates/createDateMatch' export { diff --git a/functions/src/notifications/gameRetention.ts b/functions/src/notifications/gameRetention.ts new file mode 100644 index 00000000..15e501eb --- /dev/null +++ b/functions/src/notifications/gameRetention.ts @@ -0,0 +1,238 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +const DAY_MS = 24 * 60 * 60 * 1000 + +const CHALLENGE_TITLES: Record = { + gratitude_week: { title: 'Gratitude Week', durationDays: 7 }, + appreciation_notes: { title: 'Appreciation Notes', durationDays: 7 }, + quality_time: { title: 'Quality Time', durationDays: 7 }, + deep_conversations: { title: 'Deep Conversations', durationDays: 7 }, +} + +type NotificationType = 'memory_capsule_unlocked' | 'challenge_day_ready' + +interface QueuedNotification { + userId: string + type: NotificationType + title: string + body: string + data: Record +} + +export const unlockDueMemoryCapsules = functions.pubsub + .schedule('every 1 hours') + .onRun(async () => { + const db = admin.firestore() + const messaging = admin.messaging() + const now = Date.now() + + const snapshot = await db + .collectionGroup('capsules') + .where('status', '==', 'sealed') + .where('unlockAt', '<=', now) + .limit(100) + .get() + + const notifications: QueuedNotification[] = [] + + for (const capsuleDoc of snapshot.docs) { + const capsuleRef = capsuleDoc.ref + const coupleRef = capsuleRef.parent.parent + if (!coupleRef) continue + + const capsuleNotifications = await db.runTransaction(async (tx) => { + const freshCapsule = await tx.get(capsuleRef) + const capsule = freshCapsule.data() ?? {} + if (capsule.status !== 'sealed' || Number(capsule.unlockAt ?? 0) > now) { + return [] as QueuedNotification[] + } + + const coupleDoc = await tx.get(coupleRef) + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + if (userIds.length === 0) return [] as QueuedNotification[] + + tx.update(capsuleRef, { + status: 'unlocked', + unlockedAt: now, + unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + const capsuleId = capsuleRef.id + const coupleId = coupleRef.id + const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0 + ? capsule.title.trim() + : 'A memory capsule' + + return userIds.map((userId) => ({ + userId, + type: 'memory_capsule_unlocked' as const, + title: 'Your memory capsule opened', + body: `${title} is ready to read together.`, + data: { coupleId, capsuleId }, + })) + }) + + notifications.push(...capsuleNotifications) + } + + await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))) + console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`) + }) + +export const sendChallengeDayReminders = functions.pubsub + .schedule('every 24 hours') + .onRun(async () => { + const db = admin.firestore() + const messaging = admin.messaging() + const now = Date.now() + + const snapshot = await db + .collectionGroup('challenges') + .where('status', '==', 'active') + .limit(100) + .get() + + const notifications: QueuedNotification[] = [] + + for (const challengeDoc of snapshot.docs) { + const challengeRef = challengeDoc.ref + const coupleRef = challengeRef.parent.parent + if (!coupleRef) continue + + const challengeNotifications = await db.runTransaction(async (tx) => { + const freshChallenge = await tx.get(challengeRef) + const challenge = freshChallenge.data() ?? {} + if (challenge.status !== 'active') return [] as QueuedNotification[] + + const startedAt = Number(challenge.startedAt ?? 0) + if (startedAt <= 0 || startedAt > now) return [] as QueuedNotification[] + + const challengeId = typeof challenge.challengeId === 'string' + ? challenge.challengeId + : challengeRef.id + const catalogEntry = CHALLENGE_TITLES[challengeId] ?? { + title: 'Connection Challenge', + durationDays: 7, + } + const day = Math.floor((now - startedAt) / DAY_MS) + 1 + if (day < 1 || day > catalogEntry.durationDays) return [] as QueuedNotification[] + + const coupleDoc = await tx.get(coupleRef) + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + if (userIds.length === 0) return [] as QueuedNotification[] + + const completions = (challenge.completions ?? {}) as Record + const reminderSent = (challenge.challengeReminderSent ?? {}) as Record + const dueUserIds = userIds.filter((userId) => { + const completedDays = completions[userId] ?? [] + const alreadyCompleted = completedDays.map(Number).includes(day) + const alreadySent = reminderSent[reminderKey(userId, day)] === true + return !alreadyCompleted && !alreadySent + }) + + if (dueUserIds.length === 0) return [] as QueuedNotification[] + + const updates: Record = { + lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(), + } + dueUserIds.forEach((userId) => { + updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true + }) + tx.update(challengeRef, updates) + + const coupleId = coupleRef.id + return dueUserIds.map((userId) => ({ + userId, + type: 'challenge_day_ready' as const, + title: `Day ${day} is ready`, + body: `${catalogEntry.title}: today's connection prompt is waiting.`, + data: { coupleId, challengeId, day: String(day) }, + })) + }) + + notifications.push(...challengeNotifications) + } + + await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))) + console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`) + }) + +function reminderKey(userId: string, day: number): string { + return `${userId.replace(/[^\w-]/g, '_')}_${day}` +} + +async function sendNotification( + db: admin.firestore.Firestore, + messaging: admin.messaging.Messaging, + notification: QueuedNotification +): Promise { + await db + .collection('users') + .doc(notification.userId) + .collection('notification_queue') + .add({ + type: notification.type, + title: notification.title, + body: notification.body, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }) + + const tokens = await getUserTokens(db, notification.userId) + if (tokens.length === 0) { + console.log(`[sendNotification] no FCM tokens for ${notification.userId}`) + return + } + + const message: admin.messaging.Message = { + token: tokens[0], + notification: { + title: notification.title, + body: notification.body, + }, + data: { + type: notification.type, + ...notification.data, + }, + } + + const sendResults = await Promise.allSettled( + tokens.map((token) => messaging.send({ ...message, 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(`[sendNotification] some notifications failed:`, failures) + } +} + +async function getUserTokens(db: admin.firestore.Firestore, userId: string): Promise { + const tokens: string[] = [] + const userDoc = await db.collection('users').doc(userId).get() + const legacyToken = userDoc.data()?.fcmToken + if (typeof legacyToken === 'string' && legacyToken.length > 0) { + tokens.push(legacyToken) + } + + const tokenSnapshot = await db + .collection('users') + .doc(userId) + .collection('fcmTokens') + .get() + + tokenSnapshot.docs.forEach((doc) => { + const token = doc.data()?.token + if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) { + tokens.push(token) + } + }) + + return tokens +}