refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden

Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).

Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
  ~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
  logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
  couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
  senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
  onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
  onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
  (lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
  would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).

Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.

Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 23:46:17 -05:00
parent fbf4e7957c
commit 4040abbf28
39 changed files with 931 additions and 1222 deletions

View File

@ -36,15 +36,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.onRestoreFulfilled = exports.onRestoreRequested = exports.SELF_ALERT_DEDUPE_MS = void 0; exports.onRestoreFulfilled = exports.onRestoreRequested = exports.SELF_ALERT_DEDUPE_MS = void 0;
exports.selfAlertAllowed = selfAlertAllowed; exports.selfAlertAllowed = selfAlertAllowed;
exports.isRestoreReadyTransition = isRestoreReadyTransition; exports.isRestoreReadyTransition = isRestoreReadyTransition;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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'; const CHANNEL = 'partner_activity';
// Suppress duplicate "was this you?" pushes when a request doc is rapidly deleted+recreated (a compromised // Suppress duplicate pushes when a request doc is rapidly deleted+recreated (a compromised account
// account could loop that to spam both partners). The in-app queue entry is still written every time. // 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; 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) { function selfAlertAllowed(lastAlertAt, now) {
if (typeof lastAlertAt !== 'number') if (typeof lastAlertAt !== 'number')
return true; return true;
@ -54,23 +57,6 @@ function selfAlertAllowed(lastAlertAt, now) {
function isRestoreReadyTransition(beforeStatus, afterStatus) { function isRestoreReadyTransition(beforeStatus, afterStatus) {
return afterStatus === 'READY' && beforeStatus !== 'READY'; 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. * 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. * `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(); const userDoc = await db.collection('users').doc(uid).get();
if (!userDoc.exists) if (!userDoc.exists)
return; return;
if (!bypassQuietHours && (0, quietHours_1.recipientInQuietHours)(userDoc.data())) const userData = userDoc.data();
if (!bypassQuietHours && (0, quietHours_1.recipientInQuietHours)(userData))
return; return;
const tokens = await gatherTokens(db, uid); await (0, push_1.sendPushToUser)(db, admin.messaging(), uid, {
if (tokens.length === 0)
return;
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send({
notification: { title, body }, notification: { title, body },
data: { type, couple_id: coupleId }, data: { type, couple_id: coupleId },
token,
android: { notification: { channelId: CHANNEL } }, android: { notification: { channelId: CHANNEL } },
}))); }, userData);
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);
} }
/** /**
* Fires when a member starts a partner-assisted restore * 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. * 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. * No key material is read or logged the request carries only a public key + a nonce.
*/ */
exports.onRestoreRequested = functions.firestore exports.onRestoreRequested = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/restore_requests/{recipientUid}', async (event) => {
.document('couples/{coupleId}/restore_requests/{recipientUid}') var _a, _b, _c, _d;
.onCreate(async (_snap, context) => { const { coupleId, recipientUid } = event.params;
var _a, _b, _c;
const { coupleId, recipientUid } = context.params;
const db = admin.firestore(); const db = admin.firestore();
const coupleRef = db.collection('couples').doc(coupleId); const coupleRef = db.collection('couples').doc(coupleId);
const coupleDoc = await coupleRef.get(); const coupleDoc = await coupleRef.get();
@ -127,9 +103,13 @@ exports.onRestoreRequested = functions.firestore
if (!partnerId) if (!partnerId)
return; return;
// Audit trail (no key material — actor/recipient/timestamp only). // Audit trail (no key material — actor/recipient/timestamp only).
console.log(`[onRestoreRequested] couple=${coupleId} recipient=${recipientUid} partner=${partnerId}`); log_1.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 { try {
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, { await queueAndPush(db, partnerId, {
type: 'restore_requested', type: 'restore_requested',
title: 'Help your partner restore 💜', title: 'Help your partner restore 💜',
@ -137,13 +117,14 @@ exports.onRestoreRequested = functions.firestore
coupleId, coupleId,
}); });
} }
}
catch (e) { 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. // 2) Recipient self-alert — security signal, bypasses quiet hours, deduped against create-loops.
try { try {
const now = Date.now(); 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 coupleRef.set({ lastRestoreSelfAlertAt: now }, { merge: true });
await queueAndPush(db, recipientUid, { await queueAndPush(db, recipientUid, {
type: 'restore_self_alert', type: 'restore_self_alert',
@ -155,7 +136,7 @@ exports.onRestoreRequested = functions.firestore
} }
} }
catch (e) { 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" * 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 REQUESTEDREADY transition (ignores decline/expire). * signal for the real owner. Guarded to the single REQUESTEDREADY transition (ignores decline/expire).
*/ */
exports.onRestoreFulfilled = functions.firestore exports.onRestoreFulfilled = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/restore_requests/{recipientUid}', async (event) => {
.document('couples/{coupleId}/restore_requests/{recipientUid}') var _a, _b, _c, _d;
.onUpdate(async (change, context) => { const before = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data();
var _a, _b; const after = (_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data();
const before = change.before.data();
const after = change.after.data();
if (!isRestoreReadyTransition(before === null || before === void 0 ? void 0 : before.status, after === null || after === void 0 ? void 0 : after.status)) if (!isRestoreReadyTransition(before === null || before === void 0 ? void 0 : before.status, after === null || after === void 0 ? void 0 : after.status))
return; return;
const { coupleId, recipientUid } = context.params; const { coupleId, recipientUid } = event.params;
const db = admin.firestore(); const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get(); 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)) if (!userIds.includes(recipientUid))
return; return;
console.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`); log_1.logger.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`);
try { try {
await queueAndPush(db, recipientUid, { await queueAndPush(db, recipientUid, {
type: 'restore_self_alert', type: 'restore_self_alert',
@ -188,7 +167,7 @@ exports.onRestoreFulfilled = functions.firestore
}); });
} }
catch (e) { catch (e) {
console.warn('[onRestoreFulfilled] self-alert failed:', e); log_1.logger.warn('[onRestoreFulfilled] self-alert failed', { error: String(e) });
} }
}); });
//# sourceMappingURL=onRestoreRequested.js.map //# sourceMappingURL=onRestoreRequested.js.map

File diff suppressed because one or more lines are too long

View File

@ -34,9 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onEntitlementChanged = void 0; exports.onEntitlementChanged = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); 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 * 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`.) * for the non-subscriber. (Future.md: `subscription_entitlement_changed`.)
@ -55,33 +57,14 @@ function isActive(data) {
return false; return false;
return true; return true;
} }
async function collectTokens(db, userId) { exports.onEntitlementChanged = (0, firestore_1.onDocumentWritten)('users/{userId}/entitlements/premium', async (event) => {
var _a; var _a, _b, _c, _d;
const tokens = []; const { userId } = event.params;
const userDoc = await db.collection('users').doc(userId).get(); const before = isActive((_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data());
const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; const after = isActive((_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data());
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());
if (before || !after) if (before || !after)
return; // only a genuine inactive→active gain return; // only a genuine inactive→active gain
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging();
// Resolve this user's couple + partner. // Resolve this user's couple + partner.
const coupleSnap = await db const coupleSnap = await db
.collection('couples') .collection('couples')
@ -89,21 +72,27 @@ exports.onEntitlementChanged = functions.firestore
.limit(1) .limit(1)
.get(); .get();
if (coupleSnap.empty) { if (coupleSnap.empty) {
console.log(`[onEntitlementChanged] no couple for ${userId}`); log_1.logger.log(`[onEntitlementChanged] no couple for ${userId}`);
return; return;
} }
const coupleDoc = coupleSnap.docs[0]; const coupleDoc = coupleSnap.docs[0];
const coupleId = coupleDoc.id; 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); const partnerId = userIds.find((id) => id !== userId);
if (!partnerId) { if (!partnerId) {
console.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`); log_1.logger.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`);
return; return;
} }
// If the partner already has premium the couple was already unlocked — nothing new to announce. // 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(); const partnerEnt = await db.doc(`users/${partnerId}/entitlements/premium`).get();
if (isActive(partnerEnt.data())) { 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; return;
} }
// displayName is E2EE in users/{uid}, so use a generic label (this copy is stored server-side in // 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) .doc(partnerId)
.collection('notification_queue') .collection('notification_queue')
.add(Object.assign(Object.assign({}, payload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() })); .add(Object.assign(Object.assign({}, payload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() }));
const tokens = await collectTokens(db, partnerId); const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
if (tokens.length === 0) {
console.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`);
return;
}
const message = {
token: tokens[0],
notification: { title: payload.title, body: payload.body }, notification: { title: payload.title, body: payload.body },
android: { notification: { channelId: 'partner_activity' } }, android: { notification: { channelId: 'partner_activity' } },
data: { type: payload.type, couple_id: coupleId }, 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); if (res.sent === 0 && res.failed === 0) {
console.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`); 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 //# sourceMappingURL=onEntitlementChanged.js.map

View File

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

View File

@ -34,9 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onCoupleLeave = void 0; exports.onCoupleLeave = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); 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 * 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). * 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} * Path: users/{userId}
* Condition: previous coupleId was non-empty and new coupleId is null/missing. * Condition: previous coupleId was non-empty and new coupleId is null/missing.
*/ */
exports.onCoupleLeave = functions.firestore exports.onCoupleLeave = (0, firestore_1.onDocumentUpdated)('users/{userId}', async (event) => {
.document('users/{userId}')
.onUpdate(async (change, context) => {
var _a, _b, _c, _d, _e, _f; var _a, _b, _c, _d, _e, _f;
const { userId } = context.params; const { userId } = event.params;
const previousData = (_a = change.before.data()) !== null && _a !== void 0 ? _a : {}; const previousData = (_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {};
const currentData = (_b = change.after.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 previousCoupleId = previousData.coupleId;
const currentCoupleId = currentData.coupleId; const currentCoupleId = currentData.coupleId;
// Only act when coupleId transitions from a real value to null/empty. // Only act when coupleId transitions from a real value to null/empty.
@ -61,24 +61,30 @@ exports.onCoupleLeave = functions.firestore
return; return;
} }
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging();
const coupleDoc = await db.collection('couples').doc(previousCoupleId).get(); const coupleDoc = await db.collection('couples').doc(previousCoupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`); log_1.logger.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`);
return; 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); const partnerId = userIds.find((uid) => uid !== userId);
if (!partnerId) { if (!partnerId) {
console.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`); log_1.logger.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`);
return; return;
} }
// Make sure the partner is still paired in this couple. // Make sure the partner is still paired in this couple.
// If both users are leaving simultaneously, avoid duplicate/phantom notifications. // If both users are leaving simultaneously, avoid duplicate/phantom notifications.
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); 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) { 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; return;
} }
const notificationPayload = { const notificationPayload = {
@ -93,32 +99,7 @@ exports.onCoupleLeave = functions.firestore
.doc(partnerId) .doc(partnerId)
.collection('notification_queue') .collection('notification_queue')
.add(Object.assign(Object.assign({}, notificationPayload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() })); .add(Object.assign(Object.assign({}, notificationPayload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() }));
// Collect the partner's FCM tokens (legacy field + fcmTokens subcollection). const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
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],
notification: { notification: {
title: notificationPayload.title, title: notificationPayload.title,
body: notificationPayload.body, body: notificationPayload.body,
@ -127,18 +108,11 @@ exports.onCoupleLeave = functions.firestore
data: { data: {
type: notificationPayload.type, type: notificationPayload.type,
}, },
}; }, partnerData);
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, fcmMessage), { token })))); if (res.sent === 0 && res.failed === 0) {
const failures = []; log_1.logger.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`);
sendResults.forEach((result, index) => { return;
if (result.status === 'rejected') {
failures.push(`${tokens[index]}: ${String(result.reason)}`);
} }
}); log_1.logger.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`);
if (failures.length > 0) {
console.error(`[onCoupleLeave] some notifications failed:`, failures);
}
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults);
console.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`);
}); });
//# sourceMappingURL=onCoupleLeave.js.map //# sourceMappingURL=onCoupleLeave.js.map

View File

@ -1 +1 @@
{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":"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"} {"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,gDAAsD;AACtD,8DAAmE;AACnE,gCAA+B;AAE/B;;;;;;GAMG;AACU,QAAA,aAAa,GAAG,IAAA,6BAAiB,EAAC,gBAAgB,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;;IAC/E,MAAM,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IAE/B,MAAM,YAAY,GAAG,MAAA,MAAA,KAAK,CAAC,IAAI,0CAAE,MAAM,CAAC,IAAI,EAAE,mCAAI,EAAE,CAAA;IACpD,MAAM,WAAW,GAAG,MAAA,MAAA,KAAK,CAAC,IAAI,0CAAE,KAAK,CAAC,IAAI,EAAE,mCAAI,EAAE,CAAA;IAElD,MAAM,gBAAgB,GAAG,YAAY,CAAC,QAAQ,CAAA;IAC9C,MAAM,eAAe,GAAG,WAAW,CAAC,QAAQ,CAAA;IAE5C,sEAAsE;IACtE,IAAI,CAAC,gBAAgB,IAAI,OAAO,gBAAgB,KAAK,QAAQ,EAAE,CAAC;QAC9D,OAAM;IACR,CAAC;IACD,IAAI,eAAe,EAAE,CAAC;QACpB,OAAM;IACR,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,GAAG,EAAE,CAAA;IAC5E,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,YAAM,CAAC,IAAI,CAAC,0BAA0B,gBAAgB,YAAY,CAAC,CAAA;QACnE,OAAM;IACR,CAAC;IAED,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,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,MAAM,CAAC,CAAA;IACvD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,YAAM,CAAC,IAAI,CAAC,+CAA+C,gBAAgB,EAAE,CAAC,CAAA;QAC9E,OAAM;IACR,CAAC;IAED,wDAAwD;IACxD,mFAAmF;IACnF,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,MAAM,eAAe,GAAG,WAAW,aAAX,WAAW,uBAAX,WAAW,CAAE,QAAQ,CAAA;IAC7C,IAAI,eAAe,KAAK,gBAAgB,EAAE,CAAC;QACzC,YAAM,CAAC,GAAG,CACR,2BAA2B,SAAS,2BAA2B,gBAAgB,yBAAyB,CACzG,CAAA;QACD,OAAM;IACR,CAAC;IAED,+FAA+F;IAC/F,kCAAkC;IAClC,IAAI,CAAC,CAAC,MAAM,IAAA,uBAAS,EAAC,IAAA,uBAAS,EAAC,EAAE,EAAE,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3E,YAAM,CAAC,GAAG,CAAC,+CAA+C,MAAM,oBAAoB,CAAC,CAAA;QACrF,OAAM;IACR,CAAC;IAED,MAAM,mBAAmB,GAAG;QAC1B,IAAI,EAAE,cAAc;QACpB,KAAK,EAAE,uBAAuB;QAC9B,IAAI,EAAE,uDAAuD;KAC9D,CAAA;IAED,uDAAuD;IACvD,sEAAsE;IACtE,MAAM,EAAE;SACL,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,oBAAoB,CAAC;SAChC,GAAG,iCACC,mBAAmB,KACtB,IAAI,EAAE,KAAK,EACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE,IACvD,CAAA;IAEJ,MAAM,GAAG,GAAG,MAAM,IAAA,qBAAc,EAC9B,EAAE,EACF,KAAK,CAAC,SAAS,EAAE,EACjB,SAAS,EACT;QACE,YAAY,EAAE;YACZ,KAAK,EAAE,mBAAmB,CAAC,KAAK;YAChC,IAAI,EAAE,mBAAmB,CAAC,IAAI;SAC/B;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,QAAQ;QACtE,IAAI,EAAE;YACJ,IAAI,EAAE,mBAAmB,CAAC,IAAI;SAC/B;KACF,EACD,WAAW,CACZ,CAAA;IACD,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvC,YAAM,CAAC,GAAG,CAAC,6CAA6C,SAAS,EAAE,CAAC,CAAA;QACpE,OAAM;IACR,CAAC;IAED,YAAM,CAAC,GAAG,CACR,oCAAoC,SAAS,cAAc,MAAM,gBAAgB,gBAAgB,EAAE,CACpG,CAAA;AACH,CAAC,CAAC,CAAA"}

View File

@ -34,9 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.notifyOnDateMatch = void 0; exports.notifyOnDateMatch = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); 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. * 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. * only sends the push to both partners it never reads swipe content.
* *
* Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations * 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. * the marker itself is already de-duplicated by the client transaction + rules.
*/ */
exports.notifyOnDateMatch = functions.firestore exports.notifyOnDateMatch = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_matches/{dateIdeaId}', async (event) => {
.document('couples/{coupleId}/date_matches/{dateIdeaId}')
.onCreate(async (snap, context) => {
var _a, _b; var _a, _b;
if (!snap.exists) const snap = event.data;
if (!snap || !snap.exists)
return; return;
const { coupleId, dateIdeaId } = context.params; const { coupleId, dateIdeaId } = event.params;
const db = admin.firestore(); const db = admin.firestore();
const matchRef = snap.ref; const matchRef = snap.ref;
// Atomically claim the FCM send so concurrent invocations don't double-send. // Atomically claim the FCM send so concurrent invocations don't double-send.
@ -73,7 +72,7 @@ exports.notifyOnDateMatch = functions.firestore
return; return;
const coupleDoc = await db.collection('couples').doc(coupleId).get(); 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 = ((_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) { async function notifyDateMatch(db, userId, coupleId, dateIdeaId) {
await db.collection('users').doc(userId).collection('notification_queue').add({ await db.collection('users').doc(userId).collection('notification_queue').add({
@ -83,11 +82,7 @@ async function notifyDateMatch(db, userId, coupleId, dateIdeaId) {
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
const tokens = await getUserTokens(db, userId); await (0, push_1.sendPushToUser)(db, admin.messaging(), userId, {
if (tokens.length === 0)
return;
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send({
token,
notification: { notification: {
title: "It's a match!", title: "It's a match!",
body: "You both want to go on this date. Time to make it happen.", 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, couple_id: coupleId,
date_idea_id: dateIdeaId, 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 //# sourceMappingURL=createDateMatch.js.map

View File

@ -1 +1 @@
{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,8DAA8D;AAE9D;;;;;;;;;;;;;GAaG;AACU,QAAA,iBAAiB,GAAG,SAAS,CAAC,SAAS;KACjD,QAAQ,CAAC,8CAA8C,CAAC;KACxD,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;;IAChC,IAAI,CAAC,IAAI,CAAC,MAAM;QAAE,OAAM;IAExB,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,MAGxC,CAAA;IAED,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,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,eAAe,CAAC,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC,CAAC,CAAA;AACzF,CAAC,CAAC,CAAA;AAEJ,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,MAAM,GAAG,MAAM,aAAa,CAAC,EAAE,EAAE,MAAM,CAAC,CAAA;IAC9C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/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,KAAK;QACL,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,CACH,CACF,CAAA;IACD,MAAM,IAAA,6BAAe,EAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;AACpD,CAAC;AAED,KAAK,UAAU,aAAa,CAC1B,EAA6B,EAC7B,MAAc;;IAEd,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAA;IAC9D,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;IAExE,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IACnF,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QACxB,MAAM,CAAC,GAAG,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QAC3B,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"} {"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"}

View File

@ -34,20 +34,23 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateHistoryCreated = void 0; exports.onDateHistoryCreated = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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 * 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 reflectreveal loop * the OTHER partner (not the one who logged it) to add their reflection so the reflectreveal loop
* starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours. * starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours.
*/ */
exports.onDateHistoryCreated = functions.firestore exports.onDateHistoryCreated = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_history/{dateId}', async (event) => {
.document('couples/{coupleId}/date_history/{dateId}') var _a, _b, _c;
.onCreate(async (snap, context) => { const { coupleId, dateId } = event.params;
var _a, _b, _c, _d, _e; const snap = event.data;
const { coupleId, dateId } = context.params; if (!snap)
return;
const db = admin.firestore(); const db = admin.firestore();
const addedBy = (_a = snap.data()) === null || _a === void 0 ? void 0 : _a.addedBy; const addedBy = (_a = snap.data()) === null || _a === void 0 ? void 0 : _a.addedBy;
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
@ -58,37 +61,25 @@ exports.onDateHistoryCreated = functions.firestore
if (!partnerId) if (!partnerId)
return; return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); 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; 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 title = 'You went on a date 💜';
const body = 'Reflect on it together while its fresh.'; const body = 'Reflect on it together while its fresh.';
await db.collection('users').doc(partnerId).collection('notification_queue').add({ await db.collection('users').doc(partnerId).collection('notification_queue').add({
type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), 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; return;
const tokens = []; await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
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 = {
notification: { title, body }, notification: { title, body },
data: { type: 'date_logged', couple_id: coupleId, date_id: dateId }, data: { type: 'date_logged', couple_id: coupleId, date_id: dateId },
}; android: { notification: { channelId: 'partner_activity' } },
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, 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 (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, results);
}); });
//# sourceMappingURL=onDateHistoryCreated.js.map //# sourceMappingURL=onDateHistoryCreated.js.map

View File

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

View File

@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateReflectionRevealed = void 0; exports.onDateReflectionRevealed = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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 * 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}`). * 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 * 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). * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/ */
exports.onDateReflectionRevealed = functions.firestore exports.onDateReflectionRevealed = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', async (event) => {
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') var _a, _b, _c, _d, _e, _f, _g;
.onUpdate(async (change, context) => { const { coupleId, dateId, userId } = event.params;
var _a, _b, _c, _d, _e; const before = ((_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {});
const { coupleId, dateId, userId } = context.params; const after = ((_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {});
const before = change.before.data();
const after = change.after.data();
// Only on the false → true reveal transition. // Only on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true) if (before.isRevealed === true || after.isRevealed !== true)
return; return;
@ -59,15 +59,21 @@ exports.onDateReflectionRevealed = functions.firestore
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) if (!coupleDoc.exists)
return; 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)) if (!userIds.includes(userId))
return; return;
const partnerId = userIds.find((u) => u !== userId); const partnerId = userIds.find((u) => u !== userId);
if (!partnerId) if (!partnerId)
return; return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); const partnerUserDoc = await db.collection('users').doc(partnerId).get();
if (((_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifPartnerAnswered) === false) { const partnerData = partnerUserDoc.data();
console.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`); 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; return;
} }
const title = 'Your partner opened your reflection ✨'; 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({ await db.collection('users').doc(partnerId).collection('notification_queue').add({
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
console.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`); log_1.logger.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`);
return; return;
} }
const senderAvatar = (_d = (await db.collection('users').doc(userId).get()).data()) === null || _d === void 0 ? void 0 : _d.photoUrl; const senderAvatar = (_g = (await db.collection('users').doc(userId).get()).data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
const tokens = []; await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
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 = {
notification: { title, body }, notification: { title, body },
data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0 data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar } ? { sender_avatar_url: senderAvatar }
: {})), : {})),
}; android: { notification: { channelId: 'partner_activity' } },
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); }, partnerData);
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);
}); });
//# sourceMappingURL=onDateReflectionRevealed.js.map //# sourceMappingURL=onDateReflectionRevealed.js.map

View File

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

View File

@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateReflectionWritten = void 0; exports.onDateReflectionWritten = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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 * Fires when a partner writes their post-date reflection
* (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`). Notifies the OTHER partner "your * (`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 * 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). * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/ */
exports.onDateReflectionWritten = functions.firestore exports.onDateReflectionWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', async (event) => {
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') var _a, _b, _c;
.onCreate(async (_snap, context) => { const { coupleId, dateId, userId } = event.params;
var _a, _b, _c, _d, _e;
const { coupleId, dateId, userId } = context.params;
const db = admin.firestore(); const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) if (!coupleDoc.exists)
@ -62,9 +62,15 @@ exports.onDateReflectionWritten = functions.firestore
if (!partnerId) if (!partnerId)
return; return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
// Partner-activity opt-out (default on). // Partner-activity opt-out (default on).
if (((_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifPartnerAnswered) === false) { if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) {
console.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`); 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; return;
} }
// Did this complete the pair? If the recipient already reflected, both are done → reveal-ready. // 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(), type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
// Quiet hours: keep the in-app record, suppress the disruptive push. // Quiet hours: keep the in-app record, suppress the disruptive push.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
console.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`); log_1.logger.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`);
return; return;
} }
const senderAvatar = (_d = (await db.collection('users').doc(userId).get()).data()) === null || _d === void 0 ? void 0 : _d.photoUrl; const senderAvatar = (_c = (await db.collection('users').doc(userId).get()).data()) === null || _c === void 0 ? void 0 : _c.photoUrl;
const tokens = []; await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
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 = {
notification: { title, body }, notification: { title, body },
data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0 data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar } ? { sender_avatar_url: senderAvatar }
: {})), : {})),
}; android: { notification: { channelId: 'partner_activity' } }, // E-OBS
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); }, partnerData);
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);
}); });
//# sourceMappingURL=onDateReflectionWritten.js.map //# sourceMappingURL=onDateReflectionWritten.js.map

View File

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

View File

@ -34,44 +34,46 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onGamePartFinished = exports.onGameSessionUpdate = void 0; exports.onGamePartFinished = exports.onGameSessionUpdate = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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. * Firestore trigger that notifies partners when a game session is created or completed.
* *
* Path: couples/{coupleId}/sessions/{sessionId} * Path: couples/{coupleId}/sessions/{sessionId}
* Condition: onWrite (create, update, delete) * Condition: onWrite (create, update, delete)
*/ */
exports.onGameSessionUpdate = functions.firestore exports.onGameSessionUpdate = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/sessions/{sessionId}', async (event) => {
.document('couples/{coupleId}/sessions/{sessionId}')
.onWrite(async (change, context) => {
var _a, _b, _c, _d, _e, _f, _g, _h; 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 // 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. // game session, so it must never produce a partner notification.
if (sessionId === '_active') if (sessionId === '_active')
return; return;
const change = event.data;
if (!change)
return;
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
// Get the session document // Get the session document
const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get(); const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get();
const session = sessionDoc.data(); const session = sessionDoc.data();
if (!session) { if (!session) {
console.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`); log_1.logger.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`);
return; return;
} }
// Get couple info // Get couple info
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onGameSessionUpdate] couple ${coupleId} not found`); log_1.logger.warn(`[onGameSessionUpdate] couple ${coupleId} not found`);
return; return;
} }
const coupleData = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {}; const coupleData = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {};
const userIds = ((_b = coupleData.userIds) !== null && _b !== void 0 ? _b : []); const userIds = ((_b = coupleData.userIds) !== null && _b !== void 0 ? _b : []);
if (userIds.length !== 2) { 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; return;
} }
const partnerA = userIds[0]; const partnerA = userIds[0];
@ -115,7 +117,7 @@ exports.onGameSessionUpdate = functions.firestore
const starterName = startedBy === partnerA ? partnerAName : partnerBName; const starterName = startedBy === partnerA ? partnerAName : partnerBName;
const starterAvatar = startedBy === partnerA ? avatarA : avatarB; const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
if ((0, quietHours_1.recipientInQuietHours)(dataFor(recipientId))) { 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 { else {
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId); 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 joinerName = joiner === partnerA ? partnerAName : partnerBName;
const joinerAvatar = joiner === partnerA ? avatarA : avatarB; const joinerAvatar = joiner === partnerA ? avatarA : avatarB;
if ((0, quietHours_1.recipientInQuietHours)(dataFor(startedBy))) { 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 { else {
await notifyPartner(db, messaging, startedBy, joinerName, gameType, 'partner_joined_game', `${joinerName} joined your game — tap to play together.`, coupleId, joinerAvatar, sessionId); 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'; 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. // Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours.
if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerA))) { 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 { else {
await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId); 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))) { 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 { else {
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId); 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). * 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']; const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync'];
exports.onGamePartFinished = functions.firestore exports.onGamePartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/{gameType}/{sessionId}', async (event) => {
.document('couples/{coupleId}/{gameType}/{sessionId}')
.onWrite(async (change, context) => {
var _a, _b, _c, _d, _e; var _a, _b, _c, _d, _e;
const { coupleId, gameType, sessionId } = context.params; const { coupleId, gameType, sessionId } = event.params;
if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) if (!ASYNC_GAME_COLLECTIONS.includes(gameType))
return; // ignore messages/reactions/etc. 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; return;
const answers = ((_b = (_a = change.after.data()) === null || _a === void 0 ? void 0 : _a.answers) !== null && _b !== void 0 ? _b : {}); 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); const answerUids = Object.keys(answers);
@ -263,7 +264,6 @@ function yourTurnBody(gameType) {
* Send notification to partner via FCM and write to notification_queue. * Send notification to partner via FCM and write to notification_queue.
*/ */
async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId, senderAvatarUrl, sessionId) { async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId, senderAvatarUrl, sessionId) {
var _a;
const title = notificationType === 'partner_finished_game' const title = notificationType === 'partner_finished_game'
? `${partnerName} finished the game` ? `${partnerName} finished the game`
: notificationType === 'partner_completed_part' : notificationType === 'partner_completed_part'
@ -271,71 +271,29 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no
: notificationType === 'partner_joined_game' : notificationType === 'partner_joined_game'
? `${partnerName} joined your game` ? `${partnerName} joined your game`
: `${partnerName} is playing`; : `${partnerName} is playing`;
const notificationPayload = {
type: notificationType,
title,
body: body,
};
// Write an in-app notification record for the partner // Write an in-app notification record for the partner
await db await db
.collection('users') .collection('users')
.doc(partnerId) .doc(partnerId)
.collection('notification_queue') .collection('notification_queue')
.add(Object.assign(Object.assign({}, notificationPayload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() })); .add({
// Collect the partner's FCM tokens type: notificationType,
const tokens = []; title,
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); body,
if (partnerUserDoc.exists) { read: false,
const legacyToken = (_a = partnerUserDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken; createdAt: admin.firestore.FieldValue.serverTimestamp(),
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) { await (0, push_1.sendPushToUser)(db, messaging, partnerId, {
console.log(`[notifyPartner] no FCM tokens for ${partnerId}`); notification: { title, body },
return;
}
const fcmMessage = {
token: tokens[0],
notification: {
title: notificationPayload.title,
body: notificationPayload.body,
},
// Put backgrounded notifications on the Games channel instead of the FCM fallback channel, // Put backgrounded notifications on the Games channel instead of the FCM fallback channel,
// so importance/sound and the per-category toggle apply. E-OBS. // so importance/sound and the per-category toggle apply. E-OBS.
android: { notification: { channelId: 'game_activity' } }, 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 // 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". // banner can name them instead of a generic "Your partner".
sender_name: partnerName }, (sessionId ? { game_session_id: sessionId } : {})), (senderAvatarUrl && senderAvatarUrl.length > 0 sender_name: partnerName }, (sessionId ? { game_session_id: sessionId } : {})), (senderAvatarUrl && senderAvatarUrl.length > 0
? { sender_avatar_url: senderAvatarUrl } ? { 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 //# sourceMappingURL=onGameSessionUpdate.js.map

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onAnswerRevealed = void 0; exports.onAnswerRevealed = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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 * 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 * 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} * Path: couples/{coupleId}/daily_question/{date}/answers/{userId}
*/ */
exports.onAnswerRevealed = functions.firestore exports.onAnswerRevealed = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/daily_question/{date}/answers/{userId}', async (event) => {
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}') var _a, _b, _c, _d, _e, _f, _g;
.onUpdate(async (change, context) => { const { coupleId, date, userId } = event.params;
var _a, _b, _c, _d, _e; const before = ((_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {});
const { coupleId, date, userId } = context.params; const after = ((_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {});
const before = change.before.data();
const after = change.after.data();
// Only fire on the false → true reveal transition. // Only fire on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true) if (before.isRevealed === true || after.isRevealed !== true)
return; return;
const db = admin.firestore(); const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onAnswerRevealed] couple ${coupleId} not found`); log_1.logger.warn(`[onAnswerRevealed] couple ${coupleId} not found`);
return; 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)) { 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; return;
} }
const partnerId = userIds.find((uid) => uid !== userId); const partnerId = userIds.find((uid) => uid !== userId);
if (!partnerId) { if (!partnerId) {
console.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`); log_1.logger.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`);
return; return;
} }
// Partner FCM tokens (legacy field + multi-device subcollection).
const tokens = [];
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); const partnerUserDoc = await db.collection('users').doc(partnerId).get();
if (partnerUserDoc.exists) { const partnerData = partnerUserDoc.data();
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;
}
// Respect the same partner-activity opt-out as the answered ping. // Respect the same partner-activity opt-out as the answered ping.
if (((_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.notifPartnerAnswered) === false) { if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) {
console.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`); log_1.logger.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`);
return; return;
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
console.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`); 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; return;
} }
const questionId = typeof after.questionId === 'string' ? after.questionId : ''; 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 // 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. // (photoUrl) stays plaintext, so it's still sent.
const revealerDoc = await db.collection('users').doc(userId).get(); const revealerDoc = await db.collection('users').doc(userId).get();
const revealerAvatar = (_e = revealerDoc.data()) === null || _e === void 0 ? void 0 : _e.photoUrl; const revealerAvatar = (_g = revealerDoc.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
const payload = { const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: { notification: {
title: 'Your partner opened your answers', title: 'Your partner opened your answers',
body: 'Open to see what you each said.', 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 data: Object.assign({ type: 'partner_opened_answer', couple_id: coupleId, question_id: questionId, date }, (typeof revealerAvatar === 'string' && revealerAvatar.length > 0
? { sender_avatar_url: revealerAvatar } ? { sender_avatar_url: revealerAvatar }
: {})), : {})),
}; android: { notification: { channelId: 'partner_activity' } },
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); }, partnerData);
const failures = []; if (res.sent === 0 && res.failed === 0) {
sendResults.forEach((r, i) => { log_1.logger.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`);
if (r.status === 'rejected') return;
failures.push(`${tokens[i]}: ${String(r.reason)}`); }
}); log_1.logger.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`);
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}`);
}); });
//# sourceMappingURL=onAnswerRevealed.js.map //# sourceMappingURL=onAnswerRevealed.js.map

View File

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

View File

@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onAnswerWritten = void 0; exports.onAnswerWritten = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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 * Firestore trigger that sends an FCM notification to the other partner when
* one partner writes an answer under * 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 * the answer reveal screen. It also contains a `notification` block for
* system-tray display when the app is in the background. * system-tray display when the app is in the background.
*/ */
exports.onAnswerWritten = functions.firestore exports.onAnswerWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/daily_question/{date}/answers/{userId}', async (event) => {
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}') var _a, _b, _c;
.onCreate(async (snap, context) => { const { coupleId, date, userId } = event.params;
var _a, _b, _c, _d, _e; const snap = event.data;
const { coupleId, date, userId } = context.params; if (!snap)
return;
const db = admin.firestore(); const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onAnswerWritten] couple ${coupleId} not found`); log_1.logger.warn(`[onAnswerWritten] couple ${coupleId} not found`);
return; return;
} }
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); 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, // 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. // but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping.
if (!userIds.includes(userId)) { 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; return;
} }
const partnerId = userIds.find((uid) => uid !== userId); const partnerId = userIds.find((uid) => uid !== userId);
if (!partnerId) { if (!partnerId) {
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`); log_1.logger.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`);
return; 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(); const partnerUserDoc = await db.collection('users').doc(partnerId).get();
if (partnerUserDoc.exists) { const partnerData = partnerUserDoc.data();
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;
}
// Respect the partner's notification preference (opt-out; default is enabled). // 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 ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) {
if (notifEnabled === false) { log_1.logger.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`);
console.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`);
return; return;
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
console.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`); 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; return;
} }
const answerData = snap.data(); const answerData = snap.data();
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''; const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : '';
// Sender (the partner who just answered) avatar — used as the notification large icon. // Sender (the partner who just answered) avatar — used as the notification large icon.
const senderDoc = await db.collection('users').doc(userId).get(); 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 // 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". // reveal just unlocked for both — tell them it's ready to open, instead of "go answer".
const partnerAnswerSnap = await db const partnerAnswerSnap = await db
@ -120,7 +104,7 @@ exports.onAnswerWritten = functions.firestore
.collection('daily_question').doc(date) .collection('daily_question').doc(date)
.collection('answers').doc(partnerId).get(); .collection('answers').doc(partnerId).get();
const bothAnswered = partnerAnswerSnap.exists; const bothAnswered = partnerAnswerSnap.exists;
const payload = { const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: bothAnswered notification: bothAnswered
? { ? {
title: 'Your answers are unlocked ✨', 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 data: Object.assign({ type: 'partner_answered', couple_id: coupleId, question_id: questionId, date }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar } ? { sender_avatar_url: senderAvatar }
: {})), : {})),
}; android: { notification: { channelId: 'partner_activity' } }, // E-OBS
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token, android: { notification: { channelId: 'partner_activity' } } })))); }, partnerData);
const failures = []; if (res.sent === 0 && res.failed === 0) {
sendResults.forEach((result, index) => { log_1.logger.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`);
if (result.status === 'rejected') { return;
failures.push(`${tokens[index]}: ${String(result.reason)}`);
} }
});
if (failures.length > 0) {
console.error(`[onAnswerWritten] some notifications failed:`, failures);
}
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults);
// Track last activity time on the couple doc for re-engagement targeting. // Track last activity time on the couple doc for re-engagement targeting.
await db.collection('couples').doc(coupleId).update({ await db.collection('couples').doc(coupleId).update({
lastAnsweredAt: admin.firestore.FieldValue.serverTimestamp(), lastAnsweredAt: admin.firestore.FieldValue.serverTimestamp(),
}).catch((e) => console.warn('[onAnswerWritten] lastAnsweredAt update failed:', e)); }).catch((e) => log_1.logger.warn('[onAnswerWritten] lastAnsweredAt update failed', { error: String(e) }));
console.log(`[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`); log_1.logger.log(`[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`);
}); });
//# sourceMappingURL=onAnswerWritten.js.map //# sourceMappingURL=onAnswerWritten.js.map

