From fbf4e7957ca8a288d620fdc517ca1527f7c7793b Mon Sep 17 00:00:00 2001 From: null Date: Tue, 7 Jul 2026 23:36:16 -0500 Subject: [PATCH] =?UTF-8?q?build(functions):=20B0=20v2=20foundation=20?= =?UTF-8?q?=E2=80=94=20global=20options,=20shared=20push=20helper,=20struc?= =?UTF-8?q?tured=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the v1→v2 Cloud Functions migration; no function is migrated yet (all 35 still load as v1, verified via emulator discovery). - options.ts: setGlobalOptions({ region: 'us-central1', maxInstances: 20 }), imported first in index.ts so it applies before any v2 function is defined. Region pin is load-bearing — the Android client uses the default region. - notifications/push.ts: single canonical getUserTokens() + sendPushToUser() that batches via messaging.sendEachForMulticast() and prunes dead tokens, to replace the ~10 copied token readers and ~19 copied send/prune blocks in later batches. - log.ts: firebase-functions/logger re-export + redactToken() (FCM tokens are secrets). - push.test.ts: 9 unit tests (token merge/dedupe, BatchResponse→dead-token mapping, send/prune/no-op/whole-batch-failure paths). 67 tests green. firebase-functions stays at v5.1.1 for the migration (supports both the root v1 API and the /v2 subpaths); bump to v6 is deferred to the final batch once nothing references the root namespace, so the build stays green at every step. Co-Authored-By: Claude Opus 4.8 --- functions/dist/index.js | 3 + functions/dist/index.js.map | 2 +- functions/dist/log.js | 49 ++++++ functions/dist/log.js.map | 1 + functions/dist/notifications/push.js | 75 +++++++++ functions/dist/notifications/push.js.map | 1 + functions/dist/notifications/push.test.js | 130 ++++++++++++++++ functions/dist/notifications/push.test.js.map | 1 + functions/dist/options.js | 21 +++ functions/dist/options.js.map | 1 + functions/src/index.ts | 3 + functions/src/log.ts | 17 +++ functions/src/notifications/push.test.ts | 142 ++++++++++++++++++ functions/src/notifications/push.ts | 104 +++++++++++++ functions/src/options.ts | 19 +++ 15 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 functions/dist/log.js create mode 100644 functions/dist/log.js.map create mode 100644 functions/dist/notifications/push.js create mode 100644 functions/dist/notifications/push.js.map create mode 100644 functions/dist/notifications/push.test.js create mode 100644 functions/dist/notifications/push.test.js.map create mode 100644 functions/dist/options.js create mode 100644 functions/dist/options.js.map create mode 100644 functions/src/log.ts create mode 100644 functions/src/notifications/push.test.ts create mode 100644 functions/src/notifications/push.ts create mode 100644 functions/src/options.ts diff --git a/functions/dist/index.js b/functions/dist/index.js index ec2fe522..da88d880 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +// Global v2 options (region pin, instance cap) — MUST be imported before any function module so +// setGlobalOptions runs before the functions below are defined. +require("./options"); const admin = __importStar(require("firebase-admin")); // Initialize the Admin SDK once for every function in this codebase. // Handlers call admin.firestore()/messaging() lazily at invocation time, so a diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 204083a3..c3eb138e 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file diff --git a/functions/dist/log.js b/functions/dist/log.js new file mode 100644 index 00000000..35f93990 --- /dev/null +++ b/functions/dist/log.js @@ -0,0 +1,49 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.logger = void 0; +exports.redactToken = redactToken; +const logger = __importStar(require("firebase-functions/logger")); +exports.logger = logger; +/** + * FCM registration tokens are device secrets — never log them in full. Use this to reduce a token + * to a short, non-identifying suffix for correlation in logs. + */ +function redactToken(token) { + if (!token) + return '(none)'; + return token.length <= 6 ? '***' : `…${token.slice(-6)}`; +} +//# sourceMappingURL=log.js.map \ No newline at end of file diff --git a/functions/dist/log.js.map b/functions/dist/log.js.map new file mode 100644 index 00000000..5782911e --- /dev/null +++ b/functions/dist/log.js.map @@ -0,0 +1 @@ +{"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAaA,kCAGC;AAhBD,kEAAmD;AAO1C,wBAAM;AAEf;;;GAGG;AACH,SAAgB,WAAW,CAAC,KAAgC;IAC1D,IAAI,CAAC,KAAK;QAAE,OAAO,QAAQ,CAAA;IAC3B,OAAO,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;AAC1D,CAAC"} \ No newline at end of file diff --git a/functions/dist/notifications/push.js b/functions/dist/notifications/push.js new file mode 100644 index 00000000..3f9dd73e --- /dev/null +++ b/functions/dist/notifications/push.js @@ -0,0 +1,75 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getUserTokens = getUserTokens; +exports.batchResponseToResults = batchResponseToResults; +exports.sendPushToUser = sendPushToUser; +const pruneTokens_1 = require("./pruneTokens"); +const log_1 = require("../log"); +/** + * One canonical FCM-token reader and one canonical sender for the whole codebase. + * + * Before this module, ~10 files re-implemented "read the user's tokens" and ~19 re-implemented + * "send to each token, log failures, prune the dead ones". This collapses both into a single, + * unit-tested unit. Callers keep what is genuinely theirs — gating (quiet hours / per-notif + * toggles) and any in-app `notification_queue` write; this module only touches FCM. + */ +/** + * Merge a user's FCM tokens from the legacy `fcmToken` field and the `fcmTokens` subcollection, + * de-duplicated, order-stable (legacy first). Pass `userData` if the user doc is already loaded to + * avoid a redundant read. + */ +async function getUserTokens(db, uid, userData) { + const tokens = []; + const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(uid).get()).data(); + const legacy = data === null || data === void 0 ? void 0 : data.fcmToken; + if (typeof legacy === 'string' && legacy.length > 0) + tokens.push(legacy); + const snap = await db.collection('users').doc(uid).collection('fcmTokens').get(); + snap.docs.forEach((d) => { + var _a; + const t = (_a = d.data()) === null || _a === void 0 ? void 0 : _a.token; + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) + tokens.push(t); + }); + return tokens; +} +/** + * Adapt a multicast `BatchResponse` to the `PromiseSettledResult[]` shape `pruneDeadTokens` + * consumes (index `i` still maps to `tokens[i]`). Exported for unit testing the dead-token path. + */ +function batchResponseToResults(batch) { + return batch.responses.map((r) => r.success + ? { status: 'fulfilled', value: r.messageId } + : { status: 'rejected', reason: r.error }); +} +/** + * Send one push to every FCM token a user has, in a single batched `sendEachForMulticast` + * round-trip, then prune any tokens FCM reports as permanently dead. Best-effort: never throws; + * returns a per-user summary. A whole-batch failure (auth/quota, not a per-token death) is logged + * and does not prune anything. + */ +async function sendPushToUser(db, messaging, uid, content, userData) { + const tokens = await getUserTokens(db, uid, userData); + if (tokens.length === 0) + return { sent: 0, failed: 0, pruned: 0 }; + const message = Object.assign(Object.assign({ tokens, notification: content.notification }, (content.data ? { data: content.data } : {})), (content.android ? { android: content.android } : {})); + let batch; + try { + batch = await messaging.sendEachForMulticast(message); + } + catch (e) { + log_1.logger.warn(`[push] sendEachForMulticast failed for uid=${uid}`, { error: String(e) }); + return { sent: 0, failed: tokens.length, pruned: 0 }; + } + batch.responses.forEach((r, i) => { + var _a; + if (!r.success) { + log_1.logger.warn(`[push] FCM send failed uid=${uid} token=${(0, log_1.redactToken)(tokens[i])}`, { + code: (_a = r.error) === null || _a === void 0 ? void 0 : _a.code, + }); + } + }); + const pruned = await (0, pruneTokens_1.pruneDeadTokens)(db, uid, tokens, batchResponseToResults(batch)); + return { sent: batch.successCount, failed: batch.failureCount, pruned }; +} +//# sourceMappingURL=push.js.map \ No newline at end of file diff --git a/functions/dist/notifications/push.js.map b/functions/dist/notifications/push.js.map new file mode 100644 index 00000000..26ebc4cd --- /dev/null +++ b/functions/dist/notifications/push.js.map @@ -0,0 +1 @@ +{"version":3,"file":"push.js","sourceRoot":"","sources":["../../src/notifications/push.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/notifications/push.test.js b/functions/dist/notifications/push.test.js new file mode 100644 index 00000000..ec46bdf6 --- /dev/null +++ b/functions/dist/notifications/push.test.js @@ -0,0 +1,130 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const push_1 = require("./push"); +const pruneTokens_1 = require("./pruneTokens"); +// ── Lightweight Firestore/Messaging fakes ──────────────────────────────────── +// Only the surface push.ts + pruneTokens.ts actually touch. +const deadError = { code: 'messaging/registration-token-not-registered' }; +const transientError = { code: 'messaging/server-unavailable' }; +function makeFakeDb(userData, subTokens) { + const deleted = []; + const commits = { count: 0 }; + const fcmDocs = subTokens.map((t, i) => ({ + ref: { __token: t, __id: `tok${i}` }, + data: () => ({ token: t }), + })); + const userRef = { + get: async () => ({ exists: true, data: () => userData }), + collection: (name) => { + if (name !== 'fcmTokens') + throw new Error(`unexpected subcollection ${name}`); + return { get: async () => ({ docs: fcmDocs }) }; + }, + }; + const db = { + collection: (name) => { + if (name !== 'users') + throw new Error(`unexpected collection ${name}`); + return { doc: () => userRef }; + }, + batch: () => ({ + delete: (ref) => deleted.push(ref.__token), + update: () => deleted.push('__legacy__'), + commit: async () => { + commits.count++; + }, + }), + }; + return { db, deleted, commits }; +} +function batch(responses) { + return { + responses, + successCount: responses.filter((r) => r.success).length, + failureCount: responses.filter((r) => !r.success).length, + }; +} +function makeFakeMessaging(response) { + return { sendEachForMulticast: async () => response }; +} +describe('getUserTokens', () => { + it('merges the legacy field with the subcollection, deduped and order-stable', async () => { + const { db } = makeFakeDb({ fcmToken: 'A' }, ['A', 'B']); + expect(await (0, push_1.getUserTokens)(db, 'u1')).toEqual(['A', 'B']); + }); + it('returns only subcollection tokens when there is no legacy field', async () => { + const { db } = makeFakeDb({}, ['B', 'C']); + expect(await (0, push_1.getUserTokens)(db, 'u1')).toEqual(['B', 'C']); + }); + it('returns empty when the user has no tokens', async () => { + const { db } = makeFakeDb({}, []); + expect(await (0, push_1.getUserTokens)(db, 'u1')).toEqual([]); + }); + it('uses provided userData without re-reading the user doc', async () => { + const { db } = makeFakeDb({}, ['B']); + // Make the user-doc read blow up; passing userData must avoid it entirely. + db.collection = (name) => { + if (name !== 'users') + throw new Error('unexpected'); + return { + doc: () => ({ + get: async () => { + throw new Error('should not read user doc'); + }, + collection: () => ({ get: async () => ({ docs: [{ data: () => ({ token: 'B' }) }] }) }), + }), + }; + }; + expect(await (0, push_1.getUserTokens)(db, 'u1', { fcmToken: 'A' })).toEqual(['A', 'B']); + }); +}); +describe('batchResponseToResults', () => { + it('maps a multicast BatchResponse to settled results that pick out dead tokens by index', () => { + const results = (0, push_1.batchResponseToResults)(batch([ + { success: true, messageId: 'm1' }, + { success: false, error: deadError }, + { success: false, error: transientError }, + ])); + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'rejected']); + // End-to-end: only the permanently-dead token (index 1) is selected for pruning. + expect((0, pruneTokens_1.selectDeadTokens)(['A', 'B', 'C'], results)).toEqual(['B']); + }); +}); +describe('sendPushToUser', () => { + it('sends to every token and prunes exactly the permanently-dead ones', async () => { + const { db, deleted, commits } = makeFakeDb({ fcmToken: 'LEG' }, ['LEG', 'DEAD', 'GOOD']); + const messaging = makeFakeMessaging(batch([ + { success: true, messageId: 'm1' }, // LEG + { success: false, error: deadError }, // DEAD + { success: true, messageId: 'm2' }, // GOOD + ])); + const res = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }); + expect(res).toEqual({ sent: 2, failed: 1, pruned: 1 }); + expect(deleted).toEqual(['DEAD']); + expect(commits.count).toBe(1); + }); + it('does not prune on transient failures', async () => { + const { db, deleted } = makeFakeDb({}, ['T1']); + const messaging = makeFakeMessaging(batch([{ success: false, error: transientError }])); + const res = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }); + expect(res).toEqual({ sent: 0, failed: 1, pruned: 0 }); + expect(deleted).toEqual([]); + }); + it('no-ops cleanly when the user has no tokens', async () => { + const { db } = makeFakeDb({}, []); + const messaging = makeFakeMessaging(batch([])); + expect(await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } })).toEqual({ + sent: 0, + failed: 0, + pruned: 0, + }); + }); + it('treats a whole-batch send failure as non-fatal (no prune)', async () => { + const { db, deleted } = makeFakeDb({}, ['X']); + const messaging = { sendEachForMulticast: async () => Promise.reject(new Error('quota')) }; + const res = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }); + expect(res).toEqual({ sent: 0, failed: 1, pruned: 0 }); + expect(deleted).toEqual([]); + }); +}); +//# sourceMappingURL=push.test.js.map \ No newline at end of file diff --git a/functions/dist/notifications/push.test.js.map b/functions/dist/notifications/push.test.js.map new file mode 100644 index 00000000..d65b8131 --- /dev/null +++ b/functions/dist/notifications/push.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"push.test.js","sourceRoot":"","sources":["../../src/notifications/push.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/options.js b/functions/dist/options.js new file mode 100644 index 00000000..7e3c4cd1 --- /dev/null +++ b/functions/dist/options.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.REGION = void 0; +const v2_1 = require("firebase-functions/v2"); +/** + * Global 2nd-gen options for every v2 function in this codebase. + * + * region: pinned to us-central1 — the Android client calls FirebaseFunctions.getInstance() with + * no region override (di/SecurityModule.kt, data/remote/FirestoreAnswerDataSource.kt), so the + * callables MUST live in us-central1 or every install breaks. Do not change without also + * pinning the client. + * maxInstances: a runaway-bill guardrail, not a throughput target (v2 serves ~80 concurrent + * requests per instance, so 20 instances is ample at current scale). Revisit at launch. + * + * This module is imported FIRST in index.ts so these options are set before any v2 function is + * defined. It has no effect on the one v1 holdout (onUserDelete), which keeps its own defaults. + */ +(0, v2_1.setGlobalOptions)({ region: 'us-central1', maxInstances: 20 }); +/** Region constant for any per-function override that ever needs to pin it explicitly. */ +exports.REGION = 'us-central1'; +//# sourceMappingURL=options.js.map \ No newline at end of file diff --git a/functions/dist/options.js.map b/functions/dist/options.js.map new file mode 100644 index 00000000..67557bdb --- /dev/null +++ b/functions/dist/options.js.map @@ -0,0 +1 @@ +{"version":3,"file":"options.js","sourceRoot":"","sources":["../src/options.ts"],"names":[],"mappings":";;;AAAA,8CAAwD;AAExD;;;;;;;;;;;;GAYG;AACH,IAAA,qBAAgB,EAAC,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,EAAE,EAAE,CAAC,CAAA;AAE7D,0FAA0F;AAC7E,QAAA,MAAM,GAAG,aAAa,CAAA"} \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts index 47c6bfed..60d1d1a9 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,3 +1,6 @@ +// Global v2 options (region pin, instance cap) — MUST be imported before any function module so +// setGlobalOptions runs before the functions below are defined. +import './options' import * as admin from 'firebase-admin' // Initialize the Admin SDK once for every function in this codebase. diff --git a/functions/src/log.ts b/functions/src/log.ts new file mode 100644 index 00000000..c21204d9 --- /dev/null +++ b/functions/src/log.ts @@ -0,0 +1,17 @@ +import * as logger from 'firebase-functions/logger' + +/** + * Structured logging for all functions. Prefer this over `console.*`: the v2 logger attaches + * execution/trace context automatically and supports a structured metadata object as the last arg + * (e.g. `logger.warn('msg', { code, uid })`), which is searchable in Cloud Logging. + */ +export { logger } + +/** + * FCM registration tokens are device secrets — never log them in full. Use this to reduce a token + * to a short, non-identifying suffix for correlation in logs. + */ +export function redactToken(token: string | null | undefined): string { + if (!token) return '(none)' + return token.length <= 6 ? '***' : `…${token.slice(-6)}` +} diff --git a/functions/src/notifications/push.test.ts b/functions/src/notifications/push.test.ts new file mode 100644 index 00000000..0c7f597e --- /dev/null +++ b/functions/src/notifications/push.test.ts @@ -0,0 +1,142 @@ +import { getUserTokens, batchResponseToResults, sendPushToUser } from './push' +import { selectDeadTokens } from './pruneTokens' + +// ── Lightweight Firestore/Messaging fakes ──────────────────────────────────── +// Only the surface push.ts + pruneTokens.ts actually touch. + +const deadError = { code: 'messaging/registration-token-not-registered' } +const transientError = { code: 'messaging/server-unavailable' } + +function makeFakeDb(userData: any, subTokens: string[]) { + const deleted: string[] = [] + const commits = { count: 0 } + const fcmDocs = subTokens.map((t, i) => ({ + ref: { __token: t, __id: `tok${i}` }, + data: () => ({ token: t }), + })) + const userRef = { + get: async () => ({ exists: true, data: () => userData }), + collection: (name: string) => { + if (name !== 'fcmTokens') throw new Error(`unexpected subcollection ${name}`) + return { get: async () => ({ docs: fcmDocs }) } + }, + } + const db: any = { + collection: (name: string) => { + if (name !== 'users') throw new Error(`unexpected collection ${name}`) + return { doc: () => userRef } + }, + batch: () => ({ + delete: (ref: any) => deleted.push(ref.__token), + update: () => deleted.push('__legacy__'), + commit: async () => { + commits.count++ + }, + }), + } + return { db, deleted, commits } +} + +function batch(responses: Array<{ success: boolean; messageId?: string; error?: any }>) { + return { + responses, + successCount: responses.filter((r) => r.success).length, + failureCount: responses.filter((r) => !r.success).length, + } as any +} + +function makeFakeMessaging(response: any) { + return { sendEachForMulticast: async () => response } as any +} + +describe('getUserTokens', () => { + it('merges the legacy field with the subcollection, deduped and order-stable', async () => { + const { db } = makeFakeDb({ fcmToken: 'A' }, ['A', 'B']) + expect(await getUserTokens(db, 'u1')).toEqual(['A', 'B']) + }) + + it('returns only subcollection tokens when there is no legacy field', async () => { + const { db } = makeFakeDb({}, ['B', 'C']) + expect(await getUserTokens(db, 'u1')).toEqual(['B', 'C']) + }) + + it('returns empty when the user has no tokens', async () => { + const { db } = makeFakeDb({}, []) + expect(await getUserTokens(db, 'u1')).toEqual([]) + }) + + it('uses provided userData without re-reading the user doc', async () => { + const { db } = makeFakeDb({}, ['B']) + // Make the user-doc read blow up; passing userData must avoid it entirely. + db.collection = (name: string) => { + if (name !== 'users') throw new Error('unexpected') + return { + doc: () => ({ + get: async () => { + throw new Error('should not read user doc') + }, + collection: () => ({ get: async () => ({ docs: [{ data: () => ({ token: 'B' }) }] }) }), + }), + } + } + expect(await getUserTokens(db, 'u1', { fcmToken: 'A' })).toEqual(['A', 'B']) + }) +}) + +describe('batchResponseToResults', () => { + it('maps a multicast BatchResponse to settled results that pick out dead tokens by index', () => { + const results = batchResponseToResults( + batch([ + { success: true, messageId: 'm1' }, + { success: false, error: deadError }, + { success: false, error: transientError }, + ]), + ) + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'rejected']) + // End-to-end: only the permanently-dead token (index 1) is selected for pruning. + expect(selectDeadTokens(['A', 'B', 'C'], results)).toEqual(['B']) + }) +}) + +describe('sendPushToUser', () => { + it('sends to every token and prunes exactly the permanently-dead ones', async () => { + const { db, deleted, commits } = makeFakeDb({ fcmToken: 'LEG' }, ['LEG', 'DEAD', 'GOOD']) + const messaging = makeFakeMessaging( + batch([ + { success: true, messageId: 'm1' }, // LEG + { success: false, error: deadError }, // DEAD + { success: true, messageId: 'm2' }, // GOOD + ]), + ) + const res = await sendPushToUser(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }) + expect(res).toEqual({ sent: 2, failed: 1, pruned: 1 }) + expect(deleted).toEqual(['DEAD']) + expect(commits.count).toBe(1) + }) + + it('does not prune on transient failures', async () => { + const { db, deleted } = makeFakeDb({}, ['T1']) + const messaging = makeFakeMessaging(batch([{ success: false, error: transientError }])) + const res = await sendPushToUser(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }) + expect(res).toEqual({ sent: 0, failed: 1, pruned: 0 }) + expect(deleted).toEqual([]) + }) + + it('no-ops cleanly when the user has no tokens', async () => { + const { db } = makeFakeDb({}, []) + const messaging = makeFakeMessaging(batch([])) + expect(await sendPushToUser(db, messaging, 'u1', { notification: { title: 't', body: 'b' } })).toEqual({ + sent: 0, + failed: 0, + pruned: 0, + }) + }) + + it('treats a whole-batch send failure as non-fatal (no prune)', async () => { + const { db, deleted } = makeFakeDb({}, ['X']) + const messaging = { sendEachForMulticast: async () => Promise.reject(new Error('quota')) } as any + const res = await sendPushToUser(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }) + expect(res).toEqual({ sent: 0, failed: 1, pruned: 0 }) + expect(deleted).toEqual([]) + }) +}) diff --git a/functions/src/notifications/push.ts b/functions/src/notifications/push.ts new file mode 100644 index 00000000..cb891d1b --- /dev/null +++ b/functions/src/notifications/push.ts @@ -0,0 +1,104 @@ +import * as admin from 'firebase-admin' +import { pruneDeadTokens } from './pruneTokens' +import { logger, redactToken } from '../log' + +/** + * One canonical FCM-token reader and one canonical sender for the whole codebase. + * + * Before this module, ~10 files re-implemented "read the user's tokens" and ~19 re-implemented + * "send to each token, log failures, prune the dead ones". This collapses both into a single, + * unit-tested unit. Callers keep what is genuinely theirs — gating (quiet hours / per-notif + * toggles) and any in-app `notification_queue` write; this module only touches FCM. + */ + +/** + * Merge a user's FCM tokens from the legacy `fcmToken` field and the `fcmTokens` subcollection, + * de-duplicated, order-stable (legacy first). Pass `userData` if the user doc is already loaded to + * avoid a redundant read. + */ +export async function getUserTokens( + db: admin.firestore.Firestore, + uid: string, + userData?: admin.firestore.DocumentData, +): Promise { + const tokens: string[] = [] + const data = userData ?? (await db.collection('users').doc(uid).get()).data() + const legacy = data?.fcmToken + if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) + const snap = await db.collection('users').doc(uid).collection('fcmTokens').get() + snap.docs.forEach((d) => { + const t = d.data()?.token + if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) + }) + return tokens +} + +/** Payload for a single push. Mirrors the fields callers actually set today. */ +export interface PushContent { + notification: admin.messaging.Notification + data?: { [key: string]: string } + android?: admin.messaging.AndroidConfig +} + +export interface PushResult { + sent: number + failed: number + pruned: number +} + +/** + * Adapt a multicast `BatchResponse` to the `PromiseSettledResult[]` shape `pruneDeadTokens` + * consumes (index `i` still maps to `tokens[i]`). Exported for unit testing the dead-token path. + */ +export function batchResponseToResults( + batch: admin.messaging.BatchResponse, +): PromiseSettledResult[] { + return batch.responses.map((r) => + r.success + ? ({ status: 'fulfilled', value: r.messageId } as PromiseFulfilledResult) + : ({ status: 'rejected', reason: r.error } as PromiseRejectedResult), + ) +} + +/** + * Send one push to every FCM token a user has, in a single batched `sendEachForMulticast` + * round-trip, then prune any tokens FCM reports as permanently dead. Best-effort: never throws; + * returns a per-user summary. A whole-batch failure (auth/quota, not a per-token death) is logged + * and does not prune anything. + */ +export async function sendPushToUser( + db: admin.firestore.Firestore, + messaging: admin.messaging.Messaging, + uid: string, + content: PushContent, + userData?: admin.firestore.DocumentData, +): Promise { + const tokens = await getUserTokens(db, uid, userData) + if (tokens.length === 0) return { sent: 0, failed: 0, pruned: 0 } + + const message: admin.messaging.MulticastMessage = { + tokens, + notification: content.notification, + ...(content.data ? { data: content.data } : {}), + ...(content.android ? { android: content.android } : {}), + } + + let batch: admin.messaging.BatchResponse + try { + batch = await messaging.sendEachForMulticast(message) + } catch (e) { + logger.warn(`[push] sendEachForMulticast failed for uid=${uid}`, { error: String(e) }) + return { sent: 0, failed: tokens.length, pruned: 0 } + } + + batch.responses.forEach((r, i) => { + if (!r.success) { + logger.warn(`[push] FCM send failed uid=${uid} token=${redactToken(tokens[i])}`, { + code: r.error?.code, + }) + } + }) + + const pruned = await pruneDeadTokens(db, uid, tokens, batchResponseToResults(batch)) + return { sent: batch.successCount, failed: batch.failureCount, pruned } +} diff --git a/functions/src/options.ts b/functions/src/options.ts new file mode 100644 index 00000000..6a45999f --- /dev/null +++ b/functions/src/options.ts @@ -0,0 +1,19 @@ +import { setGlobalOptions } from 'firebase-functions/v2' + +/** + * Global 2nd-gen options for every v2 function in this codebase. + * + * region: pinned to us-central1 — the Android client calls FirebaseFunctions.getInstance() with + * no region override (di/SecurityModule.kt, data/remote/FirestoreAnswerDataSource.kt), so the + * callables MUST live in us-central1 or every install breaks. Do not change without also + * pinning the client. + * maxInstances: a runaway-bill guardrail, not a throughput target (v2 serves ~80 concurrent + * requests per instance, so 20 instances is ample at current scale). Revisit at launch. + * + * This module is imported FIRST in index.ts so these options are set before any v2 function is + * defined. It has no effect on the one v1 holdout (onUserDelete), which keeps its own defaults. + */ +setGlobalOptions({ region: 'us-central1', maxInstances: 20 }) + +/** Region constant for any per-function override that ever needs to pin it explicitly. */ +export const REGION = 'us-central1'