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 } }