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:
parent
6af9c15833
commit
fbf4e7957c
|
|
@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
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;
|
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"));
|
const admin = __importStar(require("firebase-admin"));
|
||||||
// Initialize the Admin SDK once for every function in this codebase.
|
// Initialize the Admin SDK once for every function in this codebase.
|
||||||
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a
|
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"version":3,"file":"push.js","sourceRoot":"","sources":["../../src/notifications/push.ts"],"names":[],"mappings":"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"}
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
// Initialize the Admin SDK once for every function in this codebase.
|
// Initialize the Admin SDK once for every function in this codebase.
|
||||||
|
|
|
||||||
|
|
@ -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)}`
|
||||||
|
}
|
||||||
|
|
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
Loading…
Reference in New Issue