refactor(functions): B4+B5 migrate webhook to v2 + pin onUserDelete on v1
B4 — revenueCatWebhook: functions.https.onRequest → firebase-functions/v2/https onRequest,
with REVENUECAT_SIGNING_KEY bound as a Secret Manager secret via defineSecret (injected into
process.env, so the Ed25519 verify + process-before-ack/500-retry logic is unchanged). Request
type retyped to the v2 Request; console → logger. The key must be seeded in Secret Manager at
deploy (runbook) — it isn't in the repo.
B5 — onUserDelete: kept on the v1 API (2nd gen has no auth.user().onDelete), imported explicitly
from firebase-functions/v1 and wrapped in runWith({ timeoutSeconds: 300, memory: '512MB' }) for
its dual recursiveDelete + Storage sweep. Adopts shared getUserTokens/sendPushToUser + logger;
preserves the original "only notify if the partner has a live token" behavior.
(wrapReleaseKey's HttpsError→v2 swap already landed in B3.)
Build clean; 70 tests green. dist rebuilt. Still on firebase-functions v5.1.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e0c2d67373
commit
a5af32d3d2
|
|
@ -35,8 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.revenueCatWebhook = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const https_1 = require("firebase-functions/v2/https");
|
||||
const params_1 = require("firebase-functions/params");
|
||||
const entitlementLogic_1 = require("./entitlementLogic");
|
||||
const log_1 = require("../log");
|
||||
/**
|
||||
* RevenueCat webhook handler.
|
||||
*
|
||||
|
|
@ -46,14 +48,16 @@ const entitlementLogic_1 = require("./entitlementLogic");
|
|||
* Authentication:
|
||||
* - Verifies the Ed25519 signature in the X-Signature request header.
|
||||
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
|
||||
* public key from your RevenueCat project dashboard.
|
||||
* public key from your RevenueCat project dashboard. It is bound as a Secret
|
||||
* Manager secret (below) and injected into process.env at runtime.
|
||||
* - Missing key → 500 (our config error). Invalid/absent signature → 401.
|
||||
*
|
||||
* Processing:
|
||||
* - Parses the RevenueCat event payload.
|
||||
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
||||
*/
|
||||
exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
||||
const revenueCatSigningKey = (0, params_1.defineSecret)('REVENUECAT_SIGNING_KEY');
|
||||
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigningKey] }, async (req, res) => {
|
||||
var _a;
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ error: 'method_not_allowed' });
|
||||
|
|
@ -64,7 +68,7 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
|||
}
|
||||
catch (err) {
|
||||
if (err instanceof ConfigError) {
|
||||
console.error('[revenueCatWebhook] configuration error:', err.message);
|
||||
log_1.logger.error('[revenueCatWebhook] configuration error', { error: err.message });
|
||||
res.status(500).json({ error: 'internal_error' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -84,7 +88,7 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
|||
await (0, entitlementLogic_1.applyEntitlementEvent)(event);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[revenueCatWebhook] entitlement sync failed:', err);
|
||||
log_1.logger.error('[revenueCatWebhook] entitlement sync failed', { error: String(err) });
|
||||
res.status(500).json({ error: 'processing_failed' });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -34,12 +34,19 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.onUserDelete = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const pruneTokens_1 = require("../notifications/pruneTokens");
|
||||
const v1_1 = require("firebase-functions/v1");
|
||||
const push_1 = require("../notifications/push");
|
||||
const log_1 = require("../log");
|
||||
/**
|
||||
* Auth deletion trigger — cascades account deletion server-side.
|
||||
*
|
||||
* This is the ONE function kept on the v1 (1st-gen) API: 2nd gen has no
|
||||
* `auth.user().onDelete` equivalent, so it is imported explicitly from
|
||||
* firebase-functions/v1 and coexists with the v2 functions in this codebase.
|
||||
* Global v2 options (setGlobalOptions) do not apply to it, so the timeout/memory
|
||||
* for its dual recursiveDelete + Storage sweep are set here via runWith().
|
||||
*
|
||||
* Fires when a Firebase Auth user is deleted (via client deleteAccount() or
|
||||
* Admin SDK). The Admin SDK bypasses Firestore rules, so this function can
|
||||
* reach everything the client cannot.
|
||||
|
|
@ -53,7 +60,9 @@ const pruneTokens_1 = require("../notifications/pruneTokens");
|
|||
* 3. Recursively delete the user doc + all subcollections
|
||||
* (entitlements, fcmTokens, notification_queue).
|
||||
*/
|
||||
exports.onUserDelete = functions.auth.user().onDelete(async (user) => {
|
||||
exports.onUserDelete = (0, v1_1.runWith)({ timeoutSeconds: 300, memory: '512MB' })
|
||||
.auth.user()
|
||||
.onDelete(async (user) => {
|
||||
var _a, _b, _c, _d;
|
||||
const uid = user.uid;
|
||||
const db = admin.firestore();
|
||||
|
|
@ -79,7 +88,7 @@ exports.onUserDelete = functions.auth.user().onDelete(async (user) => {
|
|||
await partnerRef.update({ coupleId: null });
|
||||
// Send push notification to the partner.
|
||||
await notifyPartner(partnerId, partnerDoc, db).catch((err) => {
|
||||
console.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed:`, err);
|
||||
log_1.logger.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed`, { error: String(err) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -92,29 +101,17 @@ exports.onUserDelete = functions.auth.user().onDelete(async (user) => {
|
|||
await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` });
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[onUserDelete] Storage cleanup failed for ${uid}:`, err);
|
||||
log_1.logger.warn(`[onUserDelete] Storage cleanup failed for ${uid}`, { error: String(err) });
|
||||
}
|
||||
// ── 3. User doc + subcollections ──────────────────────────────────────────
|
||||
await db.recursiveDelete(userDocRef);
|
||||
console.log(`[onUserDelete] completed cleanup for user ${uid}`);
|
||||
log_1.logger.log(`[onUserDelete] completed cleanup for user ${uid}`);
|
||||
});
|
||||
async function notifyPartner(partnerId, partnerDoc, db) {
|
||||
var _a;
|
||||
const messaging = admin.messaging();
|
||||
// Collect FCM tokens (legacy field + fcmTokens subcollection).
|
||||
const tokens = [];
|
||||
const legacyToken = (_a = partnerDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
|
||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||
tokens.push(legacyToken);
|
||||
}
|
||||
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get();
|
||||
tokenSnap.docs.forEach((doc) => {
|
||||
var _a;
|
||||
const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
|
||||
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
|
||||
tokens.push(t);
|
||||
}
|
||||
});
|
||||
const partnerData = partnerDoc.data();
|
||||
// Preserve the original behavior: only write the in-app record + push if the partner has a
|
||||
// live device token (nothing to deliver otherwise).
|
||||
const tokens = await (0, push_1.getUserTokens)(db, partnerId, partnerData);
|
||||
if (tokens.length === 0)
|
||||
return;
|
||||
// Write an in-app notification record.
|
||||
|
|
@ -129,20 +126,13 @@ async function notifyPartner(partnerId, partnerDoc, db) {
|
|||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({
|
||||
token,
|
||||
await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
|
||||
notification: {
|
||||
title: 'Your partner deleted their account',
|
||||
body: 'You are no longer paired. Tap to create a new invite.',
|
||||
},
|
||||
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
||||
data: { type: 'partner_deleted_account' },
|
||||
})));
|
||||
sendResults.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn(`[onUserDelete] FCM send to token ${tokens[i]} failed:`, result.reason);
|
||||
}
|
||||
});
|
||||
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults);
|
||||
}, partnerData);
|
||||
}
|
||||
//# sourceMappingURL=onUserDelete.js.map
|
||||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"onUserDelete.js","sourceRoot":"","sources":["../../src/users/onUserDelete.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"onUserDelete.js","sourceRoot":"","sources":["../../src/users/onUserDelete.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import * as crypto from 'crypto'
|
||||
import * as functions from 'firebase-functions'
|
||||
import { onRequest, Request } from 'firebase-functions/v2/https'
|
||||
import { defineSecret } from 'firebase-functions/params'
|
||||
import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic'
|
||||
import { logger } from '../log'
|
||||
|
||||
/**
|
||||
* RevenueCat webhook handler.
|
||||
|
|
@ -11,51 +13,57 @@ import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic'
|
|||
* Authentication:
|
||||
* - Verifies the Ed25519 signature in the X-Signature request header.
|
||||
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
|
||||
* public key from your RevenueCat project dashboard.
|
||||
* public key from your RevenueCat project dashboard. It is bound as a Secret
|
||||
* Manager secret (below) and injected into process.env at runtime.
|
||||
* - Missing key → 500 (our config error). Invalid/absent signature → 401.
|
||||
*
|
||||
* Processing:
|
||||
* - Parses the RevenueCat event payload.
|
||||
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
||||
*/
|
||||
export const revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ error: 'method_not_allowed' })
|
||||
return
|
||||
}
|
||||
const revenueCatSigningKey = defineSecret('REVENUECAT_SIGNING_KEY')
|
||||
|
||||
try {
|
||||
verifySignature(req)
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) {
|
||||
console.error('[revenueCatWebhook] configuration error:', err.message)
|
||||
res.status(500).json({ error: 'internal_error' })
|
||||
export const revenueCatWebhook = onRequest(
|
||||
{ secrets: [revenueCatSigningKey] },
|
||||
async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ error: 'method_not_allowed' })
|
||||
return
|
||||
}
|
||||
res.status(401).json({ error: 'unauthorized' })
|
||||
return
|
||||
}
|
||||
|
||||
const event = req.body?.event as EntitlementEvent | undefined
|
||||
if (!event || !event.type || !event.app_user_id) {
|
||||
res.status(400).json({ error: 'malformed_payload' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
verifySignature(req)
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) {
|
||||
logger.error('[revenueCatWebhook] configuration error', { error: err.message })
|
||||
res.status(500).json({ error: 'internal_error' })
|
||||
return
|
||||
}
|
||||
res.status(401).json({ error: 'unauthorized' })
|
||||
return
|
||||
}
|
||||
|
||||
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
|
||||
// and only logged failures, so a failed entitlement sync was silently dropped (no retry).
|
||||
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state),
|
||||
// so returning 500 on failure recovers the event safely.
|
||||
try {
|
||||
await applyEntitlementEvent(event)
|
||||
} catch (err) {
|
||||
console.error('[revenueCatWebhook] entitlement sync failed:', err)
|
||||
res.status(500).json({ error: 'processing_failed' })
|
||||
return
|
||||
}
|
||||
const event = req.body?.event as EntitlementEvent | undefined
|
||||
if (!event || !event.type || !event.app_user_id) {
|
||||
res.status(400).json({ error: 'malformed_payload' })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(200).json({ received: true })
|
||||
})
|
||||
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
|
||||
// and only logged failures, so a failed entitlement sync was silently dropped (no retry).
|
||||
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state),
|
||||
// so returning 500 on failure recovers the event safely.
|
||||
try {
|
||||
await applyEntitlementEvent(event)
|
||||
} catch (err) {
|
||||
logger.error('[revenueCatWebhook] entitlement sync failed', { error: String(err) })
|
||||
res.status(500).json({ error: 'processing_failed' })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(200).json({ received: true })
|
||||
}
|
||||
)
|
||||
|
||||
class ConfigError extends Error {}
|
||||
class AuthError extends Error {}
|
||||
|
|
@ -67,7 +75,7 @@ class AuthError extends Error {}
|
|||
* these are deployment problems, not auth failures.
|
||||
* Throws AuthError for any missing or invalid signature — these are 401s.
|
||||
*/
|
||||
function verifySignature(req: functions.https.Request): void {
|
||||
function verifySignature(req: Request): void {
|
||||
const signingKey = process.env.REVENUECAT_SIGNING_KEY
|
||||
if (!signingKey) {
|
||||
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set')
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { pruneDeadTokens } from '../notifications/pruneTokens'
|
||||
import { auth, runWith } from 'firebase-functions/v1'
|
||||
import { getUserTokens, sendPushToUser } from '../notifications/push'
|
||||
import { logger } from '../log'
|
||||
|
||||
/**
|
||||
* Auth deletion trigger — cascades account deletion server-side.
|
||||
*
|
||||
* This is the ONE function kept on the v1 (1st-gen) API: 2nd gen has no
|
||||
* `auth.user().onDelete` equivalent, so it is imported explicitly from
|
||||
* firebase-functions/v1 and coexists with the v2 functions in this codebase.
|
||||
* Global v2 options (setGlobalOptions) do not apply to it, so the timeout/memory
|
||||
* for its dual recursiveDelete + Storage sweep are set here via runWith().
|
||||
*
|
||||
* Fires when a Firebase Auth user is deleted (via client deleteAccount() or
|
||||
* Admin SDK). The Admin SDK bypasses Firestore rules, so this function can
|
||||
* reach everything the client cannot.
|
||||
|
|
@ -18,84 +25,75 @@ import { pruneDeadTokens } from '../notifications/pruneTokens'
|
|||
* 3. Recursively delete the user doc + all subcollections
|
||||
* (entitlements, fcmTokens, notification_queue).
|
||||
*/
|
||||
export const onUserDelete = functions.auth.user().onDelete(async (user) => {
|
||||
const uid = user.uid
|
||||
const db = admin.firestore()
|
||||
export const onUserDelete = runWith({ timeoutSeconds: 300, memory: '512MB' })
|
||||
.auth.user()
|
||||
.onDelete(async (user: auth.UserRecord) => {
|
||||
const uid = user.uid
|
||||
const db = admin.firestore()
|
||||
|
||||
// ── 1. Couple unpair ──────────────────────────────────────────────────────
|
||||
// ── 1. Couple unpair ──────────────────────────────────────────────────────
|
||||
|
||||
const userDocRef = db.collection('users').doc(uid)
|
||||
const userDoc = await userDocRef.get()
|
||||
const coupleId = userDoc.exists
|
||||
? (userDoc.data()?.coupleId as string | undefined)
|
||||
: undefined
|
||||
const userDocRef = db.collection('users').doc(uid)
|
||||
const userDoc = await userDocRef.get()
|
||||
const coupleId = userDoc.exists
|
||||
? (userDoc.data()?.coupleId as string | undefined)
|
||||
: undefined
|
||||
|
||||
if (coupleId) {
|
||||
const coupleRef = db.collection('couples').doc(coupleId)
|
||||
const coupleDoc = await coupleRef.get()
|
||||
if (coupleId) {
|
||||
const coupleRef = db.collection('couples').doc(coupleId)
|
||||
const coupleDoc = await coupleRef.get()
|
||||
|
||||
if (coupleDoc.exists) {
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
const partnerId = userIds.find((id) => id !== uid)
|
||||
if (coupleDoc.exists) {
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
const partnerId = userIds.find((id) => id !== uid)
|
||||
|
||||
if (partnerId) {
|
||||
const partnerRef = db.collection('users').doc(partnerId)
|
||||
const partnerDoc = await partnerRef.get()
|
||||
if (partnerId) {
|
||||
const partnerRef = db.collection('users').doc(partnerId)
|
||||
const partnerDoc = await partnerRef.get()
|
||||
|
||||
// Only act if the partner is still recorded in this couple.
|
||||
if (partnerDoc.exists && partnerDoc.data()?.coupleId === coupleId) {
|
||||
// Clear partner's coupleId first so the onCoupleLeave trigger does
|
||||
// not fire a second time (it would find no partner left to notify).
|
||||
await partnerRef.update({ coupleId: null })
|
||||
// Only act if the partner is still recorded in this couple.
|
||||
if (partnerDoc.exists && partnerDoc.data()?.coupleId === coupleId) {
|
||||
// Clear partner's coupleId first so the onCoupleLeave trigger does
|
||||
// not fire a second time (it would find no partner left to notify).
|
||||
await partnerRef.update({ coupleId: null })
|
||||
|
||||
// Send push notification to the partner.
|
||||
await notifyPartner(partnerId, partnerDoc, db).catch((err) => {
|
||||
console.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed:`, err)
|
||||
})
|
||||
// Send push notification to the partner.
|
||||
await notifyPartner(partnerId, partnerDoc, db).catch((err) => {
|
||||
logger.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed`, { error: String(err) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively delete the couple doc + all subcollections.
|
||||
await db.recursiveDelete(coupleRef)
|
||||
}
|
||||
|
||||
// Recursively delete the couple doc + all subcollections.
|
||||
await db.recursiveDelete(coupleRef)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Storage cleanup ────────────────────────────────────────────────────
|
||||
// ── 2. Storage cleanup ────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` })
|
||||
} catch (err) {
|
||||
console.warn(`[onUserDelete] Storage cleanup failed for ${uid}:`, err)
|
||||
}
|
||||
try {
|
||||
await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` })
|
||||
} catch (err) {
|
||||
logger.warn(`[onUserDelete] Storage cleanup failed for ${uid}`, { error: String(err) })
|
||||
}
|
||||
|
||||
// ── 3. User doc + subcollections ──────────────────────────────────────────
|
||||
// ── 3. User doc + subcollections ──────────────────────────────────────────
|
||||
|
||||
await db.recursiveDelete(userDocRef)
|
||||
await db.recursiveDelete(userDocRef)
|
||||
|
||||
console.log(`[onUserDelete] completed cleanup for user ${uid}`)
|
||||
})
|
||||
logger.log(`[onUserDelete] completed cleanup for user ${uid}`)
|
||||
})
|
||||
|
||||
async function notifyPartner(
|
||||
partnerId: string,
|
||||
partnerDoc: admin.firestore.DocumentSnapshot,
|
||||
db: admin.firestore.Firestore
|
||||
): Promise<void> {
|
||||
const messaging = admin.messaging()
|
||||
|
||||
// Collect FCM tokens (legacy field + fcmTokens subcollection).
|
||||
const tokens: string[] = []
|
||||
const legacyToken = partnerDoc.data()?.fcmToken
|
||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||
tokens.push(legacyToken)
|
||||
}
|
||||
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get()
|
||||
tokenSnap.docs.forEach((doc) => {
|
||||
const t = doc.data()?.token
|
||||
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
|
||||
tokens.push(t)
|
||||
}
|
||||
})
|
||||
const partnerData = partnerDoc.data()
|
||||
|
||||
// Preserve the original behavior: only write the in-app record + push if the partner has a
|
||||
// live device token (nothing to deliver otherwise).
|
||||
const tokens = await getUserTokens(db, partnerId, partnerData)
|
||||
if (tokens.length === 0) return
|
||||
|
||||
// Write an in-app notification record.
|
||||
|
|
@ -111,25 +109,18 @@ async function notifyPartner(
|
|||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const sendResults = await Promise.allSettled(
|
||||
tokens.map((token) =>
|
||||
messaging.send({
|
||||
token,
|
||||
notification: {
|
||||
title: 'Your partner deleted their account',
|
||||
body: 'You are no longer paired. Tap to create a new invite.',
|
||||
},
|
||||
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
||||
data: { type: 'partner_deleted_account' },
|
||||
})
|
||||
)
|
||||
await sendPushToUser(
|
||||
db,
|
||||
admin.messaging(),
|
||||
partnerId,
|
||||
{
|
||||
notification: {
|
||||
title: 'Your partner deleted their account',
|
||||
body: 'You are no longer paired. Tap to create a new invite.',
|
||||
},
|
||||
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
||||
data: { type: 'partner_deleted_account' },
|
||||
},
|
||||
partnerData,
|
||||
)
|
||||
|
||||
sendResults.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn(`[onUserDelete] FCM send to token ${tokens[i]} failed:`, result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
await pruneDeadTokens(db, partnerId, tokens, sendResults)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue