feat: add FCM game retention notification functions, messaging service updates, ThisOrThat screen fixes

This commit is contained in:
null 2026-06-19 02:23:52 -05:00
parent 70e7c66cd6
commit 0e0a33a6dd
8 changed files with 625 additions and 6 deletions

View File

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

View File

@ -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<String>?
) {
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,

View File

@ -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");

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -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<string, { title: string; durationDays: number }> = {
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<string, string>
}
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<string, number[]>
const reminderSent = (challenge.challengeReminderSent ?? {}) as Record<string, boolean>
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<string, unknown> = {
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<void> {
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<string[]> {
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
}