265 lines
8.9 KiB
TypeScript
265 lines
8.9 KiB
TypeScript
import * as functions from 'firebase-functions'
|
|
import * as admin from 'firebase-admin'
|
|
import { recipientInQuietHours } from './quietHours'
|
|
|
|
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: { couple_id: coupleId, capsule_id: 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: { couple_id: coupleId, challenge_id: 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> {
|
|
const userDoc = await db.collection('users').doc(notification.userId).get()
|
|
const userData = userDoc.data()
|
|
|
|
// Challenge-day reminders are retention nudges → respect the promotional opt-out (default on).
|
|
// (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.)
|
|
if (notification.type === 'challenge_day_ready' && userData?.notifPromotional === false) {
|
|
console.log(`[sendNotification] skip ${notification.userId} — promotional off`)
|
|
return
|
|
}
|
|
// Honor the recipient's quiet hours for every scheduled push.
|
|
if (recipientInQuietHours(userData)) {
|
|
console.log(`[sendNotification] skip ${notification.userId} — quiet hours`)
|
|
return
|
|
}
|
|
|
|
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, userData)
|
|
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,
|
|
},
|
|
// E-OBS: challenge reminders → Reminders channel; capsule-unlocked → partner-activity channel.
|
|
android: {
|
|
notification: {
|
|
channelId: notification.type === 'challenge_day_ready' ? 'reminders' : 'partner_activity',
|
|
},
|
|
},
|
|
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,
|
|
userData?: admin.firestore.DocumentData
|
|
): Promise<string[]> {
|
|
const tokens: string[] = []
|
|
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
|
|
const legacyToken = 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
|
|
}
|