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 }