196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
import * as functions from 'firebase-functions'
|
|
import * as admin from 'firebase-admin'
|
|
|
|
const GENTLE_REMINDER_MAX_PER_HOUR = 5
|
|
const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000 // 1 hour
|
|
|
|
/**
|
|
* 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 limits:
|
|
* - Per-user: max 5 gentle reminders per rolling hour. Guarded by a server-side
|
|
* transaction on `rate_limits/{uid}_gentle_reminder` so malicious clients
|
|
* cannot bypass it by calling the callable in a loop. The Android-side
|
|
* NotificationRateLimiter remains for UX but is not the authoritative guard.
|
|
* - Per-couple: 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.')
|
|
}
|
|
if (!context.app) {
|
|
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.')
|
|
}
|
|
|
|
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. Server-side per-user throttle: 5 per hour (rolling window) ────────
|
|
|
|
const now = admin.firestore.Timestamp.now()
|
|
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`)
|
|
|
|
const throttleResult = await db.runTransaction(async (tx) => {
|
|
const snap = await tx.get(rateLimitRef)
|
|
const data = snap.data()
|
|
|
|
let windowStart: admin.firestore.Timestamp
|
|
let count: number
|
|
|
|
if (!snap.exists || !data) {
|
|
windowStart = now
|
|
count = 0
|
|
} else {
|
|
windowStart = data.windowStart as admin.firestore.Timestamp
|
|
count = (typeof data.count === 'number' ? data.count : 0)
|
|
|
|
const elapsedMs = now.toMillis() - windowStart.toMillis()
|
|
if (elapsedMs >= GENTLE_REMINDER_WINDOW_MS) {
|
|
// Rolling window has expired; start a fresh one.
|
|
windowStart = now
|
|
count = 0
|
|
}
|
|
}
|
|
|
|
if (count >= GENTLE_REMINDER_MAX_PER_HOUR) {
|
|
const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis())
|
|
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60_000))
|
|
return { allowed: false, retryAfterMinutes }
|
|
}
|
|
|
|
tx.set(rateLimitRef, {
|
|
count: count + 1,
|
|
windowStart,
|
|
updatedAt: now,
|
|
}, { merge: true })
|
|
|
|
return { allowed: true, count: count + 1, windowStart }
|
|
})
|
|
|
|
if (!throttleResult.allowed) {
|
|
throw new functions.https.HttpsError(
|
|
'resource-exhausted',
|
|
`Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`
|
|
)
|
|
}
|
|
|
|
// ── 3. 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' }
|
|
}
|
|
|
|
// ── 4. 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)
|
|
}
|
|
})
|
|
|
|
// ── 5. 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(),
|
|
})
|
|
|
|
// ── 6. Claim the daily rate-limit lock ───────────────────────────────────
|
|
|
|
await lockRef.set({
|
|
sentBy: callerId,
|
|
sentAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
})
|
|
|
|
// ── 7. 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.",
|
|
},
|
|
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
|
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 }
|
|
})
|