From b564d1da42bea9b809f140bf5656b3a1a1ecc738 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 23:34:22 -0500 Subject: [PATCH] feat(notifications): add pruneDeadTokens utility for FCM dead-token cleanup --- .../src/notifications/pruneTokens.test.ts | 63 +++++++++++++ functions/src/notifications/pruneTokens.ts | 92 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 functions/src/notifications/pruneTokens.test.ts create mode 100644 functions/src/notifications/pruneTokens.ts diff --git a/functions/src/notifications/pruneTokens.test.ts b/functions/src/notifications/pruneTokens.test.ts new file mode 100644 index 00000000..79e1a2c0 --- /dev/null +++ b/functions/src/notifications/pruneTokens.test.ts @@ -0,0 +1,63 @@ +import { isDeadTokenError, selectDeadTokens } from './pruneTokens' + +// Shapes mirror what firebase-admin's FirebaseMessagingError actually throws (see errorInfo.code), +// plus the `code` getter form. Both must be recognised; transient/server errors must NOT be. +const deadNotRegistered = { errorInfo: { code: 'messaging/registration-token-not-registered' }, codePrefix: 'messaging' } +const deadInvalidToken = { code: 'messaging/invalid-registration-token' } +const transientUnavailable = { errorInfo: { code: 'messaging/server-unavailable' } } +const transientInternal = { code: 'messaging/internal-error' } +const badPayload = { errorInfo: { code: 'messaging/invalid-argument' } } // payload bug — must NOT prune + +const rej = (reason: unknown): PromiseSettledResult => ({ status: 'rejected', reason }) +const ful = (): PromiseSettledResult => ({ status: 'fulfilled', value: 'id' }) + +describe('isDeadTokenError', () => { + it('is true for permanently-dead token codes (both errorInfo.code and code shapes)', () => { + expect(isDeadTokenError(deadNotRegistered)).toBe(true) + expect(isDeadTokenError(deadInvalidToken)).toBe(true) + }) + + it('is false for transient/server errors — never prune on these', () => { + expect(isDeadTokenError(transientUnavailable)).toBe(false) + expect(isDeadTokenError(transientInternal)).toBe(false) + }) + + it('is false for invalid-argument — that blames the payload, not the token', () => { + expect(isDeadTokenError(badPayload)).toBe(false) + }) + + it('is false for missing/garbage reasons (fail safe)', () => { + expect(isDeadTokenError(undefined)).toBe(false) + expect(isDeadTokenError(null)).toBe(false) + expect(isDeadTokenError({})).toBe(false) + expect(isDeadTokenError('boom')).toBe(false) + expect(isDeadTokenError({ code: 42 })).toBe(false) + }) +}) + +describe('selectDeadTokens', () => { + it('maps rejected-dead results to their token by index', () => { + const tokens = ['A', 'B', 'C'] + const results = [ful(), rej(deadNotRegistered), rej(transientUnavailable)] + expect(selectDeadTokens(tokens, results)).toEqual(['B']) + }) + + it('ignores fulfilled sends and transient failures', () => { + const tokens = ['A', 'B'] + expect(selectDeadTokens(tokens, [ful(), rej(transientInternal)])).toEqual([]) + }) + + it('dedupes when the same dead token appears twice', () => { + const tokens = ['A', 'A'] + expect(selectDeadTokens(tokens, [rej(deadNotRegistered), rej(deadInvalidToken)])).toEqual(['A']) + }) + + it('returns empty when there are no failures', () => { + expect(selectDeadTokens(['A', 'B'], [ful(), ful()])).toEqual([]) + }) + + it('never selects a token whose index is missing', () => { + // more results than tokens (defensive) — no crash, no phantom token + expect(selectDeadTokens(['A'], [rej(deadNotRegistered), rej(deadNotRegistered)])).toEqual(['A']) + }) +}) diff --git a/functions/src/notifications/pruneTokens.ts b/functions/src/notifications/pruneTokens.ts new file mode 100644 index 00000000..18e965b8 --- /dev/null +++ b/functions/src/notifications/pruneTokens.ts @@ -0,0 +1,92 @@ +import * as admin from 'firebase-admin' + +/** + * FCM error codes that mean a token is *permanently* dead and safe to delete. + * + * Deliberately narrow. We only prune on errors that unambiguously blame the TOKEN: + * - `registration-token-not-registered` — the app was uninstalled / data-cleared / token rotated. + * - `invalid-registration-token` — the token string itself is malformed. + * We intentionally do NOT include `messaging/invalid-argument` or any transient/server error + * (`unavailable`, `internal`, `quota-exceeded`, auth issues): those can be caused by a bad *payload* + * or a temporary outage, and treating them as dead would let one bug wipe every user's tokens. + */ +const DEAD_TOKEN_CODES = new Set([ + 'messaging/registration-token-not-registered', + 'messaging/invalid-registration-token', +]) + +/** Extract the `messaging/*` code from a firebase-admin messaging rejection, wherever it lives. */ +function messagingErrorCode(reason: unknown): string | undefined { + const r = reason as { errorInfo?: { code?: unknown }; code?: unknown } | null | undefined + const code = r?.errorInfo?.code ?? r?.code + return typeof code === 'string' ? code : undefined +} + +/** True only when an FCM send rejection means the token is permanently invalid (safe to delete). */ +export function isDeadTokenError(reason: unknown): boolean { + const code = messagingErrorCode(reason) + return code !== undefined && DEAD_TOKEN_CODES.has(code) +} + +/** + * Pure: given the same `tokens` array and `Promise.allSettled` results a caller already has, return the + * distinct token strings that FCM reported as permanently dead. Index `i` in results maps to `tokens[i]`. + */ +export function selectDeadTokens( + tokens: string[], + results: PromiseSettledResult[] +): string[] { + const dead = new Set() + results.forEach((r, i) => { + const token = tokens[i] + if (r.status === 'rejected' && token && isDeadTokenError(r.reason)) dead.add(token) + }) + return [...dead] +} + +/** + * Best-effort removal of tokens FCM reported as permanently dead. Pass the `tokens` array and the + * `Promise.allSettled` results from the send you just performed; any dead token is deleted from the + * user's `fcmTokens` subcollection and cleared from the legacy `fcmToken` field if it matches. + * + * Never throws — pruning is housekeeping and must never fail the notification path it runs after. + * Returns the number of token records removed. + */ +export async function pruneDeadTokens( + db: admin.firestore.Firestore, + uid: string, + tokens: string[], + results: PromiseSettledResult[] +): Promise { + const dead = new Set(selectDeadTokens(tokens, results)) + if (dead.size === 0) return 0 + try { + const userRef = db.collection('users').doc(uid) + const [tokenSnap, userDoc] = await Promise.all([ + userRef.collection('fcmTokens').get(), + userRef.get(), + ]) + const batch = db.batch() + let removed = 0 + tokenSnap.docs.forEach((d) => { + const t = d.data()?.token + if (typeof t === 'string' && dead.has(t)) { + batch.delete(d.ref) + removed++ + } + }) + const legacy = userDoc.data()?.fcmToken + if (typeof legacy === 'string' && dead.has(legacy)) { + batch.update(userRef, { fcmToken: admin.firestore.FieldValue.delete() }) + removed++ + } + if (removed > 0) { + await batch.commit() + console.log(`[pruneDeadTokens] uid=${uid} removed ${removed} dead token(s)`) + } + return removed + } catch (e) { + console.warn(`[pruneDeadTokens] uid=${uid} prune failed (ignored):`, e) + return 0 + } +}