fix(functions): add server-side throttle to gentle_reminders callable
This commit is contained in:
parent
71b230719b
commit
7898a4887f
|
|
@ -1,13 +1,21 @@
|
|||
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 limit: 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.
|
||||
* 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).
|
||||
|
|
@ -39,7 +47,56 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
|||
throw new functions.https.HttpsError('failed-precondition', 'No partner found.')
|
||||
}
|
||||
|
||||
// ── 2. Rate limit: one per couple per day ────────────────────────────────
|
||||
// ── 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
|
||||
|
|
@ -53,7 +110,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
|||
return { sent: false, reason: 'already_sent_today' }
|
||||
}
|
||||
|
||||
// ── 3. Collect partner FCM tokens ────────────────────────────────────────
|
||||
// ── 4. Collect partner FCM tokens ────────────────────────────────────────
|
||||
|
||||
const tokens: string[] = []
|
||||
const partnerDoc = await db.collection('users').doc(partnerId).get()
|
||||
|
|
@ -76,7 +133,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
|||
}
|
||||
})
|
||||
|
||||
// ── 4. Write in-app notification record ──────────────────────────────────
|
||||
// ── 5. Write in-app notification record ──────────────────────────────────
|
||||
|
||||
await db
|
||||
.collection('users')
|
||||
|
|
@ -91,14 +148,14 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
|
|||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// ── 5. Claim the daily rate-limit lock ───────────────────────────────────
|
||||
// ── 6. Claim the daily rate-limit lock ───────────────────────────────────
|
||||
|
||||
await lockRef.set({
|
||||
sentBy: callerId,
|
||||
sentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// ── 6. Send FCM push ─────────────────────────────────────────────────────
|
||||
// ── 7. Send FCM push ─────────────────────────────────────────────────────
|
||||
|
||||
if (tokens.length > 0) {
|
||||
const sendResults = await Promise.allSettled(
|
||||
|
|
|
|||
Loading…
Reference in New Issue