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:
parent
fbf4e7957c
commit
4040abbf28
|
|
@ -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,23 +103,28 @@ 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 {
|
||||||
await queueAndPush(db, partnerId, {
|
const now = Date.now();
|
||||||
type: 'restore_requested',
|
if (selfAlertAllowed((_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.lastRestorePartnerAlertAt, now)) {
|
||||||
title: 'Help your partner restore 💜',
|
await coupleRef.set({ lastRestorePartnerAlertAt: now }, { merge: true });
|
||||||
body: 'They’re setting up on a new device. Tap to help.',
|
await queueAndPush(db, partnerId, {
|
||||||
coupleId,
|
type: 'restore_requested',
|
||||||
});
|
title: 'Help your partner restore 💜',
|
||||||
|
body: 'They’re setting up on a new device. Tap to help.',
|
||||||
|
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 REQUESTED→READY transition (ignores decline/expire).
|
* signal for the real owner. Guarded to the single REQUESTED→READY 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
|
|
@ -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
|
||||||
|
|
@ -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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"}
|
||||||
|
|
@ -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)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.error(`[onCoupleLeave] some notifications failed:`, failures);
|
|
||||||
}
|
}
|
||||||
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults);
|
log_1.logger.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`);
|
||||||
console.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`);
|
|
||||||
});
|
});
|
||||||
//# sourceMappingURL=onCoupleLeave.js.map
|
//# sourceMappingURL=onCoupleLeave.js.map
|
||||||
|
|
@ -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":"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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":"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"}
|
{"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"}
|
||||||
|
|
@ -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 reflect→reveal loop
|
* the OTHER partner (not the one who logged it) to add their reflection — so the reflect→reveal loop
|
||||||
* starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours.
|
* 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 it’s fresh.';
|
const body = 'Reflect on it together while it’s 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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,4DAAmE;AACnE,gDAAsD;AACtD,8DAAmE;AACnE,gCAA+B;AAE/B;;;;;;GAMG;AACU,QAAA,wBAAwB,GAAG,IAAA,6BAAiB,EACvD,+DAA+D,EAC/D,KAAK,EAAE,KAAK,EAAE,EAAE;;IACd,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IAEjD,MAAM,MAAM,GAAG,CAAC,MAAA,MAAA,KAAK,CAAC,IAAI,0CAAE,MAAM,CAAC,IAAI,EAAE,mCAAI,EAAE,CAAqC,CAAA;IACpF,MAAM,KAAK,GAAG,CAAC,MAAA,MAAA,KAAK,CAAC,IAAI,0CAAE,KAAK,CAAC,IAAI,EAAE,mCAAI,EAAE,CAAqC,CAAA;IAClF,8CAA8C;IAC9C,IAAI,MAAM,CAAC,UAAU,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI;QAAE,OAAM;IAEnE,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,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,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAM;IACrC,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,CAAA;IACnD,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,EAAE,CAAC;QAChD,YAAM,CAAC,GAAG,CAAC,8BAA8B,SAAS,gCAAgC,CAAC,CAAA;QACnF,OAAM;IACR,CAAC;IAED,iFAAiF;IACjF,IAAI,CAAC,CAAC,MAAM,IAAA,uBAAS,EAAC,IAAA,uBAAS,EAAC,EAAE,EAAE,QAAQ,EAAE,eAAe,MAAM,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACnF,YAAM,CAAC,GAAG,CAAC,mDAAmD,MAAM,OAAO,MAAM,YAAY,CAAC,CAAA;QAC9F,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,uCAAuC,CAAA;IACrD,MAAM,IAAI,GAAG,kCAAkC,CAAA;IAC/C,MAAM,IAAI,GAAG,wBAAwB,CAAA;IAErC,uEAAuE;IACvE,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxF,CAAC,CAAA;IAEF,IAAI,IAAA,kCAAqB,EAAC,WAAW,CAAC,EAAE,CAAC;QACvC,YAAM,CAAC,GAAG,CAAC,8BAA8B,SAAS,iDAAiD,CAAC,CAAA;QACpG,OAAM;IACR,CAAC;IAED,MAAM,YAAY,GAAG,MAAA,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAEtF,MAAM,IAAA,qBAAc,EAClB,EAAE,EACF,KAAK,CAAC,SAAS,EAAE,EACjB,SAAS,EACT;QACE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,kBACF,IAAI,EACJ,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,MAAM,IACZ,CAAC,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC;YAC7D,CAAC,CAAC,EAAE,iBAAiB,EAAE,YAAY,EAAE;YACrC,CAAC,CAAC,EAAE,CAAC,CACR;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE;KAC7D,EACD,WAAW,CACZ,CAAA;AACH,CAAC,CACF,CAAA"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,4DAAmE;AACnE,8DAA8D;AAE9D;;;;;;;GAOG;AACU,QAAA,uBAAuB,GAAG,SAAS,CAAC,SAAS;KACvD,QAAQ,CAAC,+DAA+D,CAAC;KACzE,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACjC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAI5C,CAAA;IACD,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,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,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAM;IACrC,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,CAAA;IACnD,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,yCAAyC;IACzC,IAAI,CAAA,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,oBAAoB,MAAK,KAAK,EAAE,CAAC;QAC1D,OAAO,CAAC,GAAG,CAAC,6BAA6B,SAAS,gCAAgC,CAAC,CAAA;QACnF,OAAM;IACR,CAAC;IAED,gGAAgG;IAChG,MAAM,iBAAiB,GAAG,MAAM,EAAE;SAC/B,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;SACnC,UAAU,CAAC,kBAAkB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC;SAC1C,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IAC7C,MAAM,aAAa,GAAG,iBAAiB,CAAC,MAAM,CAAA;IAE9C,MAAM,KAAK,GAAG,aAAa,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAC,CAAC,wCAAwC,CAAA;IAC5G,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,+BAA+B,CAAC,CAAC,CAAC,+BAA+B,CAAA;IAC9F,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,yBAAyB,CAAA;IAEhF,uEAAuE;IACvE,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxF,CAAC,CAAA;IAEF,qEAAqE;IACrE,IAAI,IAAA,kCAAqB,EAAC,cAAc,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACjD,OAAO,CAAC,GAAG,CAAC,6BAA6B,SAAS,iDAAiD,CAAC,CAAA;QACpG,OAAM;IACR,CAAC;IAED,MAAM,YAAY,GAAG,MAAA,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAEtF,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,MAAM,GAAG,MAAA,cAAc,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAC9C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxE,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3F,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;;QAC3B,MAAM,CAAC,GAAG,MAAA,CAAC,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IACF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/B,MAAM,OAAO,GAAqC;QAChD,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,kBACF,IAAI,EACJ,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,MAAM,IACZ,CAAC,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC;YAC7D,CAAC,CAAC,EAAE,iBAAiB,EAAE,YAAY,EAAE;YACrC,CAAC,CAAC,EAAE,CAAC,CACR;KACF,CAAA;IACD,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,gCAClB,OAAO,KACV,KAAK,EACL,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,GAClC,CAAC,CAC9B,CACF,CAAA;IACD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU;YAAE,OAAO,CAAC,IAAI,CAAC,4CAA4C,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;IAC/G,CAAC,CAAC,CAAA;IACF,MAAM,IAAA,6BAAe,EAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;AACvD,CAAC,CAAC,CAAA"}
|
{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,4DAAmE;AACnE,gDAAsD;AACtD,8DAAmE;AACnE,gCAA+B;AAE/B;;;;;;;GAOG;AACU,QAAA,uBAAuB,GAAG,IAAA,6BAAiB,EACtD,+DAA+D,EAC/D,KAAK,EAAE,KAAK,EAAE,EAAE;;IACd,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IACjD,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,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,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAM;IACrC,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,CAAA;IACnD,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,yCAAyC;IACzC,IAAI,CAAA,WAAW,aAAX,WAAW,uBAAX,WAAW,CAAE,oBAAoB,MAAK,KAAK,EAAE,CAAC;QAChD,YAAM,CAAC,GAAG,CAAC,6BAA6B,SAAS,gCAAgC,CAAC,CAAA;QAClF,OAAM;IACR,CAAC;IAED,iFAAiF;IACjF,IAAI,CAAC,CAAC,MAAM,IAAA,uBAAS,EAAC,IAAA,uBAAS,EAAC,EAAE,EAAE,QAAQ,EAAE,gBAAgB,MAAM,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACpF,YAAM,CAAC,GAAG,CAAC,kDAAkD,MAAM,OAAO,MAAM,YAAY,CAAC,CAAA;QAC7F,OAAM;IACR,CAAC;IAED,gGAAgG;IAChG,MAAM,iBAAiB,GAAG,MAAM,EAAE;SAC/B,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;SACnC,UAAU,CAAC,kBAAkB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC;SAC1C,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IAC7C,MAAM,aAAa,GAAG,iBAAiB,CAAC,MAAM,CAAA;IAE9C,MAAM,KAAK,GAAG,aAAa,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAC,CAAC,wCAAwC,CAAA;IAC5G,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,+BAA+B,CAAC,CAAC,CAAC,+BAA+B,CAAA;IAC9F,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,yBAAyB,CAAA;IAEhF,uEAAuE;IACvE,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxF,CAAC,CAAA;IAEF,qEAAqE;IACrE,IAAI,IAAA,kCAAqB,EAAC,WAAW,CAAC,EAAE,CAAC;QACvC,YAAM,CAAC,GAAG,CAAC,6BAA6B,SAAS,iDAAiD,CAAC,CAAA;QACnG,OAAM;IACR,CAAC;IAED,MAAM,YAAY,GAAG,MAAA,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAEtF,MAAM,IAAA,qBAAc,EAClB,EAAE,EACF,KAAK,CAAC,SAAS,EAAE,EACjB,SAAS,EACT;QACE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,kBACF,IAAI,EACJ,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,MAAM,IACZ,CAAC,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC;YAC7D,CAAC,CAAC,EAAE,iBAAiB,EAAE,YAAY,EAAE;YACrC,CAAC,CAAC,EAAE,CAAC,CACR;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,QAAQ;KACvE,EACD,WAAW,CACZ,CAAA;AACH,CAAC,CACF,CAAA"}
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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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"}
|
||||||
|
|
@ -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;
|
||||||
}
|
|
||||||
});
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.error(`[onMessageWritten] some notifications failed:`, failures);
|
|
||||||
}
|
}
|
||||||
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults);
|
log_1.logger.log(`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`);
|
||||||
console.log(`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`);
|
|
||||||
});
|
});
|
||||||
//# sourceMappingURL=onMessageWritten.js.map
|
//# sourceMappingURL=onMessageWritten.js.map
|
||||||
|
|
@ -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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"}
|
||||||
|
|
@ -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 {
|
||||||
await queueAndPush(db, partnerId, {
|
const now = Date.now()
|
||||||
type: 'restore_requested',
|
if (selfAlertAllowed(coupleDoc.data()?.lastRestorePartnerAlertAt, now)) {
|
||||||
title: 'Help your partner restore 💜',
|
await coupleRef.set({ lastRestorePartnerAlertAt: now }, { merge: true })
|
||||||
body: 'They’re setting up on a new device. Tap to help.',
|
await queueAndPush(db, partnerId, {
|
||||||
coupleId,
|
type: 'restore_requested',
|
||||||
})
|
title: 'Help your partner restore 💜',
|
||||||
|
body: 'They’re setting up on a new device. Tap to help.',
|
||||||
|
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 REQUESTED→READY transition (ignores decline/expire).
|
* signal for the real owner. Guarded to the single REQUESTED→READY 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) })
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
}
|
|
||||||
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)
|
if (res.sent === 0 && res.failed === 0) {
|
||||||
console.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`)
|
logger.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`)
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
logger.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,98 +11,80 @@ 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
|
||||||
|
|
||||||
// Only act when coupleId transitions from a real value to null/empty.
|
// Only act when coupleId transitions from a real value to null/empty.
|
||||||
if (!previousCoupleId || typeof previousCoupleId !== 'string') {
|
if (!previousCoupleId || typeof previousCoupleId !== 'string') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (currentCoupleId) {
|
if (currentCoupleId) {
|
||||||
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`)
|
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()
|
||||||
if (partnerCoupleId !== previousCoupleId) {
|
const partnerCoupleId = partnerData?.coupleId
|
||||||
console.log(
|
if (partnerCoupleId !== previousCoupleId) {
|
||||||
`[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification`
|
logger.log(
|
||||||
)
|
`[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification`
|
||||||
return
|
)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const notificationPayload = {
|
// Dedupe redelivery of this leave (at-least-once) so the partner isn't double-notified and the
|
||||||
type: 'partner_left',
|
// in-app record isn't duplicated.
|
||||||
title: 'Your partner has left',
|
if (!(await claimOnce(notifMark(db, previousCoupleId, `leave-${userId}`)))) {
|
||||||
body: 'You are no longer paired. Tap to create a new invite.',
|
logger.log(`[onCoupleLeave] already notified partner of ${userId} leaving; skipping`)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Write an in-app notification record for the partner.
|
const notificationPayload = {
|
||||||
// This is read-only denied for clients; the Cloud Function writes it.
|
type: 'partner_left',
|
||||||
await db
|
title: 'Your partner has left',
|
||||||
.collection('users')
|
body: 'You are no longer paired. Tap to create a new invite.',
|
||||||
.doc(partnerId)
|
}
|
||||||
.collection('notification_queue')
|
|
||||||
.add({
|
|
||||||
...notificationPayload,
|
|
||||||
read: false,
|
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Collect the partner's FCM tokens (legacy field + fcmTokens subcollection).
|
// Write an in-app notification record for the partner.
|
||||||
const tokens: string[] = []
|
// This is read-only denied for clients; the Cloud Function writes it.
|
||||||
if (partnerUserDoc.exists) {
|
await db
|
||||||
const legacyToken = partnerUserDoc.data()?.fcmToken
|
.collection('users')
|
||||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
.doc(partnerId)
|
||||||
tokens.push(legacyToken)
|
.collection('notification_queue')
|
||||||
}
|
.add({
|
||||||
}
|
...notificationPayload,
|
||||||
|
read: false,
|
||||||
const tokenSnapshot = await db
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
.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) {
|
const res = await sendPushToUser(
|
||||||
console.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`)
|
db,
|
||||||
return
|
admin.messaging(),
|
||||||
}
|
partnerId,
|
||||||
|
{
|
||||||
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,
|
||||||
|
)
|
||||||
|
if (res.sent === 0 && res.failed === 0) {
|
||||||
|
logger.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const sendResults = await Promise.allSettled(
|
logger.log(
|
||||||
tokens.map((token) => messaging.send({ ...fcmMessage, token }))
|
`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`
|
||||||
)
|
)
|
||||||
|
})
|
||||||
const failures: string[] = []
|
|
||||||
sendResults.forEach((result, index) => {
|
|
||||||
if (result.status === 'rejected') {
|
|
||||||
failures.push(`${tokens[index]}: ${String(result.reason)}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.error(`[onCoupleLeave] some notifications failed:`, failures)
|
|
||||||
}
|
|
||||||
|
|
||||||
await pruneDeadTokens(db, partnerId, tokens, sendResults)
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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,42 +57,16 @@ 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
|
notification: {
|
||||||
|
title: "It's a match!",
|
||||||
const results = await Promise.allSettled(
|
body: "You both want to go on this date. Time to make it happen.",
|
||||||
tokens.map((token) =>
|
},
|
||||||
admin.messaging().send({
|
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
||||||
token,
|
data: {
|
||||||
notification: {
|
type: 'date_match',
|
||||||
title: "It's a match!",
|
couple_id: coupleId,
|
||||||
body: "You both want to go on this date. Time to make it happen.",
|
date_idea_id: dateIdeaId,
|
||||||
},
|
},
|
||||||
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
|
||||||
data: {
|
|
||||||
type: 'date_match',
|
|
||||||
couple_id: coupleId,
|
|
||||||
date_idea_id: dateIdeaId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await pruneDeadTokens(db, userId, tokens, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUserTokens(
|
|
||||||
db: admin.firestore.Firestore,
|
|
||||||
userId: string
|
|
||||||
): Promise<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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 reflect→reveal loop
|
* the OTHER partner (not the one who logged it) to add their reflection — so the reflect→reveal loop
|
||||||
* starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours.
|
* 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 it’s fresh.'
|
const body = 'Reflect on it together while it’s fresh.'
|
||||||
|
|
@ -31,33 +42,18 @@ export const onDateHistoryCreated = functions.firestore
|
||||||
type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
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
|
notification: { title, body },
|
||||||
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
|
data: { type: 'date_logged', couple_id: coupleId, date_id: dateId },
|
||||||
})
|
android: { notification: { channelId: 'partner_activity' } },
|
||||||
if (tokens.length === 0) return
|
},
|
||||||
|
partnerData,
|
||||||
const payload: admin.messaging.MessagingPayload = {
|
|
||||||
notification: { title, body },
|
|
||||||
data: { type: 'date_logged', couple_id: coupleId, date_id: dateId },
|
|
||||||
}
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
tokens.map((token) =>
|
|
||||||
admin.messaging().send({
|
|
||||||
...payload,
|
|
||||||
token,
|
|
||||||
android: { notification: { channelId: 'partner_activity' } },
|
|
||||||
} as admin.messaging.Message)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
results.forEach((r, i) => {
|
}
|
||||||
if (r.status === 'rejected') console.warn(`[onDateHistoryCreated] FCM failed for ${tokens[i]}:`, r.reason)
|
)
|
||||||
})
|
|
||||||
await pruneDeadTokens(db, partnerId, tokens, results)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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,45 +53,30 @@ 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
|
notification: { title, body },
|
||||||
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
|
data: {
|
||||||
})
|
type,
|
||||||
if (tokens.length === 0) return
|
couple_id: coupleId,
|
||||||
|
date_id: dateId,
|
||||||
const payload: admin.messaging.MessagingPayload = {
|
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
|
||||||
notification: { title, body },
|
? { sender_avatar_url: senderAvatar }
|
||||||
data: {
|
: {}),
|
||||||
type,
|
},
|
||||||
couple_id: coupleId,
|
android: { notification: { channelId: 'partner_activity' } },
|
||||||
date_id: dateId,
|
|
||||||
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
|
|
||||||
? { sender_avatar_url: senderAvatar }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
}
|
partnerData,
|
||||||
const results = await Promise.allSettled(
|
|
||||||
tokens.map((token) =>
|
|
||||||
admin.messaging().send({
|
|
||||||
...payload,
|
|
||||||
token,
|
|
||||||
android: { notification: { channelId: 'partner_activity' } },
|
|
||||||
} as admin.messaging.Message)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
results.forEach((r, i) => {
|
}
|
||||||
if (r.status === 'rejected') console.warn(`[onDateReflectionRevealed] FCM failed for ${tokens[i]}:`, r.reason)
|
)
|
||||||
})
|
|
||||||
await pruneDeadTokens(db, partnerId, tokens, results)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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,45 +57,30 @@ 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
|
notification: { title, body },
|
||||||
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
|
data: {
|
||||||
})
|
type,
|
||||||
if (tokens.length === 0) return
|
couple_id: coupleId,
|
||||||
|
date_id: dateId,
|
||||||
const payload: admin.messaging.MessagingPayload = {
|
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
|
||||||
notification: { title, body },
|
? { sender_avatar_url: senderAvatar }
|
||||||
data: {
|
: {}),
|
||||||
type,
|
},
|
||||||
couple_id: coupleId,
|
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
||||||
date_id: dateId,
|
|
||||||
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
|
|
||||||
? { sender_avatar_url: senderAvatar }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
}
|
partnerData,
|
||||||
const results = await Promise.allSettled(
|
|
||||||
tokens.map((token) =>
|
|
||||||
admin.messaging().send({
|
|
||||||
...payload,
|
|
||||||
token,
|
|
||||||
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
|
||||||
} as admin.messaging.Message)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
results.forEach((r, i) => {
|
}
|
||||||
if (r.status === 'rejected') console.warn(`[onDateReflectionWritten] FCM failed for ${tokens[i]}:`, r.reason)
|
)
|
||||||
})
|
|
||||||
await pruneDeadTokens(db, partnerId, tokens, results)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,27 @@
|
||||||
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.
|
||||||
*
|
*
|
||||||
* 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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,37 +69,33 @@ 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(
|
||||||
notification: {
|
db,
|
||||||
title: 'Your partner opened your answers',
|
admin.messaging(),
|
||||||
body: 'Open to see what you each said.',
|
partnerId,
|
||||||
},
|
{
|
||||||
data: {
|
notification: {
|
||||||
type: 'partner_opened_answer',
|
title: 'Your partner opened your answers',
|
||||||
couple_id: coupleId,
|
body: 'Open to see what you each said.',
|
||||||
question_id: questionId,
|
},
|
||||||
date,
|
data: {
|
||||||
...(typeof revealerAvatar === 'string' && revealerAvatar.length > 0
|
type: 'partner_opened_answer',
|
||||||
? { sender_avatar_url: revealerAvatar }
|
couple_id: coupleId,
|
||||||
: {}),
|
question_id: questionId,
|
||||||
|
date,
|
||||||
|
...(typeof revealerAvatar === 'string' && revealerAvatar.length > 0
|
||||||
|
? { sender_avatar_url: revealerAvatar }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
android: { notification: { channelId: 'partner_activity' } },
|
||||||
},
|
},
|
||||||
|
partnerData,
|
||||||
|
)
|
||||||
|
if (res.sent === 0 && res.failed === 0) {
|
||||||
|
logger.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendResults = await Promise.allSettled(
|
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}`)
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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,56 +82,45 @@ 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(
|
||||||
notification: bothAnswered
|
db,
|
||||||
? {
|
admin.messaging(),
|
||||||
title: 'Your answers are unlocked ✨',
|
partnerId,
|
||||||
body: "You've both answered — open to see what you each said.",
|
{
|
||||||
}
|
notification: bothAnswered
|
||||||
: {
|
? {
|
||||||
title: 'Your partner just answered!',
|
title: 'Your answers are unlocked ✨',
|
||||||
body: "See what they shared for tonight's prompt.",
|
body: "You've both answered — open to see what you each said.",
|
||||||
},
|
}
|
||||||
data: {
|
: {
|
||||||
type: 'partner_answered',
|
title: 'Your partner just answered!',
|
||||||
couple_id: coupleId,
|
body: "See what they shared for tonight's prompt.",
|
||||||
question_id: questionId,
|
},
|
||||||
date,
|
data: {
|
||||||
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
|
type: 'partner_answered',
|
||||||
? { sender_avatar_url: senderAvatar }
|
couple_id: coupleId,
|
||||||
: {}),
|
question_id: questionId,
|
||||||
|
date,
|
||||||
|
...(typeof senderAvatar === 'string' && senderAvatar.length > 0
|
||||||
|
? { sender_avatar_url: senderAvatar }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
||||||
},
|
},
|
||||||
}
|
partnerData,
|
||||||
|
|
||||||
const sendResults = await Promise.allSettled(
|
|
||||||
tokens.map((token) =>
|
|
||||||
admin.messaging().send({
|
|
||||||
...payload,
|
|
||||||
token,
|
|
||||||
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
|
|
||||||
} as admin.messaging.Message)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if (res.sent === 0 && res.failed === 0) {
|
||||||
const failures: string[] = []
|
logger.log(`[onAnswerWritten] 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(`[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}`
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,44 +67,33 @@ 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(
|
||||||
notification: {
|
db,
|
||||||
title: 'Your partner sent a message',
|
admin.messaging(),
|
||||||
body: 'Tap to read and reply.',
|
partnerId,
|
||||||
|
{
|
||||||
|
notification: {
|
||||||
|
title: 'Your partner sent a message',
|
||||||
|
body: 'Tap to read and reply.',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'chat_message',
|
||||||
|
couple_id: coupleId,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}),
|
||||||
|
},
|
||||||
|
// E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel.
|
||||||
|
android: { notification: { channelId: 'partner_activity' } },
|
||||||
},
|
},
|
||||||
data: {
|
partnerData,
|
||||||
type: 'chat_message',
|
|
||||||
couple_id: coupleId,
|
|
||||||
conversation_id: conversationId,
|
|
||||||
...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendResults = await Promise.allSettled(
|
|
||||||
tokens.map((token) =>
|
|
||||||
admin.messaging().send({
|
|
||||||
...payload,
|
|
||||||
token,
|
|
||||||
// E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel.
|
|
||||||
android: { notification: { channelId: 'partner_activity' } },
|
|
||||||
} as admin.messaging.Message)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if (res.sent === 0 && res.failed === 0) {
|
||||||
const failures: string[] = []
|
logger.log(`[onMessageWritten] 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(`[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}`
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue