build(functions): B0 v2 foundation — global options, shared push helper, structured logging

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 23:36:16 -05:00
parent 6af9c15833
commit fbf4e7957c
15 changed files with 568 additions and 1 deletions

View File

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

View File

@ -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"}
{"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"}

49
functions/dist/log.js vendored Normal file
View File

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

1
functions/dist/log.js.map vendored Normal file
View File

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

75
functions/dist/notifications/push.js vendored Normal file
View File

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

View File

@ -0,0 +1 @@
{"version":3,"file":"push.js","sourceRoot":"","sources":["../../src/notifications/push.ts"],"names":[],"mappings":"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"}

View File

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

File diff suppressed because one or more lines are too long

21
functions/dist/options.js vendored Normal file
View File

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

1
functions/dist/options.js.map vendored Normal file
View File

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

View File

@ -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.

17
functions/src/log.ts Normal file
View File

@ -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)}`
}

View File

@ -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([])
})
})

View File

@ -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<string[]> {
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<unknown>[] {
return batch.responses.map((r) =>
r.success
? ({ status: 'fulfilled', value: r.messageId } as PromiseFulfilledResult<unknown>)
: ({ 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<PushResult> {
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 }
}

19
functions/src/options.ts Normal file
View File

@ -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'