View File

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

View File

@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onMessageWritten = void 0; exports.onMessageWritten = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours"); 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 * Firestore trigger that notifies the other partner when a chat message is
* sent in a conversation (the couple chat or a per-question discussion). * 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). * Respects the recipient's `notifChatMessage` preference (default: enabled).
*/ */
exports.onMessageWritten = functions.firestore exports.onMessageWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}', async (event) => {
.document('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}') var _a, _b, _c, _d;
.onCreate(async (snap, context) => { const { coupleId, conversationId, messageId } = event.params;
var _a, _b, _c, _d, _e, _f; const snap = event.data;
const { coupleId, conversationId, messageId } = context.params; if (!snap)
return;
const db = admin.firestore(); const db = admin.firestore();
const messageData = snap.data(); const messageData = snap.data();
const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null; const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null;
if (!authorId) { if (!authorId) {
console.warn(`[onMessageWritten] no authorUserId on message ${messageId}`); log_1.logger.warn(`[onMessageWritten] no authorUserId on message ${messageId}`);
return; return;
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onMessageWritten] couple ${coupleId} not found`); log_1.logger.warn(`[onMessageWritten] couple ${coupleId} not found`);
return; return;
} }
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); 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); const partnerId = userIds.find((uid) => uid !== authorId);
if (!partnerId) { if (!partnerId) {
console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`); log_1.logger.warn(`[onMessageWritten] no partner found for couple ${coupleId}`);
return; return;
} }
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
// Respect the partner's notification preference (opt-out; default is enabled). // 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 ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifChatMessage) === false) {
if (notifEnabled === false) { log_1.logger.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`);
console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`);
return; return;
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerUserDoc.data())) { if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
console.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`); log_1.logger.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`);
return; return;
} }
const tokens = []; // Dedupe redelivery of this message create (at-least-once). messageId is unique per message.
if (partnerUserDoc.exists) { if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `msg-${messageId}`)))) {
const legacyToken = (_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.fcmToken; log_1.logger.log(`[onMessageWritten] already notified for message ${messageId}; skipping`);
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}`);
return; return;
} }
// displayName is E2EE in users/{uid}, so the server can't read it for the OS-rendered push (the // 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. // 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 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 authorPhotoUrl = (_d = (_c = authorDoc.data()) === null || _c === void 0 ? void 0 : _c.photoUrl) !== null && _d !== void 0 ? _d : '';
const payload = { const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: { notification: {
title: 'Your partner sent a message', title: 'Your partner sent a message',
body: 'Tap to read and reply.', body: 'Tap to read and reply.',
}, },
data: Object.assign({ type: 'chat_message', couple_id: coupleId, conversation_id: conversationId }, (authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {})), 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. // E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel.
android: { notification: { channelId: 'partner_activity' } } })))); android: { notification: { channelId: 'partner_activity' } },
const failures = []; }, partnerData);
sendResults.forEach((result, index) => { if (res.sent === 0 && res.failed === 0) {
if (result.status === 'rejected') { log_1.logger.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`);
failures.push(`${tokens[index]}: ${String(result.reason)}`); return;
} }
}); log_1.logger.log(`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`);
if (failures.length > 0) {
console.error(`[onMessageWritten] some notifications failed:`, failures);
}
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults);
console.log(`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`);
}); });
//# sourceMappingURL=onMessageWritten.js.map //# sourceMappingURL=onMessageWritten.js.map

View File

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

View File

@ -1,14 +1,17 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentCreated, onDocumentUpdated } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' import { recipientInQuietHours } from '../notifications/quietHours'
import { pruneDeadTokens } from '../notifications/pruneTokens' import { sendPushToUser } from '../notifications/push'
import { logger } from '../log'
const CHANNEL = 'partner_activity' const CHANNEL = 'partner_activity'
// Suppress duplicate "was this you?" pushes when a request doc is rapidly deleted+recreated (a compromised // Suppress duplicate pushes when a request doc is rapidly deleted+recreated (a compromised account
// account could loop that to spam both partners). The in-app queue entry is still written every time. // 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 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 { export function selfAlertAllowed(lastAlertAt: unknown, now: number): boolean {
if (typeof lastAlertAt !== 'number') return true if (typeof lastAlertAt !== 'number') return true
return now - lastAlertAt >= SELF_ALERT_DEDUPE_MS return now - lastAlertAt >= SELF_ALERT_DEDUPE_MS
@ -19,20 +22,6 @@ export function isRestoreReadyTransition(beforeStatus: unknown, afterStatus: unk
return afterStatus === 'READY' && beforeStatus !== 'READY' 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<string[]> {
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. * 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. * `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() const userDoc = await db.collection('users').doc(uid).get()
if (!userDoc.exists) return 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) await sendPushToUser(
if (tokens.length === 0) return db,
const results = await Promise.allSettled( admin.messaging(),
tokens.map((token) => uid,
admin.messaging().send({ {
notification: { title, body }, notification: { title, body },
data: { type, couple_id: coupleId }, data: { type, couple_id: coupleId },
token,
android: { notification: { channelId: CHANNEL } }, android: { notification: { channelId: CHANNEL } },
} as admin.messaging.Message) },
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. * 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. * No key material is read or logged the request carries only a public key + a nonce.
*/ */
export const onRestoreRequested = functions.firestore export const onRestoreRequested = onDocumentCreated(
.document('couples/{coupleId}/restore_requests/{recipientUid}') 'couples/{coupleId}/restore_requests/{recipientUid}',
.onCreate(async (_snap, context) => { async (event) => {
const { coupleId, recipientUid } = context.params as { coupleId: string; recipientUid: string } const { coupleId, recipientUid } = event.params
const db = admin.firestore() const db = admin.firestore()
const coupleRef = db.collection('couples').doc(coupleId) const coupleRef = db.collection('couples').doc(coupleId)
@ -94,18 +79,23 @@ export const onRestoreRequested = functions.firestore
if (!partnerId) return if (!partnerId) return
// Audit trail (no key material — actor/recipient/timestamp only). // 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 { try {
const now = Date.now()
if (selfAlertAllowed(coupleDoc.data()?.lastRestorePartnerAlertAt, now)) {
await coupleRef.set({ lastRestorePartnerAlertAt: now }, { merge: true })
await queueAndPush(db, partnerId, { await queueAndPush(db, partnerId, {
type: 'restore_requested', type: 'restore_requested',
title: 'Help your partner restore 💜', title: 'Help your partner restore 💜',
body: 'Theyre setting up on a new device. Tap to help.', body: 'Theyre setting up on a new device. Tap to help.',
coupleId, coupleId,
}) })
}
} catch (e) { } 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. // 2) Recipient self-alert — security signal, bypasses quiet hours, deduped against create-loops.
@ -122,30 +112,31 @@ export const onRestoreRequested = functions.firestore
}) })
} }
} catch (e) { } 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 * 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" * 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 REQUESTEDREADY transition (ignores decline/expire). * signal for the real owner. Guarded to the single REQUESTEDREADY transition (ignores decline/expire).
*/ */
export const onRestoreFulfilled = functions.firestore export const onRestoreFulfilled = onDocumentUpdated(
.document('couples/{coupleId}/restore_requests/{recipientUid}') 'couples/{coupleId}/restore_requests/{recipientUid}',
.onUpdate(async (change, context) => { async (event) => {
const before = change.before.data() const before = event.data?.before.data()
const after = change.after.data() const after = event.data?.after.data()
if (!isRestoreReadyTransition(before?.status, after?.status)) return 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 db = admin.firestore()
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(recipientUid)) return if (!userIds.includes(recipientUid)) return
console.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`) logger.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`)
try { try {
await queueAndPush(db, recipientUid, { await queueAndPush(db, recipientUid, {
type: 'restore_self_alert', type: 'restore_self_alert',
@ -155,6 +146,7 @@ export const onRestoreFulfilled = functions.firestore
bypassQuietHours: true, bypassQuietHours: true,
}) })
} catch (e) { } catch (e) {
console.warn('[onRestoreFulfilled] self-alert failed:', e) logger.warn('[onRestoreFulfilled] self-alert failed', { error: String(e) })
} }
}) }
)

View File

@ -1,6 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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 * 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 return true
} }
async function collectTokens( export const onEntitlementChanged = onDocumentWritten(
db: admin.firestore.Firestore, 'users/{userId}/entitlements/premium',
userId: string async (event) => {
): Promise<string[]> { const { userId } = event.params
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 = functions.firestore const before = isActive(event.data?.before.data())
.document('users/{userId}/entitlements/premium') const after = isActive(event.data?.after.data())
.onWrite(async (change, context) => {
const { userId } = context.params as { userId: string }
const before = isActive(change.before.data())
const after = isActive(change.after.data())
if (before || !after) return // only a genuine inactive→active gain if (before || !after) return // only a genuine inactive→active gain
const db = admin.firestore() const db = admin.firestore()
const messaging = admin.messaging()
// Resolve this user's couple + partner. // Resolve this user's couple + partner.
const coupleSnap = await db const coupleSnap = await db
@ -53,7 +38,7 @@ export const onEntitlementChanged = functions.firestore
.limit(1) .limit(1)
.get() .get()
if (coupleSnap.empty) { if (coupleSnap.empty) {
console.log(`[onEntitlementChanged] no couple for ${userId}`) logger.log(`[onEntitlementChanged] no couple for ${userId}`)
return return
} }
const coupleDoc = coupleSnap.docs[0] const coupleDoc = coupleSnap.docs[0]
@ -61,14 +46,21 @@ export const onEntitlementChanged = functions.firestore
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== userId) const partnerId = userIds.find((id) => id !== userId)
if (!partnerId) { if (!partnerId) {
console.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`) logger.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`)
return return
} }
// If the partner already has premium the couple was already unlocked — nothing new to announce. // 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() const partnerEnt = await db.doc(`users/${partnerId}/entitlements/premium`).get()
if (isActive(partnerEnt.data())) { 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 return
} }
@ -91,26 +83,15 @@ export const onEntitlementChanged = functions.firestore
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const tokens = await collectTokens(db, partnerId) const res = await sendPushToUser(db, admin.messaging(), partnerId, {
if (tokens.length === 0) {
console.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`)
return
}
const message: admin.messaging.Message = {
token: tokens[0],
notification: { title: payload.title, body: payload.body }, notification: { title: payload.title, body: payload.body },
android: { notification: { channelId: 'partner_activity' } }, android: { notification: { channelId: 'partner_activity' } },
data: { type: payload.type, couple_id: coupleId }, data: { type: payload.type, couple_id: 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})`)
} }
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})`)
})

