diff --git a/functions/dist/backup/onRestoreRequested.js b/functions/dist/backup/onRestoreRequested.js index dc49e60e..1d16dcb3 100644 --- a/functions/dist/backup/onRestoreRequested.js +++ b/functions/dist/backup/onRestoreRequested.js @@ -36,15 +36,18 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.onRestoreFulfilled = exports.onRestoreRequested = exports.SELF_ALERT_DEDUPE_MS = void 0; exports.selfAlertAllowed = selfAlertAllowed; exports.isRestoreReadyTransition = isRestoreReadyTransition; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const log_1 = require("../log"); const CHANNEL = 'partner_activity'; -// Suppress duplicate "was this you?" pushes when a request doc is rapidly deleted+recreated (a compromised -// account could loop that to spam both partners). The in-app queue entry is still written every time. +// Suppress duplicate pushes when a request doc is rapidly deleted+recreated (a compromised account +// could loop that to spam both partners) and when an event is redelivered. The in-app queue entry is +// still written every time. A genuine re-request after the window still notifies — so this is a time +// window, NOT a permanent per-recipient marker (which would block legitimate re-requests). exports.SELF_ALERT_DEDUPE_MS = 60 * 1000; -/** Pure dedupe decision: allow a self-alert push only if none was sent within the window. */ +/** Pure dedupe decision: allow an alert push only if none was sent within the window. */ function selfAlertAllowed(lastAlertAt, now) { if (typeof lastAlertAt !== 'number') return true; @@ -54,23 +57,6 @@ function selfAlertAllowed(lastAlertAt, now) { function isRestoreReadyTransition(beforeStatus, afterStatus) { return afterStatus === 'READY' && beforeStatus !== 'READY'; } -/** All FCM tokens for a user: the legacy single field + the multi-device `fcmTokens` subcollection. */ -async function gatherTokens(db, uid) { - var _a; - const tokens = []; - const userDoc = await db.collection('users').doc(uid).get(); - const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; - if (typeof legacy === 'string' && legacy.length > 0) - tokens.push(legacy); - const tokenSnap = await db.collection('users').doc(uid).collection('fcmTokens').get(); - tokenSnap.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; -} /** * Write the durable in-app queue entry (always) and — unless quiet hours suppress it — push to every device. * `bypassQuietHours` is for security signals (the "was this you?" self-alert) that must not be silenced. @@ -83,22 +69,14 @@ async function queueAndPush(db, uid, opts) { const userDoc = await db.collection('users').doc(uid).get(); if (!userDoc.exists) return; - if (!bypassQuietHours && (0, quietHours_1.recipientInQuietHours)(userDoc.data())) + const userData = userDoc.data(); + if (!bypassQuietHours && (0, quietHours_1.recipientInQuietHours)(userData)) return; - const tokens = await gatherTokens(db, uid); - if (tokens.length === 0) - return; - const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send({ + await (0, push_1.sendPushToUser)(db, admin.messaging(), uid, { notification: { title, body }, data: { type, couple_id: coupleId }, - token, android: { notification: { channelId: CHANNEL } }, - }))); - results.forEach((r, i) => { - if (r.status === 'rejected') - console.warn(`[restore] FCM failed for ${tokens[i]}:`, r.reason); - }); - await (0, pruneTokens_1.pruneDeadTokens)(db, uid, tokens, results); + }, userData); } /** * Fires when a member starts a partner-assisted restore @@ -109,11 +87,9 @@ async function queueAndPush(db, uid, opts) { * device (a phished password without device loss), this is how they learn a restore is happening. * No key material is read or logged — the request carries only a public key + a nonce. */ -exports.onRestoreRequested = functions.firestore - .document('couples/{coupleId}/restore_requests/{recipientUid}') - .onCreate(async (_snap, context) => { - var _a, _b, _c; - const { coupleId, recipientUid } = context.params; +exports.onRestoreRequested = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/restore_requests/{recipientUid}', async (event) => { + var _a, _b, _c, _d; + const { coupleId, recipientUid } = event.params; const db = admin.firestore(); const coupleRef = db.collection('couples').doc(coupleId); const coupleDoc = await coupleRef.get(); @@ -127,23 +103,28 @@ exports.onRestoreRequested = functions.firestore if (!partnerId) return; // Audit trail (no key material — actor/recipient/timestamp only). - console.log(`[onRestoreRequested] couple=${coupleId} recipient=${recipientUid} partner=${partnerId}`); - // 1) Partner "help them restore" — routine quiet hours apply. + log_1.logger.log(`[onRestoreRequested] couple=${coupleId} recipient=${recipientUid} partner=${partnerId}`); + // 1) Partner "help them restore" — routine quiet hours apply. Deduped against redelivery and + // request recreate-loops with the same window used for the self-alert. try { - await queueAndPush(db, partnerId, { - type: 'restore_requested', - title: 'Help your partner restore 💜', - body: 'They’re setting up on a new device. Tap to help.', - coupleId, - }); + const now = Date.now(); + if (selfAlertAllowed((_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.lastRestorePartnerAlertAt, now)) { + await coupleRef.set({ lastRestorePartnerAlertAt: now }, { merge: true }); + await queueAndPush(db, partnerId, { + type: 'restore_requested', + title: 'Help your partner restore 💜', + body: 'They’re setting up on a new device. Tap to help.', + coupleId, + }); + } } catch (e) { - console.warn('[onRestoreRequested] partner notify failed:', e); + log_1.logger.warn('[onRestoreRequested] partner notify failed', { error: String(e) }); } // 2) Recipient self-alert — security signal, bypasses quiet hours, deduped against create-loops. try { const now = Date.now(); - if (selfAlertAllowed((_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.lastRestoreSelfAlertAt, now)) { + if (selfAlertAllowed((_d = coupleDoc.data()) === null || _d === void 0 ? void 0 : _d.lastRestoreSelfAlertAt, now)) { await coupleRef.set({ lastRestoreSelfAlertAt: now }, { merge: true }); await queueAndPush(db, recipientUid, { type: 'restore_self_alert', @@ -155,7 +136,7 @@ exports.onRestoreRequested = functions.firestore } } catch (e) { - console.warn('[onRestoreRequested] self-alert failed:', e); + log_1.logger.warn('[onRestoreRequested] self-alert failed', { error: String(e) }); } }); /** @@ -163,21 +144,19 @@ exports.onRestoreRequested = functions.firestore * transfers. Alerts the RECIPIENT'S OWN devices that a restore just completed, the strongest "it happened" * signal for the real owner. Guarded to the single REQUESTED→READY transition (ignores decline/expire). */ -exports.onRestoreFulfilled = functions.firestore - .document('couples/{coupleId}/restore_requests/{recipientUid}') - .onUpdate(async (change, context) => { - var _a, _b; - const before = change.before.data(); - const after = change.after.data(); +exports.onRestoreFulfilled = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/restore_requests/{recipientUid}', async (event) => { + var _a, _b, _c, _d; + const before = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data(); + const after = (_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data(); if (!isRestoreReadyTransition(before === null || before === void 0 ? void 0 : before.status, after === null || after === void 0 ? void 0 : after.status)) return; - const { coupleId, recipientUid } = context.params; + const { coupleId, recipientUid } = event.params; const db = admin.firestore(); const coupleDoc = await db.collection('couples').doc(coupleId).get(); - const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); + const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); if (!userIds.includes(recipientUid)) return; - console.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`); + log_1.logger.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`); try { await queueAndPush(db, recipientUid, { type: 'restore_self_alert', @@ -188,7 +167,7 @@ exports.onRestoreFulfilled = functions.firestore }); } catch (e) { - console.warn('[onRestoreFulfilled] self-alert failed:', e); + log_1.logger.warn('[onRestoreFulfilled] self-alert failed', { error: String(e) }); } }); //# sourceMappingURL=onRestoreRequested.js.map \ No newline at end of file diff --git a/functions/dist/backup/onRestoreRequested.js.map b/functions/dist/backup/onRestoreRequested.js.map index b912b208..89a414c6 100644 --- a/functions/dist/backup/onRestoreRequested.js.map +++ b/functions/dist/backup/onRestoreRequested.js.map @@ -1 +1 @@ -{"version":3,"file":"onRestoreRequested.js","sourceRoot":"","sources":["../../src/backup/onRestoreRequested.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAWA,4CAGC;AAGD,4DAEC;AAnBD,8DAA+C;AAC/C,sDAAuC;AACvC,4DAAmE;AACnE,8DAA8D;AAE9D,MAAM,OAAO,GAAG,kBAAkB,CAAA;AAClC,2GAA2G;AAC3G,sGAAsG;AACzF,QAAA,oBAAoB,GAAG,EAAE,GAAG,IAAI,CAAA;AAE7C,6FAA6F;AAC7F,SAAgB,gBAAgB,CAAC,WAAoB,EAAE,GAAW;IAChE,IAAI,OAAO,WAAW,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IAChD,OAAO,GAAG,GAAG,WAAW,IAAI,4BAAoB,CAAA;AAClD,CAAC;AAED,kGAAkG;AAClG,SAAgB,wBAAwB,CAAC,YAAqB,EAAE,WAAoB;IAClF,OAAO,WAAW,KAAK,OAAO,IAAI,YAAY,KAAK,OAAO,CAAA;AAC5D,CAAC;AAED,uGAAuG;AACvG,KAAK,UAAU,YAAY,CAAC,EAA6B,EAAE,GAAW;;IACpE,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3D,MAAM,MAAM,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IACvC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxE,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IACrF,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;;QAC3B,MAAM,CAAC,GAAG,MAAA,CAAC,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IACF,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;GAGG;AACH,KAAK,UAAU,YAAY,CACzB,EAA6B,EAC7B,GAAW,EACX,IAAiG;IAEjG,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAAA;IAC9D,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QACzE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxF,CAAC,CAAA;IAEF,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3D,IAAI,CAAC,OAAO,CAAC,MAAM;QAAE,OAAM;IAC3B,IAAI,CAAC,gBAAgB,IAAI,IAAA,kCAAqB,EAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QAAE,OAAM;IAEtE,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,EAAE,EAAE,GAAG,CAAC,CAAA;IAC1C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAC/B,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC;QACrB,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE;QACnC,KAAK;QACL,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE;KACvB,CAAC,CAC9B,CACF,CAAA;IACD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU;YAAE,OAAO,CAAC,IAAI,CAAC,4BAA4B,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;IAC/F,CAAC,CAAC,CAAA;IACF,MAAM,IAAA,6BAAe,EAAC,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;AACjD,CAAC;AAED;;;;;;;;GAQG;AACU,QAAA,kBAAkB,GAAG,SAAS,CAAC,SAAS;KAClD,QAAQ,CAAC,oDAAoD,CAAC;KAC9D,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACjC,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC,MAAoD,CAAA;IAC/F,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACxD,MAAM,SAAS,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAAA;IACvC,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAM;IAC7B,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,sFAAsF;IACtF,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;QAAE,OAAM;IAC3C,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,YAAY,CAAC,CAAA;IACzD,IAAI,CAAC,SAAS;QAAE,OAAM;IAEtB,kEAAkE;IAClE,OAAO,CAAC,GAAG,CAAC,+BAA+B,QAAQ,cAAc,YAAY,YAAY,SAAS,EAAE,CAAC,CAAA;IAErG,8DAA8D;IAC9D,IAAI,CAAC;QACH,MAAM,YAAY,CAAC,EAAE,EAAE,SAAS,EAAE;YAChC,IAAI,EAAE,mBAAmB;YACzB,KAAK,EAAE,8BAA8B;YACrC,IAAI,EAAE,kDAAkD;YACxD,QAAQ;SACT,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,IAAI,CAAC,6CAA6C,EAAE,CAAC,CAAC,CAAA;IAChE,CAAC;IAED,iGAAiG;IACjG,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;QACtB,IAAI,gBAAgB,CAAC,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,sBAAsB,EAAE,GAAG,CAAC,EAAE,CAAC;YACpE,MAAM,SAAS,CAAC,GAAG,CAAC,EAAE,sBAAsB,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;YACrE,MAAM,YAAY,CAAC,EAAE,EAAE,YAAY,EAAE;gBACnC,IAAI,EAAE,oBAAoB;gBAC1B,KAAK,EAAE,sCAAsC;gBAC7C,IAAI,EAAE,8CAA8C;gBACpD,QAAQ;gBACR,gBAAgB,EAAE,IAAI;aACvB,CAAC,CAAA;QACJ,CAAC;IACH,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,IAAI,CAAC,yCAAyC,EAAE,CAAC,CAAC,CAAA;IAC5D,CAAC;AACH,CAAC,CAAC,CAAA;AAEJ;;;;GAIG;AACU,QAAA,kBAAkB,GAAG,SAAS,CAAC,SAAS;KAClD,QAAQ,CAAC,oDAAoD,CAAC;KAC9D,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;;IAClC,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAA;IACnC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAA;IACjC,IAAI,CAAC,wBAAwB,CAAC,MAAM,aAAN,MAAM,uBAAN,MAAM,CAAE,MAAM,EAAE,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,MAAM,CAAC;QAAE,OAAM;IAEpE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC,MAAoD,CAAA;IAC/F,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;QAAE,OAAM;IAE3C,OAAO,CAAC,GAAG,CAAC,+BAA+B,QAAQ,cAAc,YAAY,EAAE,CAAC,CAAA;IAChF,IAAI,CAAC;QACH,MAAM,YAAY,CAAC,EAAE,EAAE,YAAY,EAAE;YACnC,IAAI,EAAE,oBAAoB;YAC1B,KAAK,EAAE,gCAAgC;YACvC,IAAI,EAAE,2EAA2E;YACjF,QAAQ;YACR,gBAAgB,EAAE,IAAI;SACvB,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,IAAI,CAAC,yCAAyC,EAAE,CAAC,CAAC,CAAA;IAC5D,CAAC;AACH,CAAC,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"onRestoreRequested.js","sourceRoot":"","sources":["../../src/backup/onRestoreRequested.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/billing/onEntitlementChanged.js b/functions/dist/billing/onEntitlementChanged.js index 02a3b999..c7a073a8 100644 --- a/functions/dist/billing/onEntitlementChanged.js +++ b/functions/dist/billing/onEntitlementChanged.js @@ -34,9 +34,11 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onEntitlementChanged = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const firestore_1 = require("firebase-functions/v2/firestore"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Notifies the OTHER partner when a user GAINS premium, so couple-shared premium unlock isn't silent * for the non-subscriber. (Future.md: `subscription_entitlement_changed`.) @@ -55,33 +57,14 @@ function isActive(data) { return false; return true; } -async function collectTokens(db, userId) { - var _a; - const tokens = []; - const userDoc = await db.collection('users').doc(userId).get(); - const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; - if (typeof legacy === 'string' && legacy.length > 0) - tokens.push(legacy); - const tokSnap = await db.collection('users').doc(userId).collection('fcmTokens').get(); - tokSnap.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; -} -exports.onEntitlementChanged = functions.firestore - .document('users/{userId}/entitlements/premium') - .onWrite(async (change, context) => { - var _a, _b; - const { userId } = context.params; - const before = isActive(change.before.data()); - const after = isActive(change.after.data()); +exports.onEntitlementChanged = (0, firestore_1.onDocumentWritten)('users/{userId}/entitlements/premium', async (event) => { + var _a, _b, _c, _d; + const { userId } = event.params; + const before = isActive((_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()); + const after = isActive((_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data()); if (before || !after) return; // only a genuine inactive→active gain const db = admin.firestore(); - const messaging = admin.messaging(); // Resolve this user's couple + partner. const coupleSnap = await db .collection('couples') @@ -89,21 +72,27 @@ exports.onEntitlementChanged = functions.firestore .limit(1) .get(); if (coupleSnap.empty) { - console.log(`[onEntitlementChanged] no couple for ${userId}`); + log_1.logger.log(`[onEntitlementChanged] no couple for ${userId}`); return; } const coupleDoc = coupleSnap.docs[0]; const coupleId = coupleDoc.id; - const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); + const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); const partnerId = userIds.find((id) => id !== userId); if (!partnerId) { - console.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`); + log_1.logger.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`); return; } // If the partner already has premium the couple was already unlocked — nothing new to announce. const partnerEnt = await db.doc(`users/${partnerId}/entitlements/premium`).get(); if (isActive(partnerEnt.data())) { - console.log(`[onEntitlementChanged] partner ${partnerId} already premium; skip`); + log_1.logger.log(`[onEntitlementChanged] partner ${partnerId} already premium; skip`); + return; + } + // Dedupe redelivery of this onWrite (at-least-once) so the partner isn't double-notified and + // the in-app record isn't duplicated. + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `entitlement-${userId}`)))) { + log_1.logger.log(`[onEntitlementChanged] already announced premium for ${userId}; skip`); return; } // displayName is E2EE in users/{uid}, so use a generic label (this copy is stored server-side in @@ -119,24 +108,15 @@ exports.onEntitlementChanged = functions.firestore .doc(partnerId) .collection('notification_queue') .add(Object.assign(Object.assign({}, payload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() })); - const tokens = await collectTokens(db, partnerId); - if (tokens.length === 0) { - console.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`); - return; - } - const message = { - token: tokens[0], + const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: { title: payload.title, body: payload.body }, android: { notification: { channelId: 'partner_activity' } }, data: { type: payload.type, couple_id: coupleId }, - }; - const results = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token })))); - results.forEach((r, i) => { - if (r.status === 'rejected') { - console.warn(`[onEntitlementChanged] send failed ${tokens[i]}: ${String(r.reason)}`); - } }); - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, results); - console.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`); + if (res.sent === 0 && res.failed === 0) { + log_1.logger.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`); + return; + } + log_1.logger.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`); }); //# sourceMappingURL=onEntitlementChanged.js.map \ No newline at end of file diff --git a/functions/dist/billing/onEntitlementChanged.js.map b/functions/dist/billing/onEntitlementChanged.js.map index cd93a4a1..f23696bd 100644 --- a/functions/dist/billing/onEntitlementChanged.js.map +++ b/functions/dist/billing/onEntitlementChanged.js.map @@ -1 +1 @@ -{"version":3,"file":"onEntitlementChanged.js","sourceRoot":"","sources":["../../src/billing/onEntitlementChanged.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onEntitlementChanged.js","sourceRoot":"","sources":["../../src/billing/onEntitlementChanged.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/couples/onCoupleLeave.js b/functions/dist/couples/onCoupleLeave.js index 48fd5caa..1c737508 100644 --- a/functions/dist/couples/onCoupleLeave.js +++ b/functions/dist/couples/onCoupleLeave.js @@ -34,9 +34,11 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onCoupleLeave = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const firestore_1 = require("firebase-functions/v2/firestore"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Firestore trigger that notifies the remaining partner when a user's coupleId * field is cleared (i.e. the user left the couple or was removed). @@ -44,13 +46,11 @@ const pruneTokens_1 = require("../notifications/pruneTokens"); * Path: users/{userId} * Condition: previous coupleId was non-empty and new coupleId is null/missing. */ -exports.onCoupleLeave = functions.firestore - .document('users/{userId}') - .onUpdate(async (change, context) => { +exports.onCoupleLeave = (0, firestore_1.onDocumentUpdated)('users/{userId}', async (event) => { var _a, _b, _c, _d, _e, _f; - const { userId } = context.params; - const previousData = (_a = change.before.data()) !== null && _a !== void 0 ? _a : {}; - const currentData = (_b = change.after.data()) !== null && _b !== void 0 ? _b : {}; + const { userId } = event.params; + const previousData = (_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {}; + const currentData = (_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {}; const previousCoupleId = previousData.coupleId; const currentCoupleId = currentData.coupleId; // Only act when coupleId transitions from a real value to null/empty. @@ -61,24 +61,30 @@ exports.onCoupleLeave = functions.firestore return; } const db = admin.firestore(); - const messaging = admin.messaging(); const coupleDoc = await db.collection('couples').doc(previousCoupleId).get(); if (!coupleDoc.exists) { - console.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`); + log_1.logger.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`); return; } - const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); + const userIds = ((_f = (_e = coupleDoc.data()) === null || _e === void 0 ? void 0 : _e.userIds) !== null && _f !== void 0 ? _f : []); const partnerId = userIds.find((uid) => uid !== userId); if (!partnerId) { - console.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`); + log_1.logger.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`); return; } // Make sure the partner is still paired in this couple. // If both users are leaving simultaneously, avoid duplicate/phantom notifications. const partnerUserDoc = await db.collection('users').doc(partnerId).get(); - const partnerCoupleId = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.coupleId; + const partnerData = partnerUserDoc.data(); + const partnerCoupleId = partnerData === null || partnerData === void 0 ? void 0 : partnerData.coupleId; if (partnerCoupleId !== previousCoupleId) { - console.log(`[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification`); + log_1.logger.log(`[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification`); + return; + } + // Dedupe redelivery of this leave (at-least-once) so the partner isn't double-notified and the + // in-app record isn't duplicated. + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, previousCoupleId, `leave-${userId}`)))) { + log_1.logger.log(`[onCoupleLeave] already notified partner of ${userId} leaving; skipping`); return; } const notificationPayload = { @@ -93,32 +99,7 @@ exports.onCoupleLeave = functions.firestore .doc(partnerId) .collection('notification_queue') .add(Object.assign(Object.assign({}, notificationPayload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() })); - // Collect the partner's FCM tokens (legacy field + fcmTokens subcollection). - const tokens = []; - if (partnerUserDoc.exists) { - const legacyToken = (_f = partnerUserDoc.data()) === null || _f === void 0 ? void 0 : _f.fcmToken; - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken); - } - } - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get(); - tokenSnapshot.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); - } - }); - if (tokens.length === 0) { - console.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`); - return; - } - const fcmMessage = { - token: tokens[0], + const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: { title: notificationPayload.title, body: notificationPayload.body, @@ -127,18 +108,11 @@ exports.onCoupleLeave = functions.firestore data: { type: notificationPayload.type, }, - }; - const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, fcmMessage), { token })))); - const failures = []; - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`); - } - }); - if (failures.length > 0) { - console.error(`[onCoupleLeave] some notifications failed:`, failures); + }, partnerData); + if (res.sent === 0 && res.failed === 0) { + log_1.logger.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`); + return; } - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults); - console.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`); + log_1.logger.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`); }); //# sourceMappingURL=onCoupleLeave.js.map \ No newline at end of file diff --git a/functions/dist/couples/onCoupleLeave.js.map b/functions/dist/couples/onCoupleLeave.js.map index ee5bb026..cecfd2c3 100644 --- a/functions/dist/couples/onCoupleLeave.js.map +++ b/functions/dist/couples/onCoupleLeave.js.map @@ -1 +1 @@ -{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/dates/createDateMatch.js b/functions/dist/dates/createDateMatch.js index 47b48f6b..7cdbe90e 100644 --- a/functions/dist/dates/createDateMatch.js +++ b/functions/dist/dates/createDateMatch.js @@ -34,9 +34,9 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.notifyOnDateMatch = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const firestore_1 = require("firebase-functions/v2/firestore"); +const push_1 = require("../notifications/push"); /** * Fires the "It's a match!" notification when a date match is created. * @@ -48,16 +48,15 @@ const pruneTokens_1 = require("../notifications/pruneTokens"); * only sends the push to both partners — it never reads swipe content. * * Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations - * (or a client retry) never double-send. The match doc id is the date idea id, so + * (or a redelivered event) never double-send. The match doc id is the date idea id, so * the marker itself is already de-duplicated by the client transaction + rules. */ -exports.notifyOnDateMatch = functions.firestore - .document('couples/{coupleId}/date_matches/{dateIdeaId}') - .onCreate(async (snap, context) => { +exports.notifyOnDateMatch = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_matches/{dateIdeaId}', async (event) => { var _a, _b; - if (!snap.exists) + const snap = event.data; + if (!snap || !snap.exists) return; - const { coupleId, dateIdeaId } = context.params; + const { coupleId, dateIdeaId } = event.params; const db = admin.firestore(); const matchRef = snap.ref; // Atomically claim the FCM send so concurrent invocations don't double-send. @@ -73,7 +72,7 @@ exports.notifyOnDateMatch = functions.firestore return; const coupleDoc = await db.collection('couples').doc(coupleId).get(); const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); - await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId))); + await Promise.allSettled(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId))); }); async function notifyDateMatch(db, userId, coupleId, dateIdeaId) { await db.collection('users').doc(userId).collection('notification_queue').add({ @@ -83,11 +82,7 @@ async function notifyDateMatch(db, userId, coupleId, dateIdeaId) { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - const tokens = await getUserTokens(db, userId); - if (tokens.length === 0) - return; - const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send({ - token, + await (0, push_1.sendPushToUser)(db, admin.messaging(), userId, { notification: { title: "It's a match!", body: "You both want to go on this date. Time to make it happen.", @@ -98,23 +93,6 @@ async function notifyDateMatch(db, userId, coupleId, dateIdeaId) { couple_id: coupleId, date_idea_id: dateIdeaId, }, - }))); - await (0, pruneTokens_1.pruneDeadTokens)(db, userId, tokens, results); -} -async function getUserTokens(db, userId) { - var _a; - const tokens = []; - const userDoc = await db.collection('users').doc(userId).get(); - const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; - if (typeof legacy === 'string' && legacy.length > 0) - tokens.push(legacy); - const snap = await db.collection('users').doc(userId).collection('fcmTokens').get(); - snap.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); }); - return tokens; } //# sourceMappingURL=createDateMatch.js.map \ No newline at end of file diff --git a/functions/dist/dates/createDateMatch.js.map b/functions/dist/dates/createDateMatch.js.map index 007fbc2d..449df46a 100644 --- a/functions/dist/dates/createDateMatch.js.map +++ b/functions/dist/dates/createDateMatch.js.map @@ -1 +1 @@ -{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,gDAAsD;AAEtD;;;;;;;;;;;;;GAaG;AACU,QAAA,iBAAiB,GAAG,IAAA,6BAAiB,EAChD,8CAA8C,EAC9C,KAAK,EAAE,KAAK,EAAE,EAAE;;IACd,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAA;IACvB,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM;QAAE,OAAM;IAEjC,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IAE7C,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAA;IAEzB,6EAA6E;IAC7E,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;;QACtD,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;QAClC,IAAI,CAAC,GAAG,CAAC,MAAM,IAAI,CAAA,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,WAAW,MAAK,IAAI;YAAE,OAAO,KAAK,CAAA;QACjE,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAA;QAC1C,OAAO,IAAI,CAAA;IACb,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,UAAU;QAAE,OAAM;IAEvB,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,eAAe,CAAC,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC,CAAC,CAAA;AAChG,CAAC,CACF,CAAA;AAED,KAAK,UAAU,eAAe,CAC5B,EAA6B,EAC7B,MAAc,EACd,QAAgB,EAChB,UAAkB;IAElB,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC5E,IAAI,EAAE,YAAY;QAClB,KAAK,EAAE,eAAe;QACtB,IAAI,EAAE,2DAA2D;QACjE,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxD,CAAC,CAAA;IAEF,MAAM,IAAA,qBAAc,EAAC,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE;QAClD,YAAY,EAAE;YACZ,KAAK,EAAE,eAAe;YACtB,IAAI,EAAE,2DAA2D;SAClE;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,QAAQ;QACtE,IAAI,EAAE;YACJ,IAAI,EAAE,YAAY;YAClB,SAAS,EAAE,QAAQ;YACnB,YAAY,EAAE,UAAU;SACzB;KACF,CAAC,CAAA;AACJ,CAAC"} \ No newline at end of file diff --git a/functions/dist/dates/onDateHistoryCreated.js b/functions/dist/dates/onDateHistoryCreated.js index 848002af..d965eb6f 100644 --- a/functions/dist/dates/onDateHistoryCreated.js +++ b/functions/dist/dates/onDateHistoryCreated.js @@ -34,20 +34,23 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onDateHistoryCreated = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Fires when a date is logged as completed (`couples/{coupleId}/date_history/{dateId}` created). Nudges * the OTHER partner (not the one who logged it) to add their reflection — so the reflect→reveal loop * starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours. */ -exports.onDateHistoryCreated = functions.firestore - .document('couples/{coupleId}/date_history/{dateId}') - .onCreate(async (snap, context) => { - var _a, _b, _c, _d, _e; - const { coupleId, dateId } = context.params; +exports.onDateHistoryCreated = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_history/{dateId}', async (event) => { + var _a, _b, _c; + const { coupleId, dateId } = event.params; + const snap = event.data; + if (!snap) + return; const db = admin.firestore(); const addedBy = (_a = snap.data()) === null || _a === void 0 ? void 0 : _a.addedBy; const coupleDoc = await db.collection('couples').doc(coupleId).get(); @@ -58,37 +61,25 @@ exports.onDateHistoryCreated = functions.firestore if (!partnerId) return; const partnerUserDoc = await db.collection('users').doc(partnerId).get(); - if (((_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.notifPartnerAnswered) === false) + const partnerData = partnerUserDoc.data(); + if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) return; + // Dedupe redelivery (at-least-once) before writing the in-app record or sending. + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `date-logged-${dateId}`)))) { + log_1.logger.log(`[onDateHistoryCreated] already notified for date ${dateId}; skipping`); + return; + } const title = 'You went on a date 💜'; const body = 'Reflect on it together while it’s fresh.'; await db.collection('users').doc(partnerId).collection('notification_queue').add({ type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) + if ((0, quietHours_1.recipientInQuietHours)(partnerData)) return; - const tokens = []; - const legacy = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken; - if (typeof legacy === 'string' && legacy.length > 0) - tokens.push(legacy); - const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get(); - tokenSnap.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); - }); - if (tokens.length === 0) - return; - const payload = { + await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: { title, body }, data: { type: 'date_logged', couple_id: coupleId, date_id: dateId }, - }; - const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); - results.forEach((r, i) => { - if (r.status === 'rejected') - console.warn(`[onDateHistoryCreated] FCM failed for ${tokens[i]}:`, r.reason); - }); - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, results); + android: { notification: { channelId: 'partner_activity' } }, + }, partnerData); }); //# sourceMappingURL=onDateHistoryCreated.js.map \ No newline at end of file diff --git a/functions/dist/dates/onDateHistoryCreated.js.map b/functions/dist/dates/onDateHistoryCreated.js.map index bdb4b5e9..583784c6 100644 --- a/functions/dist/dates/onDateHistoryCreated.js.map +++ b/functions/dist/dates/onDateHistoryCreated.js.map @@ -1 +1 @@ -{"version":3,"file":"onDateHistoryCreated.js","sourceRoot":"","sources":["../../src/dates/onDateHistoryCreated.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,4DAAmE;AACnE,8DAA8D;AAE9D;;;;GAIG;AACU,QAAA,oBAAoB,GAAG,SAAS,CAAC,SAAS;KACpD,QAAQ,CAAC,0CAA0C,CAAC;KACpD,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;;IAChC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAA8C,CAAA;IACnF,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,IAAI,EAAE,0CAAE,OAA6B,CAAA;IAC1D,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAM;IAC7B,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,OAAO,CAAC,CAAA;IACpD,IAAI,CAAC,SAAS;QAAE,OAAM;IAEtB,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,IAAI,CAAA,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,oBAAoB,MAAK,KAAK;QAAE,OAAM;IAEjE,MAAM,KAAK,GAAG,uBAAuB,CAAA;IACrC,MAAM,IAAI,GAAG,0CAA0C,CAAA;IAEvD,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACvG,CAAC,CAAA;IAEF,IAAI,IAAA,kCAAqB,EAAC,cAAc,CAAC,IAAI,EAAE,CAAC;QAAE,OAAM;IAExD,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,MAAM,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAC9C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxE,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3F,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;;QAC3B,MAAM,CAAC,GAAG,MAAA,CAAC,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IACF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/B,MAAM,OAAO,GAAqC;QAChD,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE;KACpE,CAAA;IACD,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,gCAClB,OAAO,KACV,KAAK,EACL,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,GAClC,CAAC,CAC9B,CACF,CAAA;IACD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU;YAAE,OAAO,CAAC,IAAI,CAAC,yCAAyC,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;IAC5G,CAAC,CAAC,CAAA;IACF,MAAM,IAAA,6BAAe,EAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;AACvD,CAAC,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"onDateHistoryCreated.js","sourceRoot":"","sources":["../../src/dates/onDateHistoryCreated.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,4DAAmE;AACnE,gDAAsD;AACtD,8DAAmE;AACnE,gCAA+B;AAE/B;;;;GAIG;AACU,QAAA,oBAAoB,GAAG,IAAA,6BAAiB,EACnD,0CAA0C,EAC1C,KAAK,EAAE,KAAK,EAAE,EAAE;;IACd,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IACzC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAA;IACvB,IAAI,CAAC,IAAI;QAAE,OAAM;IACjB,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,IAAI,EAAE,0CAAE,OAA6B,CAAA;IAC1D,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAM;IAC7B,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,OAAO,CAAC,CAAA;IACpD,IAAI,CAAC,SAAS;QAAE,OAAM;IAEtB,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,MAAM,WAAW,GAAG,cAAc,CAAC,IAAI,EAAE,CAAA;IACzC,IAAI,CAAA,WAAW,aAAX,WAAW,uBAAX,WAAW,CAAE,oBAAoB,MAAK,KAAK;QAAE,OAAM;IAEvD,iFAAiF;IACjF,IAAI,CAAC,CAAC,MAAM,IAAA,uBAAS,EAAC,IAAA,uBAAS,EAAC,EAAE,EAAE,QAAQ,EAAE,eAAe,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzE,YAAM,CAAC,GAAG,CAAC,oDAAoD,MAAM,YAAY,CAAC,CAAA;QAClF,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,uBAAuB,CAAA;IACrC,MAAM,IAAI,GAAG,0CAA0C,CAAA;IAEvD,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACvG,CAAC,CAAA;IAEF,IAAI,IAAA,kCAAqB,EAAC,WAAW,CAAC;QAAE,OAAM;IAE9C,MAAM,IAAA,qBAAc,EAClB,EAAE,EACF,KAAK,CAAC,SAAS,EAAE,EACjB,SAAS,EACT;QACE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE;QACnE,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE;KAC7D,EACD,WAAW,CACZ,CAAA;AACH,CAAC,CACF,CAAA"} \ No newline at end of file diff --git a/functions/dist/dates/onDateReflectionRevealed.js b/functions/dist/dates/onDateReflectionRevealed.js index 66d10dc6..f846cbca 100644 --- a/functions/dist/dates/onDateReflectionRevealed.js +++ b/functions/dist/dates/onDateReflectionRevealed.js @@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onDateReflectionRevealed = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Fires when a partner OPENS (reveals) the shared date reflections — their own reflection metadata doc * flips `isRevealed` false → true (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`). @@ -45,13 +47,11 @@ const pruneTokens_1 = require("../notifications/pruneTokens"); * generic copy (no decrypted content, no name — the app renders the real name in-app), gated on * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept). */ -exports.onDateReflectionRevealed = functions.firestore - .document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') - .onUpdate(async (change, context) => { - var _a, _b, _c, _d, _e; - const { coupleId, dateId, userId } = context.params; - const before = change.before.data(); - const after = change.after.data(); +exports.onDateReflectionRevealed = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', async (event) => { + var _a, _b, _c, _d, _e, _f, _g; + const { coupleId, dateId, userId } = event.params; + const before = ((_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {}); + const after = ((_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {}); // Only on the false → true reveal transition. if (before.isRevealed === true || after.isRevealed !== true) return; @@ -59,15 +59,21 @@ exports.onDateReflectionRevealed = functions.firestore const coupleDoc = await db.collection('couples').doc(coupleId).get(); if (!coupleDoc.exists) return; - const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); + const userIds = ((_f = (_e = coupleDoc.data()) === null || _e === void 0 ? void 0 : _e.userIds) !== null && _f !== void 0 ? _f : []); if (!userIds.includes(userId)) return; const partnerId = userIds.find((u) => u !== userId); if (!partnerId) return; const partnerUserDoc = await db.collection('users').doc(partnerId).get(); - if (((_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifPartnerAnswered) === false) { - console.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`); + const partnerData = partnerUserDoc.data(); + if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) { + log_1.logger.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`); + return; + } + // Dedupe redelivery (at-least-once) before writing the in-app record or sending. + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `date-reveal-${dateId}-${userId}`)))) { + log_1.logger.log(`[onDateReflectionRevealed] already notified for ${userId} on ${dateId}; skipping`); return; } const title = 'Your partner opened your reflection ✨'; @@ -77,35 +83,17 @@ exports.onDateReflectionRevealed = functions.firestore await db.collection('users').doc(partnerId).collection('notification_queue').add({ type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { - console.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`); + if ((0, quietHours_1.recipientInQuietHours)(partnerData)) { + log_1.logger.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`); return; } - const senderAvatar = (_d = (await db.collection('users').doc(userId).get()).data()) === null || _d === void 0 ? void 0 : _d.photoUrl; - const tokens = []; - const legacy = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken; - if (typeof legacy === 'string' && legacy.length > 0) - tokens.push(legacy); - const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get(); - tokenSnap.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); - }); - if (tokens.length === 0) - return; - const payload = { + const senderAvatar = (_g = (await db.collection('users').doc(userId).get()).data()) === null || _g === void 0 ? void 0 : _g.photoUrl; + await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: { title, body }, data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0 ? { sender_avatar_url: senderAvatar } : {})), - }; - const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); - results.forEach((r, i) => { - if (r.status === 'rejected') - console.warn(`[onDateReflectionRevealed] FCM failed for ${tokens[i]}:`, r.reason); - }); - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, results); + android: { notification: { channelId: 'partner_activity' } }, + }, partnerData); }); //# sourceMappingURL=onDateReflectionRevealed.js.map \ No newline at end of file diff --git a/functions/dist/dates/onDateReflectionRevealed.js.map b/functions/dist/dates/onDateReflectionRevealed.js.map index 568062ae..7f9a3fb4 100644 --- a/functions/dist/dates/onDateReflectionRevealed.js.map +++ b/functions/dist/dates/onDateReflectionRevealed.js.map @@ -1 +1 @@ -{"version":3,"file":"onDateReflectionRevealed.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionRevealed.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onDateReflectionRevealed.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionRevealed.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/dates/onDateReflectionWritten.js b/functions/dist/dates/onDateReflectionWritten.js index f84d292f..7fd339ed 100644 --- a/functions/dist/dates/onDateReflectionWritten.js +++ b/functions/dist/dates/onDateReflectionWritten.js @@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onDateReflectionWritten = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Fires when a partner writes their post-date reflection * (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`). Notifies the OTHER partner — "your @@ -46,11 +48,9 @@ const pruneTokens_1 = require("../notifications/pruneTokens"); * generic copy (no decrypted content, no name — the app renders the real name in-app), gated on * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept). */ -exports.onDateReflectionWritten = functions.firestore - .document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') - .onCreate(async (_snap, context) => { - var _a, _b, _c, _d, _e; - const { coupleId, dateId, userId } = context.params; +exports.onDateReflectionWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', async (event) => { + var _a, _b, _c; + const { coupleId, dateId, userId } = event.params; const db = admin.firestore(); const coupleDoc = await db.collection('couples').doc(coupleId).get(); if (!coupleDoc.exists) @@ -62,9 +62,15 @@ exports.onDateReflectionWritten = functions.firestore if (!partnerId) return; const partnerUserDoc = await db.collection('users').doc(partnerId).get(); + const partnerData = partnerUserDoc.data(); // Partner-activity opt-out (default on). - if (((_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifPartnerAnswered) === false) { - console.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`); + if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) { + log_1.logger.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`); + return; + } + // Dedupe redelivery (at-least-once) before writing the in-app record or sending. + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `date-reflect-${dateId}-${userId}`)))) { + log_1.logger.log(`[onDateReflectionWritten] already notified for ${userId} on ${dateId}; skipping`); return; } // Did this complete the pair? If the recipient already reflected, both are done → reveal-ready. @@ -81,35 +87,17 @@ exports.onDateReflectionWritten = functions.firestore type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); // Quiet hours: keep the in-app record, suppress the disruptive push. - if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { - console.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`); + if ((0, quietHours_1.recipientInQuietHours)(partnerData)) { + log_1.logger.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`); return; } - const senderAvatar = (_d = (await db.collection('users').doc(userId).get()).data()) === null || _d === void 0 ? void 0 : _d.photoUrl; - const tokens = []; - const legacy = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken; - if (typeof legacy === 'string' && legacy.length > 0) - tokens.push(legacy); - const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get(); - tokenSnap.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); - }); - if (tokens.length === 0) - return; - const payload = { + const senderAvatar = (_c = (await db.collection('users').doc(userId).get()).data()) === null || _c === void 0 ? void 0 : _c.photoUrl; + await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: { title, body }, data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0 ? { sender_avatar_url: senderAvatar } : {})), - }; - const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); - results.forEach((r, i) => { - if (r.status === 'rejected') - console.warn(`[onDateReflectionWritten] FCM failed for ${tokens[i]}:`, r.reason); - }); - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, results); + android: { notification: { channelId: 'partner_activity' } }, // E-OBS + }, partnerData); }); //# sourceMappingURL=onDateReflectionWritten.js.map \ No newline at end of file diff --git a/functions/dist/dates/onDateReflectionWritten.js.map b/functions/dist/dates/onDateReflectionWritten.js.map index 35cfd264..4ea837a0 100644 --- a/functions/dist/dates/onDateReflectionWritten.js.map +++ b/functions/dist/dates/onDateReflectionWritten.js.map @@ -1 +1 @@ -{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/games/onGameSessionUpdate.js b/functions/dist/games/onGameSessionUpdate.js index ee41efe2..c0b7b2b5 100644 --- a/functions/dist/games/onGameSessionUpdate.js +++ b/functions/dist/games/onGameSessionUpdate.js @@ -34,44 +34,46 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onGamePartFinished = exports.onGameSessionUpdate = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const log_1 = require("../log"); /** * Firestore trigger that notifies partners when a game session is created or completed. * * Path: couples/{coupleId}/sessions/{sessionId} * Condition: onWrite (create, update, delete) */ -exports.onGameSessionUpdate = functions.firestore - .document('couples/{coupleId}/sessions/{sessionId}') - .onWrite(async (change, context) => { +exports.onGameSessionUpdate = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/sessions/{sessionId}', async (event) => { var _a, _b, _c, _d, _e, _f, _g, _h; - const { coupleId, sessionId } = context.params; + const { coupleId, sessionId } = event.params; // The per-couple active-session lock lives at sessions/_active — it is a pointer, not a // game session, so it must never produce a partner notification. if (sessionId === '_active') return; + const change = event.data; + if (!change) + return; const db = admin.firestore(); const messaging = admin.messaging(); // Get the session document const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get(); const session = sessionDoc.data(); if (!session) { - console.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`); + log_1.logger.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`); return; } // Get couple info const coupleDoc = await db.collection('couples').doc(coupleId).get(); if (!coupleDoc.exists) { - console.warn(`[onGameSessionUpdate] couple ${coupleId} not found`); + log_1.logger.warn(`[onGameSessionUpdate] couple ${coupleId} not found`); return; } const coupleData = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {}; const userIds = ((_b = coupleData.userIds) !== null && _b !== void 0 ? _b : []); if (userIds.length !== 2) { - console.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`); + log_1.logger.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`); return; } const partnerA = userIds[0]; @@ -115,7 +117,7 @@ exports.onGameSessionUpdate = functions.firestore const starterName = startedBy === partnerA ? partnerAName : partnerBName; const starterAvatar = startedBy === partnerA ? avatarA : avatarB; if ((0, quietHours_1.recipientInQuietHours)(dataFor(recipientId))) { - console.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`); + log_1.logger.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`); } else { await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId); @@ -147,7 +149,7 @@ exports.onGameSessionUpdate = functions.firestore const joinerName = joiner === partnerA ? partnerAName : partnerBName; const joinerAvatar = joiner === partnerA ? avatarA : avatarB; if ((0, quietHours_1.recipientInQuietHours)(dataFor(startedBy))) { - console.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`); + log_1.logger.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`); } else { await notifyPartner(db, messaging, startedBy, joinerName, gameType, 'partner_joined_game', `${joinerName} joined your game — tap to play together.`, coupleId, joinerAvatar, sessionId); @@ -175,13 +177,13 @@ exports.onGameSessionUpdate = functions.firestore const gt = (_h = currentData.gameType) !== null && _h !== void 0 ? _h : 'wheel'; // Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours. if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerA))) { - console.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`); + log_1.logger.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`); } else { await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId); } if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerB))) { - console.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`); + log_1.logger.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`); } else { await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId); @@ -203,14 +205,13 @@ exports.onGameSessionUpdate = functions.firestore * Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc). */ const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync']; -exports.onGamePartFinished = functions.firestore - .document('couples/{coupleId}/{gameType}/{sessionId}') - .onWrite(async (change, context) => { +exports.onGamePartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/{gameType}/{sessionId}', async (event) => { var _a, _b, _c, _d, _e; - const { coupleId, gameType, sessionId } = context.params; + const { coupleId, gameType, sessionId } = event.params; if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return; // ignore messages/reactions/etc. - if (!change.after.exists) + const change = event.data; + if (!(change === null || change === void 0 ? void 0 : change.after.exists)) return; const answers = ((_b = (_a = change.after.data()) === null || _a === void 0 ? void 0 : _a.answers) !== null && _b !== void 0 ? _b : {}); const answerUids = Object.keys(answers); @@ -263,7 +264,6 @@ function yourTurnBody(gameType) { * Send notification to partner via FCM and write to notification_queue. */ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId, senderAvatarUrl, sessionId) { - var _a; const title = notificationType === 'partner_finished_game' ? `${partnerName} finished the game` : notificationType === 'partner_completed_part' @@ -271,71 +271,29 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no : notificationType === 'partner_joined_game' ? `${partnerName} joined your game` : `${partnerName} is playing`; - const notificationPayload = { - type: notificationType, - title, - body: body, - }; // Write an in-app notification record for the partner await db .collection('users') .doc(partnerId) .collection('notification_queue') - .add(Object.assign(Object.assign({}, notificationPayload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() })); - // Collect the partner's FCM tokens - const tokens = []; - const partnerUserDoc = await db.collection('users').doc(partnerId).get(); - if (partnerUserDoc.exists) { - const legacyToken = (_a = partnerUserDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken); - } - } - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get(); - tokenSnapshot.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); - } + .add({ + type: notificationType, + title, + body, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), }); - if (tokens.length === 0) { - console.log(`[notifyPartner] no FCM tokens for ${partnerId}`); - return; - } - const fcmMessage = { - token: tokens[0], - notification: { - title: notificationPayload.title, - body: notificationPayload.body, - }, + await (0, push_1.sendPushToUser)(db, messaging, partnerId, { + notification: { title, body }, // Put backgrounded notifications on the Games channel instead of the FCM fallback channel, // so importance/sound and the per-category toggle apply. E-OBS. android: { notification: { channelId: 'game_activity' } }, - data: Object.assign(Object.assign({ type: notificationPayload.type, couple_id: coupleId, game_type: gameType, + data: Object.assign(Object.assign({ type: notificationType, couple_id: coupleId, game_type: gameType, // The acting partner's display name (public; also in the title) so the in-app foreground // banner can name them instead of a generic "Your partner". sender_name: partnerName }, (sessionId ? { game_session_id: sessionId } : {})), (senderAvatarUrl && senderAvatarUrl.length > 0 ? { sender_avatar_url: senderAvatarUrl } : {})), - }; - const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, fcmMessage), { token })))); - const failures = []; - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`); - } }); - if (failures.length > 0) { - console.error(`[notifyPartner] some notifications failed:`, failures); - } - else { - console.log(`[notifyPartner] notified ${partnerId} (${notificationType})`); - } - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults); } //# sourceMappingURL=onGameSessionUpdate.js.map \ No newline at end of file diff --git a/functions/dist/games/onGameSessionUpdate.js.map b/functions/dist/games/onGameSessionUpdate.js.map index ce22a3ad..65c9727c 100644 --- a/functions/dist/games/onGameSessionUpdate.js.map +++ b/functions/dist/games/onGameSessionUpdate.js.map @@ -1 +1 @@ -{"version":3,"file":"onGameSessionUpdate.js","sourceRoot":"","sources":["../../src/games/onGameSessionUpdate.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onGameSessionUpdate.js","sourceRoot":"","sources":["../../src/games/onGameSessionUpdate.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/notifications/idempotency.js b/functions/dist/notifications/idempotency.js new file mode 100644 index 00000000..b73264eb --- /dev/null +++ b/functions/dist/notifications/idempotency.js @@ -0,0 +1,75 @@ +"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.claimOnce = claimOnce; +exports.notifMark = notifMark; +const admin = __importStar(require("firebase-admin")); +const log_1 = require("../log"); +/** gRPC status for a create() that hit an existing document. */ +const ALREADY_EXISTS = 6; +/** + * Best-effort one-time claim for a notification event, keyed by a deterministic marker doc. + * + * Background triggers are delivered AT LEAST ONCE, so a redelivery of the same event would + * otherwise double-send a push. `create()` is an atomic create-if-absent: the first delivery + * writes the marker and returns true; a redelivery finds it present and returns false, so the + * caller skips. Claim right before sending — a suppressed (quiet-hours / opted-out) notification + * should not burn a claim. + * + * Fail-OPEN: if create() fails for any reason OTHER than "already claimed", return true and let + * the notification proceed. A rare duplicate is better UX than a silently dropped ping, and it + * keeps an infra blip on the marker write from swallowing every notification. The trade this makes + * explicit: at-least-once → at-most-once (a send failure AFTER the claim drops that one push). + */ +async function claimOnce(markRef) { + try { + await markRef.create({ claimedAt: admin.firestore.FieldValue.serverTimestamp() }); + return true; + } + catch (e) { + if (e.code === ALREADY_EXISTS) + return false; + log_1.logger.warn('[claimOnce] marker create failed; proceeding without dedupe', { + path: markRef.path, + error: String(e), + }); + return true; + } +} +/** Standard per-couple marker ref for a notification event (`couples/{id}/notif_marks/{markId}`). */ +function notifMark(db, coupleId, markId) { + return db.collection('couples').doc(coupleId).collection('notif_marks').doc(markId); +} +//# sourceMappingURL=idempotency.js.map \ No newline at end of file diff --git a/functions/dist/notifications/idempotency.js.map b/functions/dist/notifications/idempotency.js.map new file mode 100644 index 00000000..4216cd11 --- /dev/null +++ b/functions/dist/notifications/idempotency.js.map @@ -0,0 +1 @@ +{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../../src/notifications/idempotency.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoBA,8BAYC;AAGD,8BAMC;AAzCD,sDAAuC;AACvC,gCAA+B;AAE/B,gEAAgE;AAChE,MAAM,cAAc,GAAG,CAAC,CAAA;AAExB;;;;;;;;;;;;;GAaG;AACI,KAAK,UAAU,SAAS,CAAC,OAA0C;IACxE,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE,EAAE,CAAC,CAAA;QACjF,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,IAAK,CAAuB,CAAC,IAAI,KAAK,cAAc;YAAE,OAAO,KAAK,CAAA;QAClE,YAAM,CAAC,IAAI,CAAC,6DAA6D,EAAE;YACzE,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;SACjB,CAAC,CAAA;QACF,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC;AAED,qGAAqG;AACrG,SAAgB,SAAS,CACvB,EAA6B,EAC7B,QAAgB,EAChB,MAAc;IAEd,OAAO,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;AACrF,CAAC"} \ No newline at end of file diff --git a/functions/dist/notifications/idempotency.test.js b/functions/dist/notifications/idempotency.test.js new file mode 100644 index 00000000..833b29c2 --- /dev/null +++ b/functions/dist/notifications/idempotency.test.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const idempotency_1 = require("./idempotency"); +function markRef(create) { + return { path: 'couples/c1/notif_marks/m1', create }; +} +describe('claimOnce', () => { + it('claims (true) when the marker did not exist', async () => { + expect(await (0, idempotency_1.claimOnce)(markRef(async () => undefined))).toBe(true); + }); + it('does not claim (false) when the marker already exists (ALREADY_EXISTS = 6)', async () => { + expect(await (0, idempotency_1.claimOnce)(markRef(async () => Promise.reject({ code: 6 })))).toBe(false); + }); + it('fails open (true) on any other create error so a ping is not silently dropped', async () => { + expect(await (0, idempotency_1.claimOnce)(markRef(async () => Promise.reject({ code: 14 })))).toBe(true); + expect(await (0, idempotency_1.claimOnce)(markRef(async () => Promise.reject(new Error('boom'))))).toBe(true); + }); +}); +//# sourceMappingURL=idempotency.test.js.map \ No newline at end of file diff --git a/functions/dist/notifications/idempotency.test.js.map b/functions/dist/notifications/idempotency.test.js.map new file mode 100644 index 00000000..6b26c299 --- /dev/null +++ b/functions/dist/notifications/idempotency.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"idempotency.test.js","sourceRoot":"","sources":["../../src/notifications/idempotency.test.ts"],"names":[],"mappings":";;AAAA,+CAAyC;AAEzC,SAAS,OAAO,CAAC,MAA8B;IAC7C,OAAO,EAAE,IAAI,EAAE,2BAA2B,EAAE,MAAM,EAAS,CAAA;AAC7D,CAAC;AAED,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;IACzB,EAAE,CAAC,6CAA6C,EAAE,KAAK,IAAI,EAAE;QAC3D,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4EAA4E,EAAE,KAAK,IAAI,EAAE;QAC1F,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACvF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,+EAA+E,EAAE,KAAK,IAAI,EAAE;QAC7F,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACrF,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC5F,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/dist/questions/onAnswerRevealed.js b/functions/dist/questions/onAnswerRevealed.js index f6bdef93..1326f2ec 100644 --- a/functions/dist/questions/onAnswerRevealed.js +++ b/functions/dist/questions/onAnswerRevealed.js @@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onAnswerRevealed = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Firestore trigger: when one partner OPENS (reveals) the shared answers — i.e. their own * daily answer doc flips isRevealed false → true — notify the other partner that they've @@ -45,67 +47,54 @@ const pruneTokens_1 = require("../notifications/pruneTokens"); * * Path: couples/{coupleId}/daily_question/{date}/answers/{userId} */ -exports.onAnswerRevealed = functions.firestore - .document('couples/{coupleId}/daily_question/{date}/answers/{userId}') - .onUpdate(async (change, context) => { - var _a, _b, _c, _d, _e; - const { coupleId, date, userId } = context.params; - const before = change.before.data(); - const after = change.after.data(); +exports.onAnswerRevealed = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/daily_question/{date}/answers/{userId}', async (event) => { + var _a, _b, _c, _d, _e, _f, _g; + const { coupleId, date, userId } = event.params; + const before = ((_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {}); + const after = ((_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {}); // Only fire on the false → true reveal transition. if (before.isRevealed === true || after.isRevealed !== true) return; const db = admin.firestore(); const coupleDoc = await db.collection('couples').doc(coupleId).get(); if (!coupleDoc.exists) { - console.warn(`[onAnswerRevealed] couple ${coupleId} not found`); + log_1.logger.warn(`[onAnswerRevealed] couple ${coupleId} not found`); return; } - const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); + const userIds = ((_f = (_e = coupleDoc.data()) === null || _e === void 0 ? void 0 : _e.userIds) !== null && _f !== void 0 ? _f : []); if (!userIds.includes(userId)) { - console.warn(`[onAnswerRevealed] revealer ${userId} not a member of ${coupleId}`); + log_1.logger.warn(`[onAnswerRevealed] revealer ${userId} not a member of ${coupleId}`); return; } const partnerId = userIds.find((uid) => uid !== userId); if (!partnerId) { - console.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`); + log_1.logger.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`); return; } - // Partner FCM tokens (legacy field + multi-device subcollection). - const tokens = []; const partnerUserDoc = await db.collection('users').doc(partnerId).get(); - if (partnerUserDoc.exists) { - const legacyToken = (_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.fcmToken; - if (typeof legacyToken === 'string' && legacyToken.length > 0) - tokens.push(legacyToken); - } - const tokenSnapshot = await db.collection('users').doc(partnerId).collection('fcmTokens').get(); - tokenSnapshot.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); - }); - if (tokens.length === 0) { - console.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`); - return; - } + const partnerData = partnerUserDoc.data(); // Respect the same partner-activity opt-out as the answered ping. - if (((_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.notifPartnerAnswered) === false) { - console.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`); + if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) { + log_1.logger.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`); return; } // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. - if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { - console.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`); + if ((0, quietHours_1.recipientInQuietHours)(partnerData)) { + log_1.logger.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`); + return; + } + // Dedupe redelivery of this reveal (at-least-once). The false→true guard above already stops + // the marker write from re-triggering this handler; the claim stops a redelivered event. + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `reveal-${date}-${userId}`)))) { + log_1.logger.log(`[onAnswerRevealed] already notified for ${userId}'s reveal on ${date}; skipping`); return; } const questionId = typeof after.questionId === 'string' ? after.questionId : ''; // displayName is E2EE in users/{uid} → generic title; the app shows the real name in-app. Avatar // (photoUrl) stays plaintext, so it's still sent. const revealerDoc = await db.collection('users').doc(userId).get(); - const revealerAvatar = (_e = revealerDoc.data()) === null || _e === void 0 ? void 0 : _e.photoUrl; - const payload = { + const revealerAvatar = (_g = revealerDoc.data()) === null || _g === void 0 ? void 0 : _g.photoUrl; + const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: { title: 'Your partner opened your answers', body: 'Open to see what you each said.', @@ -113,16 +102,12 @@ exports.onAnswerRevealed = functions.firestore data: Object.assign({ type: 'partner_opened_answer', couple_id: coupleId, question_id: questionId, date }, (typeof revealerAvatar === 'string' && revealerAvatar.length > 0 ? { sender_avatar_url: revealerAvatar } : {})), - }; - const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); - const failures = []; - sendResults.forEach((r, i) => { - if (r.status === 'rejected') - failures.push(`${tokens[i]}: ${String(r.reason)}`); - }); - if (failures.length > 0) - console.error('[onAnswerRevealed] some notifications failed:', failures); - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults); - console.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`); + android: { notification: { channelId: 'partner_activity' } }, + }, partnerData); + if (res.sent === 0 && res.failed === 0) { + log_1.logger.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`); + return; + } + log_1.logger.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`); }); //# sourceMappingURL=onAnswerRevealed.js.map \ No newline at end of file diff --git a/functions/dist/questions/onAnswerRevealed.js.map b/functions/dist/questions/onAnswerRevealed.js.map index 64f5b930..1a6b0979 100644 --- a/functions/dist/questions/onAnswerRevealed.js.map +++ b/functions/dist/questions/onAnswerRevealed.js.map @@ -1 +1 @@ -{"version":3,"file":"onAnswerRevealed.js","sourceRoot":"","sources":["../../src/questions/onAnswerRevealed.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onAnswerRevealed.js","sourceRoot":"","sources":["../../src/questions/onAnswerRevealed.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/questions/onAnswerWritten.js b/functions/dist/questions/onAnswerWritten.js index a8b8304e..2bef5fc5 100644 --- a/functions/dist/questions/onAnswerWritten.js +++ b/functions/dist/questions/onAnswerWritten.js @@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onAnswerWritten = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Firestore trigger that sends an FCM notification to the other partner when * one partner writes an answer under @@ -47,15 +49,16 @@ const pruneTokens_1 = require("../notifications/pruneTokens"); * the answer reveal screen. It also contains a `notification` block for * system-tray display when the app is in the background. */ -exports.onAnswerWritten = functions.firestore - .document('couples/{coupleId}/daily_question/{date}/answers/{userId}') - .onCreate(async (snap, context) => { - var _a, _b, _c, _d, _e; - const { coupleId, date, userId } = context.params; +exports.onAnswerWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/daily_question/{date}/answers/{userId}', async (event) => { + var _a, _b, _c; + const { coupleId, date, userId } = event.params; + const snap = event.data; + if (!snap) + return; const db = admin.firestore(); const coupleDoc = await db.collection('couples').doc(coupleId).get(); if (!coupleDoc.exists) { - console.warn(`[onAnswerWritten] couple ${coupleId} not found`); + log_1.logger.warn(`[onAnswerWritten] couple ${coupleId} not found`); return; } const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); @@ -63,56 +66,37 @@ exports.onAnswerWritten = functions.firestore // before sending a cross-user notification. Firestore rules already enforce this, // but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping. if (!userIds.includes(userId)) { - console.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`); + log_1.logger.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`); return; } const partnerId = userIds.find((uid) => uid !== userId); if (!partnerId) { - console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`); + log_1.logger.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`); return; } - // Look up the partner's FCM tokens. We support a legacy `fcmToken` field - // on the user doc and a dedicated `fcmTokens` subcollection for multiple devices. - const tokens = []; const partnerUserDoc = await db.collection('users').doc(partnerId).get(); - if (partnerUserDoc.exists) { - const legacyToken = (_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.fcmToken; - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken); - } - } - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get(); - tokenSnapshot.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); - } - }); - if (tokens.length === 0) { - console.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`); - return; - } + const partnerData = partnerUserDoc.data(); // Respect the partner's notification preference (opt-out; default is enabled). - const notifEnabled = (_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.notifPartnerAnswered; - if (notifEnabled === false) { - console.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`); + if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) { + log_1.logger.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`); return; } // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. - if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { - console.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`); + if ((0, quietHours_1.recipientInQuietHours)(partnerData)) { + log_1.logger.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`); + return; + } + // Dedupe: at-least-once delivery can redeliver this create; claim once so a redelivery + // doesn't double-ping the partner (at-most-once: a post-claim send failure drops this one). + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `answer-${date}-${userId}`)))) { + log_1.logger.log(`[onAnswerWritten] already notified for ${userId}'s answer on ${date}; skipping`); return; } const answerData = snap.data(); const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''; // Sender (the partner who just answered) avatar — used as the notification large icon. const senderDoc = await db.collection('users').doc(userId).get(); - const senderAvatar = (_e = senderDoc.data()) === null || _e === void 0 ? void 0 : _e.photoUrl; + const senderAvatar = (_c = senderDoc.data()) === null || _c === void 0 ? void 0 : _c.photoUrl; // Did this answer COMPLETE the pair? If the recipient (partner) has already answered, the // reveal just unlocked for both — tell them it's ready to open, instead of "go answer". const partnerAnswerSnap = await db @@ -120,7 +104,7 @@ exports.onAnswerWritten = functions.firestore .collection('daily_question').doc(date) .collection('answers').doc(partnerId).get(); const bothAnswered = partnerAnswerSnap.exists; - const payload = { + const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: bothAnswered ? { title: 'Your answers are unlocked ✨', @@ -133,22 +117,16 @@ exports.onAnswerWritten = functions.firestore data: Object.assign({ type: 'partner_answered', couple_id: coupleId, question_id: questionId, date }, (typeof senderAvatar === 'string' && senderAvatar.length > 0 ? { sender_avatar_url: senderAvatar } : {})), - }; - const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); - const failures = []; - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`); - } - }); - if (failures.length > 0) { - console.error(`[onAnswerWritten] some notifications failed:`, failures); + android: { notification: { channelId: 'partner_activity' } }, // E-OBS + }, partnerData); + if (res.sent === 0 && res.failed === 0) { + log_1.logger.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`); + return; } - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults); // Track last activity time on the couple doc for re-engagement targeting. await db.collection('couples').doc(coupleId).update({ lastAnsweredAt: admin.firestore.FieldValue.serverTimestamp(), - }).catch((e) => console.warn('[onAnswerWritten] lastAnsweredAt update failed:', e)); - console.log(`[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`); + }).catch((e) => log_1.logger.warn('[onAnswerWritten] lastAnsweredAt update failed', { error: String(e) })); + log_1.logger.log(`[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`); }); //# sourceMappingURL=onAnswerWritten.js.map \ No newline at end of file diff --git a/functions/dist/questions/onAnswerWritten.js.map b/functions/dist/questions/onAnswerWritten.js.map index fd415aab..bc27a4e3 100644 --- a/functions/dist/questions/onAnswerWritten.js.map +++ b/functions/dist/questions/onAnswerWritten.js.map @@ -1 +1 @@ -{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/questions/onMessageWritten.js b/functions/dist/questions/onMessageWritten.js index 46e1a8ae..004aab7f 100644 --- a/functions/dist/questions/onMessageWritten.js +++ b/functions/dist/questions/onMessageWritten.js @@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.onMessageWritten = void 0; -const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); const quietHours_1 = require("../notifications/quietHours"); -const pruneTokens_1 = require("../notifications/pruneTokens"); +const push_1 = require("../notifications/push"); +const idempotency_1 = require("../notifications/idempotency"); +const log_1 = require("../log"); /** * Firestore trigger that notifies the other partner when a chat message is * sent in a conversation (the couple chat or a per-question discussion). @@ -46,88 +48,64 @@ const pruneTokens_1 = require("../notifications/pruneTokens"); * * Respects the recipient's `notifChatMessage` preference (default: enabled). */ -exports.onMessageWritten = functions.firestore - .document('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}') - .onCreate(async (snap, context) => { - var _a, _b, _c, _d, _e, _f; - const { coupleId, conversationId, messageId } = context.params; +exports.onMessageWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}', async (event) => { + var _a, _b, _c, _d; + const { coupleId, conversationId, messageId } = event.params; + const snap = event.data; + if (!snap) + return; const db = admin.firestore(); const messageData = snap.data(); const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null; if (!authorId) { - console.warn(`[onMessageWritten] no authorUserId on message ${messageId}`); + log_1.logger.warn(`[onMessageWritten] no authorUserId on message ${messageId}`); return; } const coupleDoc = await db.collection('couples').doc(coupleId).get(); if (!coupleDoc.exists) { - console.warn(`[onMessageWritten] couple ${coupleId} not found`); + log_1.logger.warn(`[onMessageWritten] couple ${coupleId} not found`); return; } const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); const partnerId = userIds.find((uid) => uid !== authorId); if (!partnerId) { - console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`); + log_1.logger.warn(`[onMessageWritten] no partner found for couple ${coupleId}`); return; } const partnerUserDoc = await db.collection('users').doc(partnerId).get(); + const partnerData = partnerUserDoc.data(); // Respect the partner's notification preference (opt-out; default is enabled). - const notifEnabled = (_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifChatMessage; - if (notifEnabled === false) { - console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`); + if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifChatMessage) === false) { + log_1.logger.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`); return; } // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. - if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { - console.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`); + if ((0, quietHours_1.recipientInQuietHours)(partnerData)) { + log_1.logger.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`); return; } - const tokens = []; - if (partnerUserDoc.exists) { - const legacyToken = (_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.fcmToken; - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken); - } - } - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get(); - tokenSnapshot.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); - } - }); - if (tokens.length === 0) { - console.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`); + // Dedupe redelivery of this message create (at-least-once). messageId is unique per message. + if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `msg-${messageId}`)))) { + log_1.logger.log(`[onMessageWritten] already notified for message ${messageId}; skipping`); return; } // displayName is E2EE in users/{uid}, so the server can't read it for the OS-rendered push (the // app shows the real name in-app). photoUrl stays plaintext, so the avatar is still sent. const authorDoc = await db.collection('users').doc(authorId).get(); - const authorPhotoUrl = (_f = (_e = authorDoc.data()) === null || _e === void 0 ? void 0 : _e.photoUrl) !== null && _f !== void 0 ? _f : ''; - const payload = { + const authorPhotoUrl = (_d = (_c = authorDoc.data()) === null || _c === void 0 ? void 0 : _c.photoUrl) !== null && _d !== void 0 ? _d : ''; + const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, { notification: { title: 'Your partner sent a message', body: 'Tap to read and reply.', }, data: Object.assign({ type: 'chat_message', couple_id: coupleId, conversation_id: conversationId }, (authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {})), - }; - const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, // E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel. - android: { notification: { channelId: 'partner_activity' } } })))); - const failures = []; - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`); - } - }); - if (failures.length > 0) { - console.error(`[onMessageWritten] some notifications failed:`, failures); + android: { notification: { channelId: 'partner_activity' } }, + }, partnerData); + if (res.sent === 0 && res.failed === 0) { + log_1.logger.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`); + return; } - await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults); - console.log(`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`); + log_1.logger.log(`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`); }); //# sourceMappingURL=onMessageWritten.js.map \ No newline at end of file diff --git a/functions/dist/questions/onMessageWritten.js.map b/functions/dist/questions/onMessageWritten.js.map index 31825bdb..57b01063 100644 --- a/functions/dist/questions/onMessageWritten.js.map +++ b/functions/dist/questions/onMessageWritten.js.map @@ -1 +1 @@ -{"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/backup/onRestoreRequested.ts b/functions/src/backup/onRestoreRequested.ts index c0baa3f7..25db2545 100644 --- a/functions/src/backup/onRestoreRequested.ts +++ b/functions/src/backup/onRestoreRequested.ts @@ -1,14 +1,17 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentCreated, onDocumentUpdated } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { logger } from '../log' const CHANNEL = 'partner_activity' -// Suppress duplicate "was this you?" pushes when a request doc is rapidly deleted+recreated (a compromised -// account could loop that to spam both partners). The in-app queue entry is still written every time. +// Suppress duplicate pushes when a request doc is rapidly deleted+recreated (a compromised account +// could loop that to spam both partners) and when an event is redelivered. The in-app queue entry is +// still written every time. A genuine re-request after the window still notifies — so this is a time +// window, NOT a permanent per-recipient marker (which would block legitimate re-requests). export const SELF_ALERT_DEDUPE_MS = 60 * 1000 -/** Pure dedupe decision: allow a self-alert push only if none was sent within the window. */ +/** Pure dedupe decision: allow an alert push only if none was sent within the window. */ export function selfAlertAllowed(lastAlertAt: unknown, now: number): boolean { if (typeof lastAlertAt !== 'number') return true return now - lastAlertAt >= SELF_ALERT_DEDUPE_MS @@ -19,20 +22,6 @@ export function isRestoreReadyTransition(beforeStatus: unknown, afterStatus: unk return afterStatus === 'READY' && beforeStatus !== 'READY' } -/** All FCM tokens for a user: the legacy single field + the multi-device `fcmTokens` subcollection. */ -async function gatherTokens(db: admin.firestore.Firestore, uid: string): Promise { - const tokens: string[] = [] - const userDoc = await db.collection('users').doc(uid).get() - const legacy = userDoc.data()?.fcmToken - if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) - const tokenSnap = await db.collection('users').doc(uid).collection('fcmTokens').get() - tokenSnap.docs.forEach((d) => { - const t = d.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) - }) - return tokens -} - /** * Write the durable in-app queue entry (always) and — unless quiet hours suppress it — push to every device. * `bypassQuietHours` is for security signals (the "was this you?" self-alert) that must not be silenced. @@ -49,24 +38,20 @@ async function queueAndPush( const userDoc = await db.collection('users').doc(uid).get() if (!userDoc.exists) return - if (!bypassQuietHours && recipientInQuietHours(userDoc.data())) return + const userData = userDoc.data() + if (!bypassQuietHours && recipientInQuietHours(userData)) return - const tokens = await gatherTokens(db, uid) - if (tokens.length === 0) return - const results = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - notification: { title, body }, - data: { type, couple_id: coupleId }, - token, - android: { notification: { channelId: CHANNEL } }, - } as admin.messaging.Message) - ) + await sendPushToUser( + db, + admin.messaging(), + uid, + { + notification: { title, body }, + data: { type, couple_id: coupleId }, + android: { notification: { channelId: CHANNEL } }, + }, + userData, ) - results.forEach((r, i) => { - if (r.status === 'rejected') console.warn(`[restore] FCM failed for ${tokens[i]}:`, r.reason) - }) - await pruneDeadTokens(db, uid, tokens, results) } /** @@ -78,10 +63,10 @@ async function queueAndPush( * device (a phished password without device loss), this is how they learn a restore is happening. * No key material is read or logged — the request carries only a public key + a nonce. */ -export const onRestoreRequested = functions.firestore - .document('couples/{coupleId}/restore_requests/{recipientUid}') - .onCreate(async (_snap, context) => { - const { coupleId, recipientUid } = context.params as { coupleId: string; recipientUid: string } +export const onRestoreRequested = onDocumentCreated( + 'couples/{coupleId}/restore_requests/{recipientUid}', + async (event) => { + const { coupleId, recipientUid } = event.params const db = admin.firestore() const coupleRef = db.collection('couples').doc(coupleId) @@ -94,18 +79,23 @@ export const onRestoreRequested = functions.firestore if (!partnerId) return // Audit trail (no key material — actor/recipient/timestamp only). - console.log(`[onRestoreRequested] couple=${coupleId} recipient=${recipientUid} partner=${partnerId}`) + logger.log(`[onRestoreRequested] couple=${coupleId} recipient=${recipientUid} partner=${partnerId}`) - // 1) Partner "help them restore" — routine quiet hours apply. + // 1) Partner "help them restore" — routine quiet hours apply. Deduped against redelivery and + // request recreate-loops with the same window used for the self-alert. try { - await queueAndPush(db, partnerId, { - type: 'restore_requested', - title: 'Help your partner restore 💜', - body: 'They’re setting up on a new device. Tap to help.', - coupleId, - }) + const now = Date.now() + if (selfAlertAllowed(coupleDoc.data()?.lastRestorePartnerAlertAt, now)) { + await coupleRef.set({ lastRestorePartnerAlertAt: now }, { merge: true }) + await queueAndPush(db, partnerId, { + type: 'restore_requested', + title: 'Help your partner restore 💜', + body: 'They’re setting up on a new device. Tap to help.', + coupleId, + }) + } } catch (e) { - console.warn('[onRestoreRequested] partner notify failed:', e) + logger.warn('[onRestoreRequested] partner notify failed', { error: String(e) }) } // 2) Recipient self-alert — security signal, bypasses quiet hours, deduped against create-loops. @@ -122,30 +112,31 @@ export const onRestoreRequested = functions.firestore }) } } catch (e) { - console.warn('[onRestoreRequested] self-alert failed:', e) + logger.warn('[onRestoreRequested] self-alert failed', { error: String(e) }) } - }) + } +) /** * Fires when the partner writes the keybox (status flips to READY) — the moment the couple key actually * transfers. Alerts the RECIPIENT'S OWN devices that a restore just completed, the strongest "it happened" * signal for the real owner. Guarded to the single REQUESTED→READY transition (ignores decline/expire). */ -export const onRestoreFulfilled = functions.firestore - .document('couples/{coupleId}/restore_requests/{recipientUid}') - .onUpdate(async (change, context) => { - const before = change.before.data() - const after = change.after.data() +export const onRestoreFulfilled = onDocumentUpdated( + 'couples/{coupleId}/restore_requests/{recipientUid}', + async (event) => { + const before = event.data?.before.data() + const after = event.data?.after.data() if (!isRestoreReadyTransition(before?.status, after?.status)) return - const { coupleId, recipientUid } = context.params as { coupleId: string; recipientUid: string } + const { coupleId, recipientUid } = event.params const db = admin.firestore() const coupleDoc = await db.collection('couples').doc(coupleId).get() const userIds = (coupleDoc.data()?.userIds ?? []) as string[] if (!userIds.includes(recipientUid)) return - console.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`) + logger.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`) try { await queueAndPush(db, recipientUid, { type: 'restore_self_alert', @@ -155,6 +146,7 @@ export const onRestoreFulfilled = functions.firestore bypassQuietHours: true, }) } catch (e) { - console.warn('[onRestoreFulfilled] self-alert failed:', e) + logger.warn('[onRestoreFulfilled] self-alert failed', { error: String(e) }) } - }) + } +) diff --git a/functions/src/billing/onEntitlementChanged.ts b/functions/src/billing/onEntitlementChanged.ts index 77498472..564832d2 100644 --- a/functions/src/billing/onEntitlementChanged.ts +++ b/functions/src/billing/onEntitlementChanged.ts @@ -1,6 +1,8 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { onDocumentWritten } from 'firebase-functions/v2/firestore' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Notifies the OTHER partner when a user GAINS premium, so couple-shared premium unlock isn't silent @@ -18,33 +20,16 @@ function isActive(data: FirebaseFirestore.DocumentData | undefined): boolean { return true } -async function collectTokens( - db: admin.firestore.Firestore, - userId: string -): Promise { - const tokens: string[] = [] - const userDoc = await db.collection('users').doc(userId).get() - const legacy = userDoc.data()?.fcmToken - if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) - const tokSnap = await db.collection('users').doc(userId).collection('fcmTokens').get() - tokSnap.docs.forEach((d) => { - const t = d.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) - }) - return tokens -} +export const onEntitlementChanged = onDocumentWritten( + 'users/{userId}/entitlements/premium', + async (event) => { + const { userId } = event.params -export const onEntitlementChanged = functions.firestore - .document('users/{userId}/entitlements/premium') - .onWrite(async (change, context) => { - const { userId } = context.params as { userId: string } - - const before = isActive(change.before.data()) - const after = isActive(change.after.data()) + const before = isActive(event.data?.before.data()) + const after = isActive(event.data?.after.data()) if (before || !after) return // only a genuine inactive→active gain const db = admin.firestore() - const messaging = admin.messaging() // Resolve this user's couple + partner. const coupleSnap = await db @@ -53,7 +38,7 @@ export const onEntitlementChanged = functions.firestore .limit(1) .get() if (coupleSnap.empty) { - console.log(`[onEntitlementChanged] no couple for ${userId}`) + logger.log(`[onEntitlementChanged] no couple for ${userId}`) return } const coupleDoc = coupleSnap.docs[0] @@ -61,14 +46,21 @@ export const onEntitlementChanged = functions.firestore const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const partnerId = userIds.find((id) => id !== userId) if (!partnerId) { - console.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`) + logger.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`) return } // If the partner already has premium the couple was already unlocked — nothing new to announce. const partnerEnt = await db.doc(`users/${partnerId}/entitlements/premium`).get() if (isActive(partnerEnt.data())) { - console.log(`[onEntitlementChanged] partner ${partnerId} already premium; skip`) + logger.log(`[onEntitlementChanged] partner ${partnerId} already premium; skip`) + return + } + + // Dedupe redelivery of this onWrite (at-least-once) so the partner isn't double-notified and + // the in-app record isn't duplicated. + if (!(await claimOnce(notifMark(db, coupleId, `entitlement-${userId}`)))) { + logger.log(`[onEntitlementChanged] already announced premium for ${userId}; skip`) return } @@ -91,26 +83,15 @@ export const onEntitlementChanged = functions.firestore createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - const tokens = await collectTokens(db, partnerId) - if (tokens.length === 0) { - console.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`) - return - } - - const message: admin.messaging.Message = { - token: tokens[0], + const res = await sendPushToUser(db, admin.messaging(), partnerId, { notification: { title: payload.title, body: payload.body }, android: { notification: { channelId: 'partner_activity' } }, data: { type: payload.type, couple_id: coupleId }, - } - const results = await Promise.allSettled( - tokens.map((token) => messaging.send({ ...message, token })) - ) - results.forEach((r, i) => { - if (r.status === 'rejected') { - console.warn(`[onEntitlementChanged] send failed ${tokens[i]}: ${String(r.reason)}`) - } }) - await pruneDeadTokens(db, partnerId, tokens, results) - console.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`) - }) + if (res.sent === 0 && res.failed === 0) { + logger.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`) + return + } + logger.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`) + } +) diff --git a/functions/src/couples/onCoupleLeave.ts b/functions/src/couples/onCoupleLeave.ts index 883dfba7..f103f9d2 100644 --- a/functions/src/couples/onCoupleLeave.ts +++ b/functions/src/couples/onCoupleLeave.ts @@ -1,6 +1,8 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { onDocumentUpdated } from 'firebase-functions/v2/firestore' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Firestore trigger that notifies the remaining partner when a user's coupleId @@ -9,98 +11,80 @@ import { pruneDeadTokens } from '../notifications/pruneTokens' * Path: users/{userId} * Condition: previous coupleId was non-empty and new coupleId is null/missing. */ -export const onCoupleLeave = functions.firestore - .document('users/{userId}') - .onUpdate(async (change, context) => { - const { userId } = context.params as { userId: string } +export const onCoupleLeave = onDocumentUpdated('users/{userId}', async (event) => { + const { userId } = event.params - const previousData = change.before.data() ?? {} - const currentData = change.after.data() ?? {} + const previousData = event.data?.before.data() ?? {} + const currentData = event.data?.after.data() ?? {} - const previousCoupleId = previousData.coupleId - const currentCoupleId = currentData.coupleId + const previousCoupleId = previousData.coupleId + const currentCoupleId = currentData.coupleId - // Only act when coupleId transitions from a real value to null/empty. - if (!previousCoupleId || typeof previousCoupleId !== 'string') { - return - } - if (currentCoupleId) { - return - } + // Only act when coupleId transitions from a real value to null/empty. + if (!previousCoupleId || typeof previousCoupleId !== 'string') { + return + } + if (currentCoupleId) { + return + } - const db = admin.firestore() - const messaging = admin.messaging() + const db = admin.firestore() - const coupleDoc = await db.collection('couples').doc(previousCoupleId).get() - if (!coupleDoc.exists) { - console.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`) - return - } + const coupleDoc = await db.collection('couples').doc(previousCoupleId).get() + if (!coupleDoc.exists) { + logger.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`) + return + } - const userIds = (coupleDoc.data()?.userIds ?? []) as string[] - const partnerId = userIds.find((uid) => uid !== userId) - if (!partnerId) { - console.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`) - return - } + const userIds = (coupleDoc.data()?.userIds ?? []) as string[] + const partnerId = userIds.find((uid) => uid !== userId) + if (!partnerId) { + logger.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`) + return + } - // Make sure the partner is still paired in this couple. - // If both users are leaving simultaneously, avoid duplicate/phantom notifications. - const partnerUserDoc = await db.collection('users').doc(partnerId).get() - const partnerCoupleId = partnerUserDoc.data()?.coupleId - if (partnerCoupleId !== previousCoupleId) { - console.log( - `[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification` - ) - return - } + // Make sure the partner is still paired in this couple. + // If both users are leaving simultaneously, avoid duplicate/phantom notifications. + const partnerUserDoc = await db.collection('users').doc(partnerId).get() + const partnerData = partnerUserDoc.data() + const partnerCoupleId = partnerData?.coupleId + if (partnerCoupleId !== previousCoupleId) { + logger.log( + `[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification` + ) + return + } - const notificationPayload = { - type: 'partner_left', - title: 'Your partner has left', - body: 'You are no longer paired. Tap to create a new invite.', - } + // Dedupe redelivery of this leave (at-least-once) so the partner isn't double-notified and the + // in-app record isn't duplicated. + if (!(await claimOnce(notifMark(db, previousCoupleId, `leave-${userId}`)))) { + logger.log(`[onCoupleLeave] already notified partner of ${userId} leaving; skipping`) + return + } - // Write an in-app notification record for the partner. - // This is read-only denied for clients; the Cloud Function writes it. - await db - .collection('users') - .doc(partnerId) - .collection('notification_queue') - .add({ - ...notificationPayload, - read: false, - createdAt: admin.firestore.FieldValue.serverTimestamp(), - }) + const notificationPayload = { + type: 'partner_left', + title: 'Your partner has left', + body: 'You are no longer paired. Tap to create a new invite.', + } - // Collect the partner's FCM tokens (legacy field + fcmTokens subcollection). - const tokens: string[] = [] - if (partnerUserDoc.exists) { - const legacyToken = partnerUserDoc.data()?.fcmToken - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken) - } - } - - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get() - tokenSnapshot.docs.forEach((doc) => { - const t = doc.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { - tokens.push(t) - } + // Write an in-app notification record for the partner. + // This is read-only denied for clients; the Cloud Function writes it. + await db + .collection('users') + .doc(partnerId) + .collection('notification_queue') + .add({ + ...notificationPayload, + read: false, + createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - if (tokens.length === 0) { - console.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`) - return - } - - const fcmMessage: admin.messaging.Message = { - token: tokens[0], + const res = await sendPushToUser( + db, + admin.messaging(), + partnerId, + { notification: { title: notificationPayload.title, body: notificationPayload.body, @@ -109,26 +93,15 @@ export const onCoupleLeave = functions.firestore data: { type: notificationPayload.type, }, - } + }, + partnerData, + ) + if (res.sent === 0 && res.failed === 0) { + logger.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`) + return + } - const sendResults = await Promise.allSettled( - tokens.map((token) => messaging.send({ ...fcmMessage, token })) - ) - - const failures: string[] = [] - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`) - } - }) - - if (failures.length > 0) { - console.error(`[onCoupleLeave] some notifications failed:`, failures) - } - - await pruneDeadTokens(db, partnerId, tokens, sendResults) - - console.log( - `[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}` - ) - }) + logger.log( + `[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}` + ) +}) diff --git a/functions/src/dates/createDateMatch.ts b/functions/src/dates/createDateMatch.ts index 2121e0be..c77620dd 100644 --- a/functions/src/dates/createDateMatch.ts +++ b/functions/src/dates/createDateMatch.ts @@ -1,6 +1,6 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { onDocumentCreated } from 'firebase-functions/v2/firestore' +import { sendPushToUser } from '../notifications/push' /** * Fires the "It's a match!" notification when a date match is created. @@ -13,18 +13,16 @@ import { pruneDeadTokens } from '../notifications/pruneTokens' * only sends the push to both partners — it never reads swipe content. * * Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations - * (or a client retry) never double-send. The match doc id is the date idea id, so + * (or a redelivered event) never double-send. The match doc id is the date idea id, so * the marker itself is already de-duplicated by the client transaction + rules. */ -export const notifyOnDateMatch = functions.firestore - .document('couples/{coupleId}/date_matches/{dateIdeaId}') - .onCreate(async (snap, context) => { - if (!snap.exists) return +export const notifyOnDateMatch = onDocumentCreated( + 'couples/{coupleId}/date_matches/{dateIdeaId}', + async (event) => { + const snap = event.data + if (!snap || !snap.exists) return - const { coupleId, dateIdeaId } = context.params as { - coupleId: string - dateIdeaId: string - } + const { coupleId, dateIdeaId } = event.params const db = admin.firestore() const matchRef = snap.ref @@ -41,8 +39,9 @@ export const notifyOnDateMatch = functions.firestore const coupleDoc = await db.collection('couples').doc(coupleId).get() const userIds = (coupleDoc.data()?.userIds ?? []) as string[] - await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId))) - }) + await Promise.allSettled(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId))) + } +) async function notifyDateMatch( db: admin.firestore.Firestore, @@ -58,42 +57,16 @@ async function notifyDateMatch( createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - const tokens = await getUserTokens(db, userId) - if (tokens.length === 0) return - - const results = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - token, - notification: { - title: "It's a match!", - body: "You both want to go on this date. Time to make it happen.", - }, - android: { notification: { channelId: 'partner_activity' } }, // E-OBS - data: { - type: 'date_match', - couple_id: coupleId, - date_idea_id: dateIdeaId, - }, - }) - ) - ) - await pruneDeadTokens(db, userId, tokens, results) -} - -async function getUserTokens( - db: admin.firestore.Firestore, - userId: string -): Promise { - const tokens: string[] = [] - const userDoc = await db.collection('users').doc(userId).get() - const legacy = userDoc.data()?.fcmToken - if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) - - const snap = await db.collection('users').doc(userId).collection('fcmTokens').get() - snap.docs.forEach((doc) => { - const t = doc.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) + await sendPushToUser(db, admin.messaging(), userId, { + notification: { + title: "It's a match!", + body: "You both want to go on this date. Time to make it happen.", + }, + android: { notification: { channelId: 'partner_activity' } }, // E-OBS + data: { + type: 'date_match', + couple_id: coupleId, + date_idea_id: dateIdeaId, + }, }) - return tokens } diff --git a/functions/src/dates/onDateHistoryCreated.ts b/functions/src/dates/onDateHistoryCreated.ts index 43ec9809..44aff1c3 100644 --- a/functions/src/dates/onDateHistoryCreated.ts +++ b/functions/src/dates/onDateHistoryCreated.ts @@ -1,17 +1,21 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentCreated } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Fires when a date is logged as completed (`couples/{coupleId}/date_history/{dateId}` created). Nudges * the OTHER partner (not the one who logged it) to add their reflection — so the reflect→reveal loop * starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours. */ -export const onDateHistoryCreated = functions.firestore - .document('couples/{coupleId}/date_history/{dateId}') - .onCreate(async (snap, context) => { - const { coupleId, dateId } = context.params as { coupleId: string; dateId: string } +export const onDateHistoryCreated = onDocumentCreated( + 'couples/{coupleId}/date_history/{dateId}', + async (event) => { + const { coupleId, dateId } = event.params + const snap = event.data + if (!snap) return const db = admin.firestore() const addedBy = snap.data()?.addedBy as string | undefined @@ -22,7 +26,14 @@ export const onDateHistoryCreated = functions.firestore if (!partnerId) return const partnerUserDoc = await db.collection('users').doc(partnerId).get() - if (partnerUserDoc.data()?.notifPartnerAnswered === false) return + const partnerData = partnerUserDoc.data() + if (partnerData?.notifPartnerAnswered === false) return + + // Dedupe redelivery (at-least-once) before writing the in-app record or sending. + if (!(await claimOnce(notifMark(db, coupleId, `date-logged-${dateId}`)))) { + logger.log(`[onDateHistoryCreated] already notified for date ${dateId}; skipping`) + return + } const title = 'You went on a date 💜' const body = 'Reflect on it together while it’s fresh.' @@ -31,33 +42,18 @@ export const onDateHistoryCreated = functions.firestore type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - if (recipientInQuietHours(partnerUserDoc.data())) return + if (recipientInQuietHours(partnerData)) return - const tokens: string[] = [] - const legacy = partnerUserDoc.data()?.fcmToken - if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) - const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() - tokenSnap.docs.forEach((d) => { - const t = d.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) - }) - if (tokens.length === 0) return - - const payload: admin.messaging.MessagingPayload = { - notification: { title, body }, - data: { type: 'date_logged', couple_id: coupleId, date_id: dateId }, - } - const results = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - ...payload, - token, - android: { notification: { channelId: 'partner_activity' } }, - } as admin.messaging.Message) - ) + await sendPushToUser( + db, + admin.messaging(), + partnerId, + { + notification: { title, body }, + data: { type: 'date_logged', couple_id: coupleId, date_id: dateId }, + android: { notification: { channelId: 'partner_activity' } }, + }, + partnerData, ) - results.forEach((r, i) => { - if (r.status === 'rejected') console.warn(`[onDateHistoryCreated] FCM failed for ${tokens[i]}:`, r.reason) - }) - await pruneDeadTokens(db, partnerId, tokens, results) - }) + } +) diff --git a/functions/src/dates/onDateReflectionRevealed.ts b/functions/src/dates/onDateReflectionRevealed.ts index 70a59a71..88fc7643 100644 --- a/functions/src/dates/onDateReflectionRevealed.ts +++ b/functions/src/dates/onDateReflectionRevealed.ts @@ -1,7 +1,9 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentUpdated } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Fires when a partner OPENS (reveals) the shared date reflections — their own reflection metadata doc @@ -10,17 +12,13 @@ import { pruneDeadTokens } from '../notifications/pruneTokens' * generic copy (no decrypted content, no name — the app renders the real name in-app), gated on * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept). */ -export const onDateReflectionRevealed = functions.firestore - .document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') - .onUpdate(async (change, context) => { - const { coupleId, dateId, userId } = context.params as { - coupleId: string - dateId: string - userId: string - } +export const onDateReflectionRevealed = onDocumentUpdated( + 'couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', + async (event) => { + const { coupleId, dateId, userId } = event.params - const before = change.before.data() as Partial> - const after = change.after.data() as Partial> + const before = (event.data?.before.data() ?? {}) as Partial> + const after = (event.data?.after.data() ?? {}) as Partial> // Only on the false → true reveal transition. if (before.isRevealed === true || after.isRevealed !== true) return @@ -34,8 +32,15 @@ export const onDateReflectionRevealed = functions.firestore if (!partnerId) return const partnerUserDoc = await db.collection('users').doc(partnerId).get() - if (partnerUserDoc.data()?.notifPartnerAnswered === false) { - console.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`) + const partnerData = partnerUserDoc.data() + if (partnerData?.notifPartnerAnswered === false) { + logger.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`) + return + } + + // Dedupe redelivery (at-least-once) before writing the in-app record or sending. + if (!(await claimOnce(notifMark(db, coupleId, `date-reveal-${dateId}-${userId}`)))) { + logger.log(`[onDateReflectionRevealed] already notified for ${userId} on ${dateId}; skipping`) return } @@ -48,45 +53,30 @@ export const onDateReflectionRevealed = functions.firestore type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - if (recipientInQuietHours(partnerUserDoc.data())) { - console.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`) + if (recipientInQuietHours(partnerData)) { + logger.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`) return } const senderAvatar = (await db.collection('users').doc(userId).get()).data()?.photoUrl - const tokens: string[] = [] - const legacy = partnerUserDoc.data()?.fcmToken - if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) - const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() - tokenSnap.docs.forEach((d) => { - const t = d.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) - }) - if (tokens.length === 0) return - - const payload: admin.messaging.MessagingPayload = { - notification: { title, body }, - data: { - type, - couple_id: coupleId, - date_id: dateId, - ...(typeof senderAvatar === 'string' && senderAvatar.length > 0 - ? { sender_avatar_url: senderAvatar } - : {}), + await sendPushToUser( + db, + admin.messaging(), + partnerId, + { + notification: { title, body }, + data: { + type, + couple_id: coupleId, + date_id: dateId, + ...(typeof senderAvatar === 'string' && senderAvatar.length > 0 + ? { sender_avatar_url: senderAvatar } + : {}), + }, + android: { notification: { channelId: 'partner_activity' } }, }, - } - const results = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - ...payload, - token, - android: { notification: { channelId: 'partner_activity' } }, - } as admin.messaging.Message) - ) + partnerData, ) - results.forEach((r, i) => { - if (r.status === 'rejected') console.warn(`[onDateReflectionRevealed] FCM failed for ${tokens[i]}:`, r.reason) - }) - await pruneDeadTokens(db, partnerId, tokens, results) - }) + } +) diff --git a/functions/src/dates/onDateReflectionWritten.ts b/functions/src/dates/onDateReflectionWritten.ts index d3418cd1..dbff7e5d 100644 --- a/functions/src/dates/onDateReflectionWritten.ts +++ b/functions/src/dates/onDateReflectionWritten.ts @@ -1,7 +1,9 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentCreated } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Fires when a partner writes their post-date reflection @@ -11,14 +13,10 @@ import { pruneDeadTokens } from '../notifications/pruneTokens' * generic copy (no decrypted content, no name — the app renders the real name in-app), gated on * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept). */ -export const onDateReflectionWritten = functions.firestore - .document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') - .onCreate(async (_snap, context) => { - const { coupleId, dateId, userId } = context.params as { - coupleId: string - dateId: string - userId: string - } +export const onDateReflectionWritten = onDocumentCreated( + 'couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', + async (event) => { + const { coupleId, dateId, userId } = event.params const db = admin.firestore() const coupleDoc = await db.collection('couples').doc(coupleId).get() @@ -29,9 +27,16 @@ export const onDateReflectionWritten = functions.firestore if (!partnerId) return const partnerUserDoc = await db.collection('users').doc(partnerId).get() + const partnerData = partnerUserDoc.data() // Partner-activity opt-out (default on). - if (partnerUserDoc.data()?.notifPartnerAnswered === false) { - console.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`) + if (partnerData?.notifPartnerAnswered === false) { + logger.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`) + return + } + + // Dedupe redelivery (at-least-once) before writing the in-app record or sending. + if (!(await claimOnce(notifMark(db, coupleId, `date-reflect-${dateId}-${userId}`)))) { + logger.log(`[onDateReflectionWritten] already notified for ${userId} on ${dateId}; skipping`) return } @@ -52,45 +57,30 @@ export const onDateReflectionWritten = functions.firestore }) // Quiet hours: keep the in-app record, suppress the disruptive push. - if (recipientInQuietHours(partnerUserDoc.data())) { - console.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`) + if (recipientInQuietHours(partnerData)) { + logger.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`) return } const senderAvatar = (await db.collection('users').doc(userId).get()).data()?.photoUrl - const tokens: string[] = [] - const legacy = partnerUserDoc.data()?.fcmToken - if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) - const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() - tokenSnap.docs.forEach((d) => { - const t = d.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) - }) - if (tokens.length === 0) return - - const payload: admin.messaging.MessagingPayload = { - notification: { title, body }, - data: { - type, - couple_id: coupleId, - date_id: dateId, - ...(typeof senderAvatar === 'string' && senderAvatar.length > 0 - ? { sender_avatar_url: senderAvatar } - : {}), + await sendPushToUser( + db, + admin.messaging(), + partnerId, + { + notification: { title, body }, + data: { + type, + couple_id: coupleId, + date_id: dateId, + ...(typeof senderAvatar === 'string' && senderAvatar.length > 0 + ? { sender_avatar_url: senderAvatar } + : {}), + }, + android: { notification: { channelId: 'partner_activity' } }, // E-OBS }, - } - const results = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - ...payload, - token, - android: { notification: { channelId: 'partner_activity' } }, // E-OBS - } as admin.messaging.Message) - ) + partnerData, ) - results.forEach((r, i) => { - if (r.status === 'rejected') console.warn(`[onDateReflectionWritten] FCM failed for ${tokens[i]}:`, r.reason) - }) - await pruneDeadTokens(db, partnerId, tokens, results) - }) + } +) diff --git a/functions/src/games/onGameSessionUpdate.ts b/functions/src/games/onGameSessionUpdate.ts index 72bb7b41..ae7cdf1c 100644 --- a/functions/src/games/onGameSessionUpdate.ts +++ b/functions/src/games/onGameSessionUpdate.ts @@ -1,23 +1,27 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentWritten } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { logger } from '../log' /** * Firestore trigger that notifies partners when a game session is created or completed. - * + * * Path: couples/{coupleId}/sessions/{sessionId} * Condition: onWrite (create, update, delete) */ -export const onGameSessionUpdate = functions.firestore - .document('couples/{coupleId}/sessions/{sessionId}') - .onWrite(async (change, context) => { - const { coupleId, sessionId } = context.params as { coupleId: string; sessionId: string } +export const onGameSessionUpdate = onDocumentWritten( + 'couples/{coupleId}/sessions/{sessionId}', + async (event) => { + const { coupleId, sessionId } = event.params // The per-couple active-session lock lives at sessions/_active — it is a pointer, not a // game session, so it must never produce a partner notification. if (sessionId === '_active') return + const change = event.data + if (!change) return + const db = admin.firestore() const messaging = admin.messaging() @@ -25,21 +29,21 @@ export const onGameSessionUpdate = functions.firestore const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get() const session = sessionDoc.data() if (!session) { - console.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`) + logger.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`) return } // Get couple info const coupleDoc = await db.collection('couples').doc(coupleId).get() if (!coupleDoc.exists) { - console.warn(`[onGameSessionUpdate] couple ${coupleId} not found`) + logger.warn(`[onGameSessionUpdate] couple ${coupleId} not found`) return } const coupleData = coupleDoc.data() ?? {} const userIds = (coupleData.userIds ?? []) as string[] if (userIds.length !== 2) { - console.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`) + logger.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`) return } @@ -86,7 +90,7 @@ export const onGameSessionUpdate = functions.firestore const starterName = startedBy === partnerA ? partnerAName : partnerBName const starterAvatar = startedBy === partnerA ? avatarA : avatarB if (recipientInQuietHours(dataFor(recipientId))) { - console.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`) + logger.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`) } else { await notifyPartner( db, messaging, recipientId, starterName, gameType, @@ -120,7 +124,7 @@ export const onGameSessionUpdate = functions.firestore const joinerName = joiner === partnerA ? partnerAName : partnerBName const joinerAvatar = joiner === partnerA ? avatarA : avatarB if (recipientInQuietHours(dataFor(startedBy))) { - console.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`) + logger.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`) } else { await notifyPartner( db, messaging, startedBy, joinerName, gameType, @@ -151,7 +155,7 @@ export const onGameSessionUpdate = functions.firestore const gt = currentData.gameType ?? 'wheel' // Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours. if (recipientInQuietHours(dataFor(partnerA))) { - console.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`) + logger.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`) } else { await notifyPartner( db, messaging, partnerA, partnerBName, gt, @@ -160,7 +164,7 @@ export const onGameSessionUpdate = functions.firestore ) } if (recipientInQuietHours(dataFor(partnerB))) { - console.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`) + logger.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`) } else { await notifyPartner( db, messaging, partnerB, partnerAName, gt, @@ -171,7 +175,8 @@ export const onGameSessionUpdate = functions.firestore } return } - }) + } +) /** * Notify the WAITING partner the moment the FIRST player finishes their part of an async game. @@ -186,13 +191,13 @@ export const onGameSessionUpdate = functions.firestore * Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc). */ const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync'] -export const onGamePartFinished = functions.firestore - .document('couples/{coupleId}/{gameType}/{sessionId}') - .onWrite(async (change, context) => { - const { coupleId, gameType, sessionId } = - context.params as { coupleId: string; gameType: string; sessionId: string } +export const onGamePartFinished = onDocumentWritten( + 'couples/{coupleId}/{gameType}/{sessionId}', + async (event) => { + const { coupleId, gameType, sessionId } = event.params if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return // ignore messages/reactions/etc. - if (!change.after.exists) return + const change = event.data + if (!change?.after.exists) return const answers = (change.after.data()?.answers ?? {}) as Record const answerUids = Object.keys(answers) @@ -231,7 +236,8 @@ export const onGamePartFinished = functions.firestore 'partner_completed_part', yourTurnBody(gameType), coupleId, finisherAvatar, sessionId ) - }) + } +) /** * Per-game body for the "your turn" (partner_completed_part) push — mirrors the client banner's @@ -269,11 +275,6 @@ async function notifyPartner( : notificationType === 'partner_joined_game' ? `${partnerName} joined your game` : `${partnerName} is playing` - const notificationPayload = { - type: notificationType, - title, - body: body, - } // Write an in-app notification record for the partner await db @@ -281,50 +282,20 @@ async function notifyPartner( .doc(partnerId) .collection('notification_queue') .add({ - ...notificationPayload, + type: notificationType, + title, + body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - // Collect the partner's FCM tokens - const tokens: string[] = [] - const partnerUserDoc = await db.collection('users').doc(partnerId).get() - - if (partnerUserDoc.exists) { - const legacyToken = partnerUserDoc.data()?.fcmToken - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken) - } - } - - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get() - tokenSnapshot.docs.forEach((doc) => { - const t = doc.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { - tokens.push(t) - } - }) - - if (tokens.length === 0) { - console.log(`[notifyPartner] no FCM tokens for ${partnerId}`) - return - } - - const fcmMessage: admin.messaging.Message = { - token: tokens[0], - notification: { - title: notificationPayload.title, - body: notificationPayload.body, - }, + await sendPushToUser(db, messaging, partnerId, { + notification: { title, body }, // Put backgrounded notifications on the Games channel instead of the FCM fallback channel, // so importance/sound and the per-category toggle apply. E-OBS. android: { notification: { channelId: 'game_activity' } }, data: { - type: notificationPayload.type, + type: notificationType, couple_id: coupleId, game_type: gameType, // The acting partner's display name (public; also in the title) so the in-app foreground @@ -337,24 +308,5 @@ async function notifyPartner( ? { sender_avatar_url: senderAvatarUrl } : {}), }, - } - - const sendResults = await Promise.allSettled( - tokens.map((token) => messaging.send({ ...fcmMessage, token })) - ) - - const failures: string[] = [] - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`) - } }) - - if (failures.length > 0) { - console.error(`[notifyPartner] some notifications failed:`, failures) - } else { - console.log(`[notifyPartner] notified ${partnerId} (${notificationType})`) - } - - await pruneDeadTokens(db, partnerId, tokens, sendResults) } diff --git a/functions/src/notifications/idempotency.test.ts b/functions/src/notifications/idempotency.test.ts new file mode 100644 index 00000000..d2e0b5ac --- /dev/null +++ b/functions/src/notifications/idempotency.test.ts @@ -0,0 +1,20 @@ +import { claimOnce } from './idempotency' + +function markRef(create: () => Promise) { + return { path: 'couples/c1/notif_marks/m1', create } as any +} + +describe('claimOnce', () => { + it('claims (true) when the marker did not exist', async () => { + expect(await claimOnce(markRef(async () => undefined))).toBe(true) + }) + + it('does not claim (false) when the marker already exists (ALREADY_EXISTS = 6)', async () => { + expect(await claimOnce(markRef(async () => Promise.reject({ code: 6 })))).toBe(false) + }) + + it('fails open (true) on any other create error so a ping is not silently dropped', async () => { + expect(await claimOnce(markRef(async () => Promise.reject({ code: 14 })))).toBe(true) + expect(await claimOnce(markRef(async () => Promise.reject(new Error('boom'))))).toBe(true) + }) +}) diff --git a/functions/src/notifications/idempotency.ts b/functions/src/notifications/idempotency.ts new file mode 100644 index 00000000..be317691 --- /dev/null +++ b/functions/src/notifications/idempotency.ts @@ -0,0 +1,42 @@ +import * as admin from 'firebase-admin' +import { logger } from '../log' + +/** gRPC status for a create() that hit an existing document. */ +const ALREADY_EXISTS = 6 + +/** + * Best-effort one-time claim for a notification event, keyed by a deterministic marker doc. + * + * Background triggers are delivered AT LEAST ONCE, so a redelivery of the same event would + * otherwise double-send a push. `create()` is an atomic create-if-absent: the first delivery + * writes the marker and returns true; a redelivery finds it present and returns false, so the + * caller skips. Claim right before sending — a suppressed (quiet-hours / opted-out) notification + * should not burn a claim. + * + * Fail-OPEN: if create() fails for any reason OTHER than "already claimed", return true and let + * the notification proceed. A rare duplicate is better UX than a silently dropped ping, and it + * keeps an infra blip on the marker write from swallowing every notification. The trade this makes + * explicit: at-least-once → at-most-once (a send failure AFTER the claim drops that one push). + */ +export async function claimOnce(markRef: admin.firestore.DocumentReference): Promise { + try { + await markRef.create({ claimedAt: admin.firestore.FieldValue.serverTimestamp() }) + return true + } catch (e) { + if ((e as { code?: number }).code === ALREADY_EXISTS) return false + logger.warn('[claimOnce] marker create failed; proceeding without dedupe', { + path: markRef.path, + error: String(e), + }) + return true + } +} + +/** Standard per-couple marker ref for a notification event (`couples/{id}/notif_marks/{markId}`). */ +export function notifMark( + db: admin.firestore.Firestore, + coupleId: string, + markId: string, +): admin.firestore.DocumentReference { + return db.collection('couples').doc(coupleId).collection('notif_marks').doc(markId) +} diff --git a/functions/src/questions/onAnswerRevealed.ts b/functions/src/questions/onAnswerRevealed.ts index 324beed1..4b717faa 100644 --- a/functions/src/questions/onAnswerRevealed.ts +++ b/functions/src/questions/onAnswerRevealed.ts @@ -1,7 +1,9 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentUpdated } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Firestore trigger: when one partner OPENS (reveals) the shared answers — i.e. their own @@ -10,17 +12,13 @@ import { pruneDeadTokens } from '../notifications/pruneTokens' * * Path: couples/{coupleId}/daily_question/{date}/answers/{userId} */ -export const onAnswerRevealed = functions.firestore - .document('couples/{coupleId}/daily_question/{date}/answers/{userId}') - .onUpdate(async (change, context) => { - const { coupleId, date, userId } = context.params as { - coupleId: string - date: string - userId: string - } +export const onAnswerRevealed = onDocumentUpdated( + 'couples/{coupleId}/daily_question/{date}/answers/{userId}', + async (event) => { + const { coupleId, date, userId } = event.params - const before = change.before.data() as Partial> - const after = change.after.data() as Partial> + const before = (event.data?.before.data() ?? {}) as Partial> + const after = (event.data?.after.data() ?? {}) as Partial> // Only fire on the false → true reveal transition. if (before.isRevealed === true || after.isRevealed !== true) return @@ -29,46 +27,39 @@ export const onAnswerRevealed = functions.firestore const coupleDoc = await db.collection('couples').doc(coupleId).get() if (!coupleDoc.exists) { - console.warn(`[onAnswerRevealed] couple ${coupleId} not found`) + logger.warn(`[onAnswerRevealed] couple ${coupleId} not found`) return } const userIds = (coupleDoc.data()?.userIds ?? []) as string[] if (!userIds.includes(userId)) { - console.warn(`[onAnswerRevealed] revealer ${userId} not a member of ${coupleId}`) + logger.warn(`[onAnswerRevealed] revealer ${userId} not a member of ${coupleId}`) return } const partnerId = userIds.find((uid) => uid !== userId) if (!partnerId) { - console.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`) + logger.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`) return } - // Partner FCM tokens (legacy field + multi-device subcollection). - const tokens: string[] = [] const partnerUserDoc = await db.collection('users').doc(partnerId).get() - if (partnerUserDoc.exists) { - const legacyToken = partnerUserDoc.data()?.fcmToken - if (typeof legacyToken === 'string' && legacyToken.length > 0) tokens.push(legacyToken) - } - const tokenSnapshot = await db.collection('users').doc(partnerId).collection('fcmTokens').get() - tokenSnapshot.docs.forEach((doc) => { - const t = doc.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t) - }) - if (tokens.length === 0) { - console.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`) - return - } + const partnerData = partnerUserDoc.data() // Respect the same partner-activity opt-out as the answered ping. - if (partnerUserDoc.data()?.notifPartnerAnswered === false) { - console.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`) + if (partnerData?.notifPartnerAnswered === false) { + logger.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`) return } // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. - if (recipientInQuietHours(partnerUserDoc.data())) { - console.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`) + if (recipientInQuietHours(partnerData)) { + logger.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`) + return + } + + // Dedupe redelivery of this reveal (at-least-once). The false→true guard above already stops + // the marker write from re-triggering this handler; the claim stops a redelivered event. + if (!(await claimOnce(notifMark(db, coupleId, `reveal-${date}-${userId}`)))) { + logger.log(`[onAnswerRevealed] already notified for ${userId}'s reveal on ${date}; skipping`) return } @@ -78,37 +69,33 @@ export const onAnswerRevealed = functions.firestore const revealerDoc = await db.collection('users').doc(userId).get() const revealerAvatar = revealerDoc.data()?.photoUrl - const payload: admin.messaging.MessagingPayload = { - notification: { - title: 'Your partner opened your answers', - body: 'Open to see what you each said.', - }, - data: { - type: 'partner_opened_answer', - couple_id: coupleId, - question_id: questionId, - date, - ...(typeof revealerAvatar === 'string' && revealerAvatar.length > 0 - ? { sender_avatar_url: revealerAvatar } - : {}), + const res = await sendPushToUser( + db, + admin.messaging(), + partnerId, + { + notification: { + title: 'Your partner opened your answers', + body: 'Open to see what you each said.', + }, + data: { + type: 'partner_opened_answer', + couple_id: coupleId, + question_id: questionId, + date, + ...(typeof revealerAvatar === 'string' && revealerAvatar.length > 0 + ? { sender_avatar_url: revealerAvatar } + : {}), + }, + android: { notification: { channelId: 'partner_activity' } }, }, + partnerData, + ) + if (res.sent === 0 && res.failed === 0) { + logger.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`) + return } - const sendResults = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - ...payload, - token, - android: { notification: { channelId: 'partner_activity' } }, - } as admin.messaging.Message) - ) - ) - const failures: string[] = [] - sendResults.forEach((r, i) => { - if (r.status === 'rejected') failures.push(`${tokens[i]}: ${String(r.reason)}`) - }) - if (failures.length > 0) console.error('[onAnswerRevealed] some notifications failed:', failures) - await pruneDeadTokens(db, partnerId, tokens, sendResults) - - console.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`) - }) + logger.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`) + } +) diff --git a/functions/src/questions/onAnswerWritten.ts b/functions/src/questions/onAnswerWritten.ts index e0651670..2e454093 100644 --- a/functions/src/questions/onAnswerWritten.ts +++ b/functions/src/questions/onAnswerWritten.ts @@ -1,7 +1,9 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentCreated } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Firestore trigger that sends an FCM notification to the other partner when @@ -12,20 +14,18 @@ import { pruneDeadTokens } from '../notifications/pruneTokens' * the answer reveal screen. It also contains a `notification` block for * system-tray display when the app is in the background. */ -export const onAnswerWritten = functions.firestore - .document('couples/{coupleId}/daily_question/{date}/answers/{userId}') - .onCreate(async (snap, context) => { - const { coupleId, date, userId } = context.params as { - coupleId: string - date: string - userId: string - } +export const onAnswerWritten = onDocumentCreated( + 'couples/{coupleId}/daily_question/{date}/answers/{userId}', + async (event) => { + const { coupleId, date, userId } = event.params + const snap = event.data + if (!snap) return const db = admin.firestore() const coupleDoc = await db.collection('couples').doc(coupleId).get() if (!coupleDoc.exists) { - console.warn(`[onAnswerWritten] couple ${coupleId} not found`) + logger.warn(`[onAnswerWritten] couple ${coupleId} not found`) return } @@ -35,54 +35,35 @@ export const onAnswerWritten = functions.firestore // before sending a cross-user notification. Firestore rules already enforce this, // but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping. if (!userIds.includes(userId)) { - console.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`) + logger.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`) return } const partnerId = userIds.find((uid) => uid !== userId) if (!partnerId) { - console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`) + logger.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`) return } - // Look up the partner's FCM tokens. We support a legacy `fcmToken` field - // on the user doc and a dedicated `fcmTokens` subcollection for multiple devices. - const tokens: string[] = [] const partnerUserDoc = await db.collection('users').doc(partnerId).get() - if (partnerUserDoc.exists) { - const legacyToken = partnerUserDoc.data()?.fcmToken - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken) - } - } - - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get() - tokenSnapshot.docs.forEach((doc) => { - const t = doc.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { - tokens.push(t) - } - }) - - if (tokens.length === 0) { - console.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`) - return - } + const partnerData = partnerUserDoc.data() // Respect the partner's notification preference (opt-out; default is enabled). - const notifEnabled = partnerUserDoc.data()?.notifPartnerAnswered - if (notifEnabled === false) { - console.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`) + if (partnerData?.notifPartnerAnswered === false) { + logger.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`) return } // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. - if (recipientInQuietHours(partnerUserDoc.data())) { - console.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`) + if (recipientInQuietHours(partnerData)) { + logger.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`) + return + } + + // Dedupe: at-least-once delivery can redeliver this create; claim once so a redelivery + // doesn't double-ping the partner (at-most-once: a post-claim send failure drops this one). + if (!(await claimOnce(notifMark(db, coupleId, `answer-${date}-${userId}`)))) { + logger.log(`[onAnswerWritten] already notified for ${userId}'s answer on ${date}; skipping`) return } @@ -101,56 +82,45 @@ export const onAnswerWritten = functions.firestore .collection('answers').doc(partnerId).get() const bothAnswered = partnerAnswerSnap.exists - const payload: admin.messaging.MessagingPayload = { - notification: bothAnswered - ? { - title: 'Your answers are unlocked ✨', - body: "You've both answered — open to see what you each said.", - } - : { - title: 'Your partner just answered!', - body: "See what they shared for tonight's prompt.", - }, - data: { - type: 'partner_answered', - couple_id: coupleId, - question_id: questionId, - date, - ...(typeof senderAvatar === 'string' && senderAvatar.length > 0 - ? { sender_avatar_url: senderAvatar } - : {}), + const res = await sendPushToUser( + db, + admin.messaging(), + partnerId, + { + notification: bothAnswered + ? { + title: 'Your answers are unlocked ✨', + body: "You've both answered — open to see what you each said.", + } + : { + title: 'Your partner just answered!', + body: "See what they shared for tonight's prompt.", + }, + data: { + type: 'partner_answered', + couple_id: coupleId, + question_id: questionId, + date, + ...(typeof senderAvatar === 'string' && senderAvatar.length > 0 + ? { sender_avatar_url: senderAvatar } + : {}), + }, + android: { notification: { channelId: 'partner_activity' } }, // E-OBS }, - } - - const sendResults = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - ...payload, - token, - android: { notification: { channelId: 'partner_activity' } }, // E-OBS - } as admin.messaging.Message) - ) + partnerData, ) - - const failures: string[] = [] - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`) - } - }) - - if (failures.length > 0) { - console.error(`[onAnswerWritten] some notifications failed:`, failures) + if (res.sent === 0 && res.failed === 0) { + logger.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`) + return } - await pruneDeadTokens(db, partnerId, tokens, sendResults) - // Track last activity time on the couple doc for re-engagement targeting. await db.collection('couples').doc(coupleId).update({ lastAnsweredAt: admin.firestore.FieldValue.serverTimestamp(), - }).catch((e) => console.warn('[onAnswerWritten] lastAnsweredAt update failed:', e)) + }).catch((e) => logger.warn('[onAnswerWritten] lastAnsweredAt update failed', { error: String(e) })) - console.log( + logger.log( `[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}` ) - }) + } +) diff --git a/functions/src/questions/onMessageWritten.ts b/functions/src/questions/onMessageWritten.ts index 9022721b..32c55a15 100644 --- a/functions/src/questions/onMessageWritten.ts +++ b/functions/src/questions/onMessageWritten.ts @@ -1,7 +1,9 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { onDocumentCreated } from 'firebase-functions/v2/firestore' import { recipientInQuietHours } from '../notifications/quietHours' -import { pruneDeadTokens } from '../notifications/pruneTokens' +import { sendPushToUser } from '../notifications/push' +import { claimOnce, notifMark } from '../notifications/idempotency' +import { logger } from '../log' /** * Firestore trigger that notifies the other partner when a chat message is @@ -11,73 +13,52 @@ import { pruneDeadTokens } from '../notifications/pruneTokens' * * Respects the recipient's `notifChatMessage` preference (default: enabled). */ -export const onMessageWritten = functions.firestore - .document('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}') - .onCreate(async (snap, context) => { - const { coupleId, conversationId, messageId } = context.params as { - coupleId: string - conversationId: string - messageId: string - } +export const onMessageWritten = onDocumentCreated( + 'couples/{coupleId}/conversations/{conversationId}/messages/{messageId}', + async (event) => { + const { coupleId, conversationId, messageId } = event.params + const snap = event.data + if (!snap) return const db = admin.firestore() const messageData = snap.data() as Partial> const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null if (!authorId) { - console.warn(`[onMessageWritten] no authorUserId on message ${messageId}`) + logger.warn(`[onMessageWritten] no authorUserId on message ${messageId}`) return } const coupleDoc = await db.collection('couples').doc(coupleId).get() if (!coupleDoc.exists) { - console.warn(`[onMessageWritten] couple ${coupleId} not found`) + logger.warn(`[onMessageWritten] couple ${coupleId} not found`) return } const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const partnerId = userIds.find((uid) => uid !== authorId) if (!partnerId) { - console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`) + logger.warn(`[onMessageWritten] no partner found for couple ${coupleId}`) return } const partnerUserDoc = await db.collection('users').doc(partnerId).get() + const partnerData = partnerUserDoc.data() // Respect the partner's notification preference (opt-out; default is enabled). - const notifEnabled = partnerUserDoc.data()?.notifChatMessage - if (notifEnabled === false) { - console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`) + if (partnerData?.notifChatMessage === false) { + logger.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`) return } // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. - if (recipientInQuietHours(partnerUserDoc.data())) { - console.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`) + if (recipientInQuietHours(partnerData)) { + logger.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`) return } - const tokens: string[] = [] - if (partnerUserDoc.exists) { - const legacyToken = partnerUserDoc.data()?.fcmToken - if (typeof legacyToken === 'string' && legacyToken.length > 0) { - tokens.push(legacyToken) - } - } - - const tokenSnapshot = await db - .collection('users') - .doc(partnerId) - .collection('fcmTokens') - .get() - tokenSnapshot.docs.forEach((doc) => { - const t = doc.data()?.token - if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) { - tokens.push(t) - } - }) - - if (tokens.length === 0) { - console.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`) + // Dedupe redelivery of this message create (at-least-once). messageId is unique per message. + if (!(await claimOnce(notifMark(db, coupleId, `msg-${messageId}`)))) { + logger.log(`[onMessageWritten] already notified for message ${messageId}; skipping`) return } @@ -86,44 +67,33 @@ export const onMessageWritten = functions.firestore const authorDoc = await db.collection('users').doc(authorId).get() const authorPhotoUrl = (authorDoc.data()?.photoUrl as string | undefined) ?? '' - const payload: admin.messaging.MessagingPayload = { - notification: { - title: 'Your partner sent a message', - body: 'Tap to read and reply.', + const res = await sendPushToUser( + db, + admin.messaging(), + partnerId, + { + notification: { + title: 'Your partner sent a message', + body: 'Tap to read and reply.', + }, + data: { + type: 'chat_message', + couple_id: coupleId, + conversation_id: conversationId, + ...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}), + }, + // E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel. + android: { notification: { channelId: 'partner_activity' } }, }, - data: { - type: 'chat_message', - couple_id: coupleId, - conversation_id: conversationId, - ...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}), - }, - } - - const sendResults = await Promise.allSettled( - tokens.map((token) => - admin.messaging().send({ - ...payload, - token, - // E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel. - android: { notification: { channelId: 'partner_activity' } }, - } as admin.messaging.Message) - ) + partnerData, ) - - const failures: string[] = [] - sendResults.forEach((result, index) => { - if (result.status === 'rejected') { - failures.push(`${tokens[index]}: ${String(result.reason)}`) - } - }) - - if (failures.length > 0) { - console.error(`[onMessageWritten] some notifications failed:`, failures) + if (res.sent === 0 && res.failed === 0) { + logger.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`) + return } - await pruneDeadTokens(db, partnerId, tokens, sendResults) - - console.log( + logger.log( `[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}` ) - }) + } +)