View File

@ -1,6 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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 * Firestore trigger that notifies the remaining partner when a user's coupleId
@ -9,13 +11,11 @@ import { pruneDeadTokens } from '../notifications/pruneTokens'
* Path: users/{userId} * Path: users/{userId}
* Condition: previous coupleId was non-empty and new coupleId is null/missing. * Condition: previous coupleId was non-empty and new coupleId is null/missing.
*/ */
export const onCoupleLeave = functions.firestore export const onCoupleLeave = onDocumentUpdated('users/{userId}', async (event) => {
.document('users/{userId}') const { userId } = event.params
.onUpdate(async (change, context) => {
const { userId } = context.params as { userId: string }
const previousData = change.before.data() ?? {} const previousData = event.data?.before.data() ?? {}
const currentData = change.after.data() ?? {} const currentData = event.data?.after.data() ?? {}
const previousCoupleId = previousData.coupleId const previousCoupleId = previousData.coupleId
const currentCoupleId = currentData.coupleId const currentCoupleId = currentData.coupleId
@ -29,32 +29,39 @@ export const onCoupleLeave = functions.firestore
} }
const db = admin.firestore() const db = admin.firestore()
const messaging = admin.messaging()
const coupleDoc = await db.collection('couples').doc(previousCoupleId).get() const coupleDoc = await db.collection('couples').doc(previousCoupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`) logger.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`)
return return
} }
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((uid) => uid !== userId) const partnerId = userIds.find((uid) => uid !== userId)
if (!partnerId) { if (!partnerId) {
console.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`) logger.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`)
return return
} }
// Make sure the partner is still paired in this couple. // Make sure the partner is still paired in this couple.
// If both users are leaving simultaneously, avoid duplicate/phantom notifications. // If both users are leaving simultaneously, avoid duplicate/phantom notifications.
const partnerUserDoc = await db.collection('users').doc(partnerId).get() const partnerUserDoc = await db.collection('users').doc(partnerId).get()
const partnerCoupleId = partnerUserDoc.data()?.coupleId const partnerData = partnerUserDoc.data()
const partnerCoupleId = partnerData?.coupleId
if (partnerCoupleId !== previousCoupleId) { if (partnerCoupleId !== previousCoupleId) {
console.log( logger.log(
`[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification` `[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification`
) )
return 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 claimOnce(notifMark(db, previousCoupleId, `leave-${userId}`)))) {
logger.log(`[onCoupleLeave] already notified partner of ${userId} leaving; skipping`)
return
}
const notificationPayload = { const notificationPayload = {
type: 'partner_left', type: 'partner_left',
title: 'Your partner has left', title: 'Your partner has left',
@ -73,34 +80,11 @@ export const onCoupleLeave = functions.firestore
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
// Collect the partner's FCM tokens (legacy field + fcmTokens subcollection). const res = await sendPushToUser(
const tokens: string[] = [] db,
if (partnerUserDoc.exists) { admin.messaging(),
const legacyToken = partnerUserDoc.data()?.fcmToken partnerId,
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(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`)
return
}
const fcmMessage: admin.messaging.Message = {
token: tokens[0],
notification: { notification: {
title: notificationPayload.title, title: notificationPayload.title,
body: notificationPayload.body, body: notificationPayload.body,
@ -109,26 +93,15 @@ export const onCoupleLeave = functions.firestore
data: { data: {
type: notificationPayload.type, type: notificationPayload.type,
}, },
} },
partnerData,
const sendResults = await Promise.allSettled(
tokens.map((token) => messaging.send({ ...fcmMessage, token }))
) )
if (res.sent === 0 && res.failed === 0) {
const failures: string[] = [] logger.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`)
sendResults.forEach((result, index) => { return
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) logger.log(
console.log(
`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}` `[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`
) )
}) })

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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. * 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. * only sends the push to both partners it never reads swipe content.
* *
* Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations * 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. * the marker itself is already de-duplicated by the client transaction + rules.
*/ */
export const notifyOnDateMatch = functions.firestore export const notifyOnDateMatch = onDocumentCreated(
.document('couples/{coupleId}/date_matches/{dateIdeaId}') 'couples/{coupleId}/date_matches/{dateIdeaId}',
.onCreate(async (snap, context) => { async (event) => {
if (!snap.exists) return const snap = event.data
if (!snap || !snap.exists) return
const { coupleId, dateIdeaId } = context.params as { const { coupleId, dateIdeaId } = event.params
coupleId: string
dateIdeaId: string
}
const db = admin.firestore() const db = admin.firestore()
const matchRef = snap.ref const matchRef = snap.ref
@ -41,8 +39,9 @@ export const notifyOnDateMatch = functions.firestore
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] 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( async function notifyDateMatch(
db: admin.firestore.Firestore, db: admin.firestore.Firestore,
@ -58,13 +57,7 @@ async function notifyDateMatch(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const tokens = await getUserTokens(db, userId) await sendPushToUser(db, admin.messaging(), userId, {
if (tokens.length === 0) return
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
token,
notification: { notification: {
title: "It's a match!", title: "It's a match!",
body: "You both want to go on this date. Time to make it happen.", body: "You both want to go on this date. Time to make it happen.",
@ -76,24 +69,4 @@ async function notifyDateMatch(
date_idea_id: dateIdeaId, date_idea_id: dateIdeaId,
}, },
}) })
)
)
await pruneDeadTokens(db, userId, tokens, results)
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string
): Promise<string[]> {
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)
})
return tokens
} }

View File

@ -1,17 +1,21 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentCreated } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' 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 * 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 reflectreveal loop * the OTHER partner (not the one who logged it) to add their reflection so the reflectreveal loop
* starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours. * starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours.
*/ */
export const onDateHistoryCreated = functions.firestore export const onDateHistoryCreated = onDocumentCreated(
.document('couples/{coupleId}/date_history/{dateId}') 'couples/{coupleId}/date_history/{dateId}',
.onCreate(async (snap, context) => { async (event) => {
const { coupleId, dateId } = context.params as { coupleId: string; dateId: string } const { coupleId, dateId } = event.params
const snap = event.data
if (!snap) return
const db = admin.firestore() const db = admin.firestore()
const addedBy = snap.data()?.addedBy as string | undefined const addedBy = snap.data()?.addedBy as string | undefined
@ -22,7 +26,14 @@ export const onDateHistoryCreated = functions.firestore
if (!partnerId) return if (!partnerId) return
const partnerUserDoc = await db.collection('users').doc(partnerId).get() 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 title = 'You went on a date 💜'
const body = 'Reflect on it together while its fresh.' const body = 'Reflect on it together while its fresh.'
@ -31,33 +42,18 @@ export const onDateHistoryCreated = functions.firestore
type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
if (recipientInQuietHours(partnerUserDoc.data())) return if (recipientInQuietHours(partnerData)) return
const tokens: string[] = [] await sendPushToUser(
const legacy = partnerUserDoc.data()?.fcmToken db,
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) admin.messaging(),
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() partnerId,
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 }, notification: { title, body },
data: { type: 'date_logged', couple_id: coupleId, date_id: dateId }, 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' } }, android: { notification: { channelId: 'partner_activity' } },
} as admin.messaging.Message) },
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)
})

View File

@ -1,7 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentUpdated } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' 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 * 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 * 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). * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/ */
export const onDateReflectionRevealed = functions.firestore export const onDateReflectionRevealed = onDocumentUpdated(
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') 'couples/{coupleId}/date_reflections/{dateId}/answers/{userId}',
.onUpdate(async (change, context) => { async (event) => {
const { coupleId, dateId, userId } = context.params as { const { coupleId, dateId, userId } = event.params
coupleId: string
dateId: string
userId: string
}
const before = change.before.data() as Partial<Record<string, unknown>> const before = (event.data?.before.data() ?? {}) as Partial<Record<string, unknown>>
const after = change.after.data() as Partial<Record<string, unknown>> const after = (event.data?.after.data() ?? {}) as Partial<Record<string, unknown>>
// Only on the false → true reveal transition. // Only on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true) return if (before.isRevealed === true || after.isRevealed !== true) return
@ -34,8 +32,15 @@ export const onDateReflectionRevealed = functions.firestore
if (!partnerId) return if (!partnerId) return
const partnerUserDoc = await db.collection('users').doc(partnerId).get() const partnerUserDoc = await db.collection('users').doc(partnerId).get()
if (partnerUserDoc.data()?.notifPartnerAnswered === false) { const partnerData = partnerUserDoc.data()
console.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`) 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 return
} }
@ -48,24 +53,18 @@ export const onDateReflectionRevealed = functions.firestore
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
if (recipientInQuietHours(partnerUserDoc.data())) { if (recipientInQuietHours(partnerData)) {
console.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`) logger.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`)
return return
} }
const senderAvatar = (await db.collection('users').doc(userId).get()).data()?.photoUrl const senderAvatar = (await db.collection('users').doc(userId).get()).data()?.photoUrl
const tokens: string[] = [] await sendPushToUser(
const legacy = partnerUserDoc.data()?.fcmToken db,
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) admin.messaging(),
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() partnerId,
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 }, notification: { title, body },
data: { data: {
type, type,
@ -75,18 +74,9 @@ export const onDateReflectionRevealed = functions.firestore
? { sender_avatar_url: senderAvatar } ? { sender_avatar_url: senderAvatar }
: {}), : {}),
}, },
}
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
...payload,
token,
android: { notification: { channelId: 'partner_activity' } }, 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)
})

View File

@ -1,7 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentCreated } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' 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 * 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 * 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). * `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/ */
export const onDateReflectionWritten = functions.firestore export const onDateReflectionWritten = onDocumentCreated(
.document('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}') 'couples/{coupleId}/date_reflections/{dateId}/answers/{userId}',
.onCreate(async (_snap, context) => { async (event) => {
const { coupleId, dateId, userId } = context.params as { const { coupleId, dateId, userId } = event.params
coupleId: string
dateId: string
userId: string
}
const db = admin.firestore() const db = admin.firestore()
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
@ -29,9 +27,16 @@ export const onDateReflectionWritten = functions.firestore
if (!partnerId) return if (!partnerId) return
const partnerUserDoc = await db.collection('users').doc(partnerId).get() const partnerUserDoc = await db.collection('users').doc(partnerId).get()
const partnerData = partnerUserDoc.data()
// Partner-activity opt-out (default on). // Partner-activity opt-out (default on).
if (partnerUserDoc.data()?.notifPartnerAnswered === false) { if (partnerData?.notifPartnerAnswered === false) {
console.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`) 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 return
} }
@ -52,24 +57,18 @@ export const onDateReflectionWritten = functions.firestore
}) })
// Quiet hours: keep the in-app record, suppress the disruptive push. // Quiet hours: keep the in-app record, suppress the disruptive push.
if (recipientInQuietHours(partnerUserDoc.data())) { if (recipientInQuietHours(partnerData)) {
console.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`) logger.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`)
return return
} }
const senderAvatar = (await db.collection('users').doc(userId).get()).data()?.photoUrl const senderAvatar = (await db.collection('users').doc(userId).get()).data()?.photoUrl
const tokens: string[] = [] await sendPushToUser(
const legacy = partnerUserDoc.data()?.fcmToken db,
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) admin.messaging(),
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() partnerId,
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 }, notification: { title, body },
data: { data: {
type, type,
@ -79,18 +78,9 @@ export const onDateReflectionWritten = functions.firestore
? { sender_avatar_url: senderAvatar } ? { sender_avatar_url: senderAvatar }
: {}), : {}),
}, },
}
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
...payload,
token,
android: { notification: { channelId: 'partner_activity' } }, // E-OBS 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)
})

View File

@ -1,7 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentWritten } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' 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. * Firestore trigger that notifies partners when a game session is created or completed.
@ -9,15 +10,18 @@ import { pruneDeadTokens } from '../notifications/pruneTokens'
* Path: couples/{coupleId}/sessions/{sessionId} * Path: couples/{coupleId}/sessions/{sessionId}
* Condition: onWrite (create, update, delete) * Condition: onWrite (create, update, delete)
*/ */
export const onGameSessionUpdate = functions.firestore export const onGameSessionUpdate = onDocumentWritten(
.document('couples/{coupleId}/sessions/{sessionId}') 'couples/{coupleId}/sessions/{sessionId}',
.onWrite(async (change, context) => { async (event) => {
const { coupleId, sessionId } = context.params as { coupleId: string; sessionId: string } const { coupleId, sessionId } = event.params
// The per-couple active-session lock lives at sessions/_active — it is a pointer, not a // 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. // game session, so it must never produce a partner notification.
if (sessionId === '_active') return if (sessionId === '_active') return
const change = event.data
if (!change) return
const db = admin.firestore() const db = admin.firestore()
const messaging = admin.messaging() 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 sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get()
const session = sessionDoc.data() const session = sessionDoc.data()
if (!session) { if (!session) {
console.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`) logger.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`)
return return
} }
// Get couple info // Get couple info
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onGameSessionUpdate] couple ${coupleId} not found`) logger.warn(`[onGameSessionUpdate] couple ${coupleId} not found`)
return return
} }
const coupleData = coupleDoc.data() ?? {} const coupleData = coupleDoc.data() ?? {}
const userIds = (coupleData.userIds ?? []) as string[] const userIds = (coupleData.userIds ?? []) as string[]
if (userIds.length !== 2) { 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 return
} }
@ -86,7 +90,7 @@ export const onGameSessionUpdate = functions.firestore
const starterName = startedBy === partnerA ? partnerAName : partnerBName const starterName = startedBy === partnerA ? partnerAName : partnerBName
const starterAvatar = startedBy === partnerA ? avatarA : avatarB const starterAvatar = startedBy === partnerA ? avatarA : avatarB
if (recipientInQuietHours(dataFor(recipientId))) { 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 { } else {
await notifyPartner( await notifyPartner(
db, messaging, recipientId, starterName, gameType, db, messaging, recipientId, starterName, gameType,
@ -120,7 +124,7 @@ export const onGameSessionUpdate = functions.firestore
const joinerName = joiner === partnerA ? partnerAName : partnerBName const joinerName = joiner === partnerA ? partnerAName : partnerBName
const joinerAvatar = joiner === partnerA ? avatarA : avatarB const joinerAvatar = joiner === partnerA ? avatarA : avatarB
if (recipientInQuietHours(dataFor(startedBy))) { 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 { } else {
await notifyPartner( await notifyPartner(
db, messaging, startedBy, joinerName, gameType, db, messaging, startedBy, joinerName, gameType,
@ -151,7 +155,7 @@ export const onGameSessionUpdate = functions.firestore
const gt = currentData.gameType ?? 'wheel' const gt = currentData.gameType ?? 'wheel'
// Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours. // Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours.
if (recipientInQuietHours(dataFor(partnerA))) { 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 { } else {
await notifyPartner( await notifyPartner(
db, messaging, partnerA, partnerBName, gt, db, messaging, partnerA, partnerBName, gt,
@ -160,7 +164,7 @@ export const onGameSessionUpdate = functions.firestore
) )
} }
if (recipientInQuietHours(dataFor(partnerB))) { 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 { } else {
await notifyPartner( await notifyPartner(
db, messaging, partnerB, partnerAName, gt, db, messaging, partnerB, partnerAName, gt,
@ -171,7 +175,8 @@ export const onGameSessionUpdate = functions.firestore
} }
return return
} }
}) }
)
/** /**
* Notify the WAITING partner the moment the FIRST player finishes their part of an async game. * 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). * 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'] const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync']
export const onGamePartFinished = functions.firestore export const onGamePartFinished = onDocumentWritten(
.document('couples/{coupleId}/{gameType}/{sessionId}') 'couples/{coupleId}/{gameType}/{sessionId}',
.onWrite(async (change, context) => { async (event) => {
const { coupleId, gameType, sessionId } = const { coupleId, gameType, sessionId } = event.params
context.params as { coupleId: string; gameType: string; sessionId: string }
if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return // ignore messages/reactions/etc. 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<string, unknown> const answers = (change.after.data()?.answers ?? {}) as Record<string, unknown>
const answerUids = Object.keys(answers) const answerUids = Object.keys(answers)
@ -231,7 +236,8 @@ export const onGamePartFinished = functions.firestore
'partner_completed_part', yourTurnBody(gameType), coupleId, 'partner_completed_part', yourTurnBody(gameType), coupleId,
finisherAvatar, sessionId finisherAvatar, sessionId
) )
}) }
)
/** /**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's * 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' : notificationType === 'partner_joined_game'
? `${partnerName} joined your game` ? `${partnerName} joined your game`
: `${partnerName} is playing` : `${partnerName} is playing`
const notificationPayload = {
type: notificationType,
title,
body: body,
}
// Write an in-app notification record for the partner // Write an in-app notification record for the partner
await db await db
@ -281,50 +282,20 @@ async function notifyPartner(
.doc(partnerId) .doc(partnerId)
.collection('notification_queue') .collection('notification_queue')
.add({ .add({
...notificationPayload, type: notificationType,
title,
body,
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
// Collect the partner's FCM tokens await sendPushToUser(db, messaging, partnerId, {
const tokens: string[] = [] notification: { title, body },
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,
},
// Put backgrounded notifications on the Games channel instead of the FCM fallback channel, // Put backgrounded notifications on the Games channel instead of the FCM fallback channel,
// so importance/sound and the per-category toggle apply. E-OBS. // so importance/sound and the per-category toggle apply. E-OBS.
android: { notification: { channelId: 'game_activity' } }, android: { notification: { channelId: 'game_activity' } },
data: { data: {
type: notificationPayload.type, type: notificationType,
couple_id: coupleId, couple_id: coupleId,
game_type: gameType, game_type: gameType,
// The acting partner's display name (public; also in the title) so the in-app foreground // 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 } ? { 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)
} }

View File

@ -0,0 +1,20 @@
import { claimOnce } from './idempotency'
function markRef(create: () => Promise<unknown>) {
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)
})
})

View File

@ -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<boolean> {
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)
}

View File

@ -1,7 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentUpdated } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' 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 * 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} * Path: couples/{coupleId}/daily_question/{date}/answers/{userId}
*/ */
export const onAnswerRevealed = functions.firestore export const onAnswerRevealed = onDocumentUpdated(
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}') 'couples/{coupleId}/daily_question/{date}/answers/{userId}',
.onUpdate(async (change, context) => { async (event) => {
const { coupleId, date, userId } = context.params as { const { coupleId, date, userId } = event.params
coupleId: string
date: string
userId: string
}
const before = change.before.data() as Partial<Record<string, unknown>> const before = (event.data?.before.data() ?? {}) as Partial<Record<string, unknown>>
const after = change.after.data() as Partial<Record<string, unknown>> const after = (event.data?.after.data() ?? {}) as Partial<Record<string, unknown>>
// Only fire on the false → true reveal transition. // Only fire on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true) return 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() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onAnswerRevealed] couple ${coupleId} not found`) logger.warn(`[onAnswerRevealed] couple ${coupleId} not found`)
return return
} }
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(userId)) { 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 return
} }
const partnerId = userIds.find((uid) => uid !== userId) const partnerId = userIds.find((uid) => uid !== userId)
if (!partnerId) { if (!partnerId) {
console.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`) logger.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`)
return return
} }
// Partner FCM tokens (legacy field + multi-device subcollection).
const tokens: string[] = []
const partnerUserDoc = await db.collection('users').doc(partnerId).get() const partnerUserDoc = await db.collection('users').doc(partnerId).get()
if (partnerUserDoc.exists) { const partnerData = partnerUserDoc.data()
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
}
// Respect the same partner-activity opt-out as the answered ping. // Respect the same partner-activity opt-out as the answered ping.
if (partnerUserDoc.data()?.notifPartnerAnswered === false) { if (partnerData?.notifPartnerAnswered === false) {
console.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`) logger.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`)
return return
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if (recipientInQuietHours(partnerUserDoc.data())) { if (recipientInQuietHours(partnerData)) {
console.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`) 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 return
} }
@ -78,7 +69,11 @@ export const onAnswerRevealed = functions.firestore
const revealerDoc = await db.collection('users').doc(userId).get() const revealerDoc = await db.collection('users').doc(userId).get()
const revealerAvatar = revealerDoc.data()?.photoUrl const revealerAvatar = revealerDoc.data()?.photoUrl
const payload: admin.messaging.MessagingPayload = { const res = await sendPushToUser(
db,
admin.messaging(),
partnerId,
{
notification: { notification: {
title: 'Your partner opened your answers', title: 'Your partner opened your answers',
body: 'Open to see what you each said.', body: 'Open to see what you each said.',
@ -92,23 +87,15 @@ export const onAnswerRevealed = functions.firestore
? { sender_avatar_url: revealerAvatar } ? { 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( logger.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`)
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}`)
})

View File

@ -1,7 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentCreated } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' 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 * 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 * the answer reveal screen. It also contains a `notification` block for
* system-tray display when the app is in the background. * system-tray display when the app is in the background.
*/ */
export const onAnswerWritten = functions.firestore export const onAnswerWritten = onDocumentCreated(
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}') 'couples/{coupleId}/daily_question/{date}/answers/{userId}',
.onCreate(async (snap, context) => { async (event) => {
const { coupleId, date, userId } = context.params as { const { coupleId, date, userId } = event.params
coupleId: string const snap = event.data
date: string if (!snap) return
userId: string
}
const db = admin.firestore() const db = admin.firestore()
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onAnswerWritten] couple ${coupleId} not found`) logger.warn(`[onAnswerWritten] couple ${coupleId} not found`)
return return
} }
@ -35,54 +35,35 @@ export const onAnswerWritten = functions.firestore
// before sending a cross-user notification. Firestore rules already enforce this, // 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. // but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping.
if (!userIds.includes(userId)) { 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 return
} }
const partnerId = userIds.find((uid) => uid !== userId) const partnerId = userIds.find((uid) => uid !== userId)
if (!partnerId) { if (!partnerId) {
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`) logger.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`)
return 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() const partnerUserDoc = await db.collection('users').doc(partnerId).get()
if (partnerUserDoc.exists) { const partnerData = partnerUserDoc.data()
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
}
// Respect the partner's notification preference (opt-out; default is enabled). // Respect the partner's notification preference (opt-out; default is enabled).
const notifEnabled = partnerUserDoc.data()?.notifPartnerAnswered if (partnerData?.notifPartnerAnswered === false) {
if (notifEnabled === false) { logger.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`)
console.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`)
return return
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if (recipientInQuietHours(partnerUserDoc.data())) { if (recipientInQuietHours(partnerData)) {
console.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`) 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 return
} }
@ -101,7 +82,11 @@ export const onAnswerWritten = functions.firestore
.collection('answers').doc(partnerId).get() .collection('answers').doc(partnerId).get()
const bothAnswered = partnerAnswerSnap.exists const bothAnswered = partnerAnswerSnap.exists
const payload: admin.messaging.MessagingPayload = { const res = await sendPushToUser(
db,
admin.messaging(),
partnerId,
{
notification: bothAnswered notification: bothAnswered
? { ? {
title: 'Your answers are unlocked ✨', title: 'Your answers are unlocked ✨',
@ -120,37 +105,22 @@ export const onAnswerWritten = functions.firestore
? { sender_avatar_url: senderAvatar } ? { sender_avatar_url: senderAvatar }
: {}), : {}),
}, },
}
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
...payload,
token,
android: { notification: { channelId: 'partner_activity' } }, // E-OBS android: { notification: { channelId: 'partner_activity' } }, // E-OBS
} as admin.messaging.Message) },
partnerData,
) )
) if (res.sent === 0 && res.failed === 0) {
logger.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`)
const failures: string[] = [] return
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)
}
await pruneDeadTokens(db, partnerId, tokens, sendResults)
// Track last activity time on the couple doc for re-engagement targeting. // Track last activity time on the couple doc for re-engagement targeting.
await db.collection('couples').doc(coupleId).update({ await db.collection('couples').doc(coupleId).update({
lastAnsweredAt: admin.firestore.FieldValue.serverTimestamp(), 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}` `[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`
) )
}) }
)

View File

@ -1,7 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onDocumentCreated } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours' 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 * 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). * Respects the recipient's `notifChatMessage` preference (default: enabled).
*/ */
export const onMessageWritten = functions.firestore export const onMessageWritten = onDocumentCreated(
.document('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}') 'couples/{coupleId}/conversations/{conversationId}/messages/{messageId}',
.onCreate(async (snap, context) => { async (event) => {
const { coupleId, conversationId, messageId } = context.params as { const { coupleId, conversationId, messageId } = event.params
coupleId: string const snap = event.data
conversationId: string if (!snap) return
messageId: string
}
const db = admin.firestore() const db = admin.firestore()
const messageData = snap.data() as Partial<Record<string, unknown>> const messageData = snap.data() as Partial<Record<string, unknown>>
const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null
if (!authorId) { if (!authorId) {
console.warn(`[onMessageWritten] no authorUserId on message ${messageId}`) logger.warn(`[onMessageWritten] no authorUserId on message ${messageId}`)
return return
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
console.warn(`[onMessageWritten] couple ${coupleId} not found`) logger.warn(`[onMessageWritten] couple ${coupleId} not found`)
return return
} }
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((uid) => uid !== authorId) const partnerId = userIds.find((uid) => uid !== authorId)
if (!partnerId) { if (!partnerId) {
console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`) logger.warn(`[onMessageWritten] no partner found for couple ${coupleId}`)
return return
} }
const partnerUserDoc = await db.collection('users').doc(partnerId).get() const partnerUserDoc = await db.collection('users').doc(partnerId).get()
const partnerData = partnerUserDoc.data()
// Respect the partner's notification preference (opt-out; default is enabled). // Respect the partner's notification preference (opt-out; default is enabled).
const notifEnabled = partnerUserDoc.data()?.notifChatMessage if (partnerData?.notifChatMessage === false) {
if (notifEnabled === false) { logger.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`)
console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`)
return return
} }
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open. // M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if (recipientInQuietHours(partnerUserDoc.data())) { if (recipientInQuietHours(partnerData)) {
console.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`) logger.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`)
return return
} }
const tokens: string[] = [] // Dedupe redelivery of this message create (at-least-once). messageId is unique per message.
if (partnerUserDoc.exists) { if (!(await claimOnce(notifMark(db, coupleId, `msg-${messageId}`)))) {
const legacyToken = partnerUserDoc.data()?.fcmToken logger.log(`[onMessageWritten] already notified for message ${messageId}; skipping`)
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}`)
return return
} }
@ -86,7 +67,11 @@ export const onMessageWritten = functions.firestore
const authorDoc = await db.collection('users').doc(authorId).get() const authorDoc = await db.collection('users').doc(authorId).get()
const authorPhotoUrl = (authorDoc.data()?.photoUrl as string | undefined) ?? '' const authorPhotoUrl = (authorDoc.data()?.photoUrl as string | undefined) ?? ''
const payload: admin.messaging.MessagingPayload = { const res = await sendPushToUser(
db,
admin.messaging(),
partnerId,
{
notification: { notification: {
title: 'Your partner sent a message', title: 'Your partner sent a message',
body: 'Tap to read and reply.', body: 'Tap to read and reply.',
@ -97,33 +82,18 @@ export const onMessageWritten = functions.firestore
conversation_id: conversationId, conversation_id: conversationId,
...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}), ...(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. // E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel.
android: { notification: { channelId: 'partner_activity' } }, android: { notification: { channelId: 'partner_activity' } },
} as admin.messaging.Message) },
partnerData,
) )
) if (res.sent === 0 && res.failed === 0) {
logger.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`)
const failures: string[] = [] return
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)
} }
await pruneDeadTokens(db, partnerId, tokens, sendResults) logger.log(
console.log(
`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}` `[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`
) )
}) }
)