refactor(functions): B2 migrate 8 scheduled functions to v2 + harden

Migrate all scheduled jobs off functions.pubsub.schedule().onRun() to
firebase-functions/v2/scheduler onSchedule({ schedule, timeZone, ...opts }, handler):
sendChallengeDayReminders, unlockDueMemoryCapsules, sendDailyQuestionProactiveReminder,
sendStreakReminder, sendReengagementReminder, assignDailyQuestion (scheduled export),
aggregateOutcomeStats, scheduledOutcomesReminder.

Hardening folded in:
- Fan-out isolation: Promise.all → Promise.allSettled in dailyQuestionReminder (outer+inner),
  reengagement, gameRetention (both jobs), scheduledOutcomesReminder — one bad couple can no
  longer abort a whole run. streakReminder / assignDailyQuestion already isolated.
- Resource options: assignDailyQuestion + aggregateOutcomeStats memory 512MiB + timeout 300s
  (they iterate all couples); the four fan-out reminders get timeout 180s.
- Adopt shared sendPushToUser()/logger everywhere; remove five copied getUserTokens() and the
  copied send/prune blocks (no plaintext token logging remains here).
- Consolidate duplicated date/time helpers into notifications/time.ts (chicagoDateKey, toMillis),
  replacing streakReminder's + scheduledOutcomesReminder's per-file copies.

assignDailyQuestion.ts callable export stays v1 for now (migrates in B3); its tested CST helpers
are untouched. Scanner pagination for assignDailyQuestion/aggregateOutcomes is deferred to B6.

Build clean; 70 tests green (tested pure helpers preserved). dist rebuilt. Still on v5.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 23:50:59 -05:00
parent 4040abbf28
commit 6681bf1518
24 changed files with 460 additions and 743 deletions

View File

@ -36,8 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.aggregateOutcomeStats = exports.MIN_COHORT = exports.FOLLOWUP_DAYS = exports.SCORE_KEYS = void 0; exports.aggregateOutcomeStats = exports.MIN_COHORT = exports.FOLLOWUP_DAYS = exports.SCORE_KEYS = void 0;
exports.aggregate = aggregate; exports.aggregate = aggregate;
exports.extractCoupleOutcome = extractCoupleOutcome; exports.extractCoupleOutcome = extractCoupleOutcome;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const log_1 = require("../log");
exports.SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness']; exports.SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness'];
exports.FOLLOWUP_DAYS = ['day_30', 'day_60', 'day_90']; exports.FOLLOWUP_DAYS = ['day_30', 'day_60', 'day_90'];
exports.MIN_COHORT = 50; exports.MIN_COHORT = 50;
@ -83,7 +84,7 @@ function extractCoupleOutcome(outcomeDocs) {
} }
return { deltas }; return { deltas };
} }
exports.aggregateOutcomeStats = functions.pubsub.schedule('every 24 hours').onRun(async () => { exports.aggregateOutcomeStats = (0, scheduler_1.onSchedule)({ schedule: 'every 24 hours', memory: '512MiB', timeoutSeconds: 300 }, async () => {
const db = admin.firestore(); const db = admin.firestore();
const couples = []; const couples = [];
const couplesSnap = await db.collection('couples').get(); const couplesSnap = await db.collection('couples').get();
@ -93,8 +94,7 @@ exports.aggregateOutcomeStats = functions.pubsub.schedule('every 24 hours').onRu
} }
const stats = aggregate(couples); const stats = aggregate(couples);
await db.collection('aggregate_stats').doc('outcomes').set(Object.assign(Object.assign({}, stats), { totalCouples: couples.length, generatedAt: admin.firestore.Timestamp.now() })); await db.collection('aggregate_stats').doc('outcomes').set(Object.assign(Object.assign({}, stats), { totalCouples: couples.length, generatedAt: admin.firestore.Timestamp.now() }));
console.log(`[aggregateOutcomeStats] ${couples.length} couples → ` + log_1.logger.log(`[aggregateOutcomeStats] ${couples.length} couples → ` +
exports.FOLLOWUP_DAYS.map((d) => `${d}:${stats.windows[d].suppressed ? 'suppressed' : stats.windows[d].feltCloserPct + '%'}`).join(' ')); exports.FOLLOWUP_DAYS.map((d) => `${d}:${stats.windows[d].suppressed ? 'suppressed' : stats.windows[d].feltCloserPct + '%'}`).join(' '));
return null;
}); });
//# sourceMappingURL=aggregateOutcomes.js.map //# sourceMappingURL=aggregateOutcomes.js.map

View File

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

View File

@ -34,16 +34,16 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.scheduledOutcomesReminder = void 0; exports.scheduledOutcomesReminder = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
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 time_1 = require("../notifications/time");
const log_1 = require("../log");
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const REMINDER_DAYS = [30, 60, 90]; const REMINDER_DAYS = [30, 60, 90];
const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' }; const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' };
exports.scheduledOutcomesReminder = functions.pubsub exports.scheduledOutcomesReminder = (0, scheduler_1.onSchedule)({ schedule: 'every 24 hours', timeoutSeconds: 180 }, async () => {
.schedule('every 24 hours')
.onRun(async () => {
var _a, _b; var _a, _b;
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
@ -53,7 +53,7 @@ exports.scheduledOutcomesReminder = functions.pubsub
for (const coupleDoc of couplesSnap.docs) { for (const coupleDoc of couplesSnap.docs) {
const coupleId = coupleDoc.id; const coupleId = coupleDoc.id;
const data = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {}; const data = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {};
const createdAt = millisFromFirestoreValue(data.createdAt); const createdAt = (0, time_1.toMillis)(data.createdAt);
if (createdAt <= 0) if (createdAt <= 0)
continue; continue;
const ageDays = Math.floor((now - createdAt) / DAY_MS); const ageDays = Math.floor((now - createdAt) / DAY_MS);
@ -82,26 +82,16 @@ exports.scheduledOutcomesReminder = functions.pubsub
notifications.push({ userId, coupleId, day: dayLabel, title, body }); notifications.push({ userId, coupleId, day: dayLabel, title, body });
} }
} }
await Promise.all(notifications.map((notification) => sendOutcomeReminder(db, messaging, notification))); // Per-recipient failures are isolated so one bad send can't abort the batch.
console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`); await Promise.allSettled(notifications.map((notification) => sendOutcomeReminder(db, messaging, notification)));
log_1.logger.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`);
}); });
function millisFromFirestoreValue(value) {
if (typeof value === 'number')
return value;
if (value instanceof admin.firestore.Timestamp)
return value.toMillis();
if (value != null &&
typeof value.toMillis === 'function') {
return value.toMillis();
}
return 0;
}
async function sendOutcomeReminder(db, messaging, notification) { async function sendOutcomeReminder(db, messaging, notification) {
const userDoc = await db.collection('users').doc(notification.userId).get(); const userDoc = await db.collection('users').doc(notification.userId).get();
const userData = userDoc.data(); const userData = userDoc.data();
// Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate). // Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate).
if ((0, quietHours_1.recipientInQuietHours)(userData)) { if ((0, quietHours_1.recipientInQuietHours)(userData)) {
console.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`); log_1.logger.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`);
return; return;
} }
await db await db
@ -117,13 +107,7 @@ async function sendOutcomeReminder(db, messaging, notification) {
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
const tokens = await getUserTokens(db, notification.userId, userData); await (0, push_1.sendPushToUser)(db, messaging, notification.userId, {
if (tokens.length === 0) {
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`);
return;
}
const message = {
token: tokens[0],
notification: { notification: {
title: notification.title, title: notification.title,
body: notification.body, body: notification.body,
@ -134,34 +118,6 @@ async function sendOutcomeReminder(db, messaging, notification) {
coupleId: notification.coupleId, coupleId: notification.coupleId,
day: String(notification.day), day: String(notification.day),
}, },
}; }, userData);
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token }))));
sendResults.forEach((result, index) => {
if (result.status === 'rejected') {
console.warn(`[sendOutcomeReminder] FCM send to ${tokens[index]} failed:`, result.reason);
}
});
await (0, pruneTokens_1.pruneDeadTokens)(db, notification.userId, tokens, sendResults);
}
async function getUserTokens(db, userId, userData) {
const tokens = [];
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken);
}
const tokenSnapshot = await db
.collection('users')
.doc(userId)
.collection('fcmTokens')
.get();
tokenSnapshot.docs.forEach((doc) => {
var _a;
const token = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
tokens.push(token);
}
});
return tokens;
} }
//# sourceMappingURL=scheduledOutcomesReminder.js.map //# sourceMappingURL=scheduledOutcomesReminder.js.map

File diff suppressed because one or more lines are too long

View File

@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.sendDailyQuestionProactiveReminder = void 0; exports.sendDailyQuestionProactiveReminder = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours"); const quietHours_1 = require("./quietHours");
const pruneTokens_1 = require("./pruneTokens"); const push_1 = require("./push");
const log_1 = require("../log");
/** /**
* Proactive daily question reminder. * Proactive daily question reminder.
* *
@ -55,10 +56,7 @@ const pruneTokens_1 = require("./pruneTokens");
* 5. Record automated_daily_reminder/{docId} so this function is idempotent * 5. Record automated_daily_reminder/{docId} so this function is idempotent
* on re-runs. * on re-runs.
*/ */
exports.sendDailyQuestionProactiveReminder = functions.pubsub exports.sendDailyQuestionProactiveReminder = (0, scheduler_1.onSchedule)({ schedule: '0 16 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
.schedule('0 16 * * *')
.timeZone('America/Chicago')
.onRun(async () => {
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
const now = Date.now(); const now = Date.now();
@ -71,7 +69,8 @@ exports.sendDailyQuestionProactiveReminder = functions.pubsub
.get(); .get();
let notified = 0; let notified = 0;
let skipped = 0; let skipped = 0;
await Promise.all(expiringSnap.docs.map(async (questionDoc) => { // Per-couple failures are isolated so one bad couple can't abort the whole run.
await Promise.allSettled(expiringSnap.docs.map(async (questionDoc) => {
var _a, _b; var _a, _b;
const coupleRef = questionDoc.ref.parent.parent; const coupleRef = questionDoc.ref.parent.parent;
if (!coupleRef) if (!coupleRef)
@ -122,21 +121,21 @@ exports.sendDailyQuestionProactiveReminder = functions.pubsub
return; return;
} }
// Send FCM + notification_queue entry to every user in the couple. // Send FCM + notification_queue entry to every user in the couple.
await Promise.all(userIds.map((userId) => sendReminder(db, messaging, userId, coupleRef.id, dateKey))); await Promise.allSettled(userIds.map((userId) => sendReminder(db, messaging, userId, coupleRef.id, dateKey)));
notified += userIds.length; notified += userIds.length;
})); }));
console.log(`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`); log_1.logger.log(`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`);
}); });
async function sendReminder(db, messaging, userId, coupleId, questionDate) { async function sendReminder(db, messaging, userId, coupleId, questionDate) {
const userDoc = await db.collection('users').doc(userId).get(); const userDoc = await db.collection('users').doc(userId).get();
const userData = userDoc.data(); const userData = userDoc.data();
// Respect the user's Daily Reminder toggle (default on) and quiet hours. // Respect the user's Daily Reminder toggle (default on) and quiet hours.
if ((userData === null || userData === void 0 ? void 0 : userData.notifDailyReminder) === false) { if ((userData === null || userData === void 0 ? void 0 : userData.notifDailyReminder) === false) {
console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`); log_1.logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`);
return; return;
} }
if ((0, quietHours_1.recipientInQuietHours)(userData)) { if ((0, quietHours_1.recipientInQuietHours)(userData)) {
console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`); log_1.logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`);
return; return;
} }
// In-app notification record. // In-app notification record.
@ -151,12 +150,7 @@ async function sendReminder(db, messaging, userId, coupleId, questionDate) {
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
// FCM push. await (0, push_1.sendPushToUser)(db, messaging, userId, {
const tokens = await getUserTokens(db, userId, userData);
if (tokens.length === 0)
return;
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({
token,
notification: { notification: {
title: "Tonight's question is waiting.", title: "Tonight's question is waiting.",
body: 'Answer together before it expires.', body: 'Answer together before it expires.',
@ -167,33 +161,6 @@ async function sendReminder(db, messaging, userId, coupleId, questionDate) {
couple_id: coupleId, couple_id: coupleId,
question_date: questionDate, question_date: questionDate,
}, },
}))); }, userData);
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[sendDailyQuestionProactiveReminder] FCM failed for token ${tokens[i]}:`, result.reason);
}
});
await (0, pruneTokens_1.pruneDeadTokens)(db, userId, tokens, sendResults);
}
async function getUserTokens(db, userId, userData) {
const tokens = [];
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken);
}
const tokenSnap = await db
.collection('users')
.doc(userId)
.collection('fcmTokens')
.get();
tokenSnap.docs.forEach((doc) => {
var _a;
const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t);
}
});
return tokens;
} }
//# sourceMappingURL=dailyQuestionReminder.js.map //# sourceMappingURL=dailyQuestionReminder.js.map

File diff suppressed because one or more lines are too long

View File

@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0; exports.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours"); const quietHours_1 = require("./quietHours");
const pruneTokens_1 = require("./pruneTokens"); const push_1 = require("./push");
const log_1 = require("../log");
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const CHALLENGE_TITLES = { const CHALLENGE_TITLES = {
gratitude_week: { title: 'Gratitude Week', durationDays: 7 }, gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
@ -45,9 +46,7 @@ const CHALLENGE_TITLES = {
quality_time: { title: 'Quality Time', durationDays: 7 }, quality_time: { title: 'Quality Time', durationDays: 7 },
deep_conversations: { title: 'Deep Conversations', durationDays: 7 }, deep_conversations: { title: 'Deep Conversations', durationDays: 7 },
}; };
exports.unlockDueMemoryCapsules = functions.pubsub exports.unlockDueMemoryCapsules = (0, scheduler_1.onSchedule)('every 1 hours', async () => {
.schedule('every 1 hours')
.onRun(async () => {
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
const now = Date.now(); const now = Date.now();
@ -94,12 +93,11 @@ exports.unlockDueMemoryCapsules = functions.pubsub
}); });
notifications.push(...capsuleNotifications); notifications.push(...capsuleNotifications);
} }
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))); // Per-recipient failures are isolated so one bad send can't abort the batch.
console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`); await Promise.allSettled(notifications.map((notification) => sendNotification(db, messaging, notification)));
log_1.logger.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`);
}); });
exports.sendChallengeDayReminders = functions.pubsub exports.sendChallengeDayReminders = (0, scheduler_1.onSchedule)('every 24 hours', async () => {
.schedule('every 24 hours')
.onRun(async () => {
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
const now = Date.now(); const now = Date.now();
@ -166,8 +164,9 @@ exports.sendChallengeDayReminders = functions.pubsub
}); });
notifications.push(...challengeNotifications); notifications.push(...challengeNotifications);
} }
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))); // Per-recipient failures are isolated so one bad send can't abort the batch.
console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`); await Promise.allSettled(notifications.map((notification) => sendNotification(db, messaging, notification)));
log_1.logger.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`);
}); });
function reminderKey(userId, day) { function reminderKey(userId, day) {
return `${userId.replace(/[^\w-]/g, '_')}_${day}`; return `${userId.replace(/[^\w-]/g, '_')}_${day}`;
@ -178,12 +177,12 @@ async function sendNotification(db, messaging, notification) {
// Challenge-day reminders are retention nudges → respect the promotional opt-out (default on). // Challenge-day reminders are retention nudges → respect the promotional opt-out (default on).
// (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.) // (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.)
if (notification.type === 'challenge_day_ready' && (userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) { if (notification.type === 'challenge_day_ready' && (userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) {
console.log(`[sendNotification] skip ${notification.userId} — promotional off`); log_1.logger.log(`[sendNotification] skip ${notification.userId} — promotional off`);
return; return;
} }
// Honor the recipient's quiet hours for every scheduled push. // Honor the recipient's quiet hours for every scheduled push.
if ((0, quietHours_1.recipientInQuietHours)(userData)) { if ((0, quietHours_1.recipientInQuietHours)(userData)) {
console.log(`[sendNotification] skip ${notification.userId} — quiet hours`); log_1.logger.log(`[sendNotification] skip ${notification.userId} — quiet hours`);
return; return;
} }
await db await db
@ -197,13 +196,7 @@ async function sendNotification(db, messaging, notification) {
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
const tokens = await getUserTokens(db, notification.userId, userData); await (0, push_1.sendPushToUser)(db, messaging, notification.userId, {
if (tokens.length === 0) {
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`);
return;
}
const message = {
token: tokens[0],
notification: { notification: {
title: notification.title, title: notification.title,
body: notification.body, body: notification.body,
@ -215,38 +208,6 @@ async function sendNotification(db, messaging, notification) {
}, },
}, },
data: Object.assign({ type: notification.type }, notification.data), data: Object.assign({ type: notification.type }, notification.data),
}; }, userData);
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token }))));
const failures = [];
sendResults.forEach((result, index) => {
if (result.status === 'rejected') {
failures.push(`${tokens[index]}: ${String(result.reason)}`);
}
});
if (failures.length > 0) {
console.error(`[sendNotification] some notifications failed:`, failures);
}
await (0, pruneTokens_1.pruneDeadTokens)(db, notification.userId, tokens, sendResults);
}
async function getUserTokens(db, userId, userData) {
const tokens = [];
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken);
}
const tokenSnapshot = await db
.collection('users')
.doc(userId)
.collection('fcmTokens')
.get();
tokenSnapshot.docs.forEach((doc) => {
var _a;
const token = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
tokens.push(token);
}
});
return tokens;
} }
//# sourceMappingURL=gameRetention.js.map //# sourceMappingURL=gameRetention.js.map

File diff suppressed because one or more lines are too long

View File

@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.sendReengagementReminder = void 0; exports.sendReengagementReminder = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours"); const quietHours_1 = require("./quietHours");
const pruneTokens_1 = require("./pruneTokens"); const push_1 = require("./push");
const log_1 = require("../log");
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000; const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000;
const REENGAGEMENT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; const REENGAGEMENT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000;
@ -52,10 +53,7 @@ const REENGAGEMENT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000;
* *
* Requires a Firestore composite index on couples: lastAnsweredAt ASC. * Requires a Firestore composite index on couples: lastAnsweredAt ASC.
*/ */
exports.sendReengagementReminder = functions.pubsub exports.sendReengagementReminder = (0, scheduler_1.onSchedule)({ schedule: '0 12 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
.schedule('0 12 * * *')
.timeZone('America/Chicago')
.onRun(async () => {
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
const now = Date.now(); const now = Date.now();
@ -69,7 +67,8 @@ exports.sendReengagementReminder = functions.pubsub
.get(); .get();
let notified = 0; let notified = 0;
let skipped = 0; let skipped = 0;
await Promise.all(snap.docs.map(async (coupleDoc) => { // Per-couple failures are isolated so one bad couple can't abort the whole run.
await Promise.allSettled(snap.docs.map(async (coupleDoc) => {
var _a; var _a;
const data = coupleDoc.data(); const data = coupleDoc.data();
const coupleId = coupleDoc.id; const coupleId = coupleDoc.id;
@ -100,21 +99,21 @@ exports.sendReengagementReminder = functions.pubsub
skipped++; skipped++;
return; return;
} }
await Promise.all(userIds.map((uid) => sendNudge(db, messaging, uid, coupleId))); await Promise.allSettled(userIds.map((uid) => sendNudge(db, messaging, uid, coupleId)));
notified += userIds.length; notified += userIds.length;
})); }));
console.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`); log_1.logger.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`);
}); });
async function sendNudge(db, messaging, userId, coupleId) { async function sendNudge(db, messaging, userId, coupleId) {
const userDoc = await db.collection('users').doc(userId).get(); const userDoc = await db.collection('users').doc(userId).get();
const userData = userDoc.data(); const userData = userDoc.data();
// Re-engagement is a promotional nudge — respect the opt-out (default on) and quiet hours. // Re-engagement is a promotional nudge — respect the opt-out (default on) and quiet hours.
if ((userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) { if ((userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) {
console.log(`[sendReengagementReminder] skip ${userId} — promotional off`); log_1.logger.log(`[sendReengagementReminder] skip ${userId} — promotional off`);
return; return;
} }
if ((0, quietHours_1.recipientInQuietHours)(userData)) { if ((0, quietHours_1.recipientInQuietHours)(userData)) {
console.log(`[sendReengagementReminder] skip ${userId} — quiet hours`); log_1.logger.log(`[sendReengagementReminder] skip ${userId} — quiet hours`);
return; return;
} }
await db.collection('users').doc(userId).collection('notification_queue').add({ await db.collection('users').doc(userId).collection('notification_queue').add({
@ -124,11 +123,7 @@ async function sendNudge(db, messaging, userId, coupleId) {
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
const tokens = await getUserTokens(db, userId, userData); await (0, push_1.sendPushToUser)(db, messaging, userId, {
if (tokens.length === 0)
return;
const results = await Promise.allSettled(tokens.map((token) => messaging.send({
token,
notification: { notification: {
title: "It's been a while.", title: "It's been a while.",
body: "Tonight's question is a good reason to reconnect.", body: "Tonight's question is a good reason to reconnect.",
@ -138,22 +133,6 @@ async function sendNudge(db, messaging, userId, coupleId) {
type: 'reengagement', type: 'reengagement',
couple_id: coupleId, couple_id: coupleId,
}, },
}))); }, userData);
await (0, pruneTokens_1.pruneDeadTokens)(db, userId, tokens, results);
}
async function getUserTokens(db, userId, userData) {
const tokens = [];
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
const legacy = data === null || data === void 0 ? void 0 : data.fcmToken;
if (typeof legacy === 'string' && legacy.length > 0)
tokens.push(legacy);
const snap = await db.collection('users').doc(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=reengagement.js.map //# sourceMappingURL=reengagement.js.map

View File

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

View File

@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.sendStreakReminder = void 0; exports.sendStreakReminder = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours"); const quietHours_1 = require("./quietHours");
const pruneTokens_1 = require("./pruneTokens"); const push_1 = require("./push");
const time_1 = require("./time");
const log_1 = require("../log");
/** /**
* Streak reminder an evening "don't lose your streak" nudge. * Streak reminder an evening "don't lose your streak" nudge.
* *
@ -51,13 +53,10 @@ const pruneTokens_1 = require("./pruneTokens");
* local day true per-timezone firing is a future refinement; quiet-hours suppression keeps a mistimed * local day true per-timezone firing is a future refinement; quiet-hours suppression keeps a mistimed
* fire from landing at a bad local hour. (Streak day-boundary note in the plan.) * fire from landing at a bad local hour. (Streak day-boundary note in the plan.)
*/ */
exports.sendStreakReminder = functions.pubsub exports.sendStreakReminder = (0, scheduler_1.onSchedule)({ schedule: '0 19 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
.schedule('0 19 * * *')
.timeZone('America/Chicago')
.onRun(async () => {
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
const todayKey = chicagoDateKey(new Date()); const todayKey = (0, time_1.chicagoDateKey)(new Date());
const coupleSnap = await db.collection('couples').where('streakCount', '>', 0).get(); const coupleSnap = await db.collection('couples').where('streakCount', '>', 0).get();
let notified = 0; let notified = 0;
let skipped = 0; let skipped = 0;
@ -70,8 +69,8 @@ exports.sendStreakReminder = functions.pubsub
return; return;
} }
// Already did a shared action today → the streak is safe, no nudge needed. // Already did a shared action today → the streak is safe, no nudge needed.
const lastMs = toMillis(couple.lastAnsweredAt); const lastMs = (0, time_1.toMillis)(couple.lastAnsweredAt);
if (lastMs > 0 && chicagoDateKey(new Date(lastMs)) === todayKey) { if (lastMs > 0 && (0, time_1.chicagoDateKey)(new Date(lastMs)) === todayKey) {
skipped++; skipped++;
return; return;
} }
@ -99,20 +98,20 @@ exports.sendStreakReminder = functions.pubsub
})); }));
results.forEach((r) => { results.forEach((r) => {
if (r.status === 'rejected') if (r.status === 'rejected')
console.warn('[sendStreakReminder] couple failed:', r.reason); log_1.logger.warn('[sendStreakReminder] couple failed', { error: String(r.reason) });
}); });
console.log(`[sendStreakReminder] scanned ${coupleSnap.size} streak couples; notified ${notified}; skipped ${skipped}`); log_1.logger.log(`[sendStreakReminder] scanned ${coupleSnap.size} streak couples; notified ${notified}; skipped ${skipped}`);
}); });
async function sendStreakNudge(db, messaging, userId, coupleId, streak, dateKey) { async function sendStreakNudge(db, messaging, userId, coupleId, streak, dateKey) {
const userDoc = await db.collection('users').doc(userId).get(); const userDoc = await db.collection('users').doc(userId).get();
const userData = userDoc.data(); const userData = userDoc.data();
// Respect the user's Streak Reminder toggle (default on) and quiet hours. // Respect the user's Streak Reminder toggle (default on) and quiet hours.
if ((userData === null || userData === void 0 ? void 0 : userData.notifStreakReminder) === false) { if ((userData === null || userData === void 0 ? void 0 : userData.notifStreakReminder) === false) {
console.log(`[sendStreakReminder] skip ${userId} — streak reminder off`); log_1.logger.log(`[sendStreakReminder] skip ${userId} — streak reminder off`);
return; return;
} }
if ((0, quietHours_1.recipientInQuietHours)(userData)) { if ((0, quietHours_1.recipientInQuietHours)(userData)) {
console.log(`[sendStreakReminder] skip ${userId} — quiet hours`); log_1.logger.log(`[sendStreakReminder] skip ${userId} — quiet hours`);
return; return;
} }
const title = `🔥 Keep your ${streak}-day streak`; const title = `🔥 Keep your ${streak}-day streak`;
@ -128,53 +127,10 @@ async function sendStreakNudge(db, messaging, userId, coupleId, streak, dateKey)
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
const tokens = await getUserTokens(db, userId, userData); await (0, push_1.sendPushToUser)(db, messaging, userId, {
if (tokens.length === 0)
return;
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({
token,
notification: { title, body }, notification: { title, body },
android: { notification: { channelId: 'reminders' } }, android: { notification: { channelId: 'reminders' } },
data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey }, data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey },
}))); }, userData);
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[sendStreakReminder] FCM failed for token ${tokens[i]}:`, result.reason);
}
});
await (0, pruneTokens_1.pruneDeadTokens)(db, userId, tokens, sendResults);
}
/** YYYY-MM-DD for the given instant in America/Chicago (DST-safe; en-CA gives ISO date order). */
function chicagoDateKey(d) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
function toMillis(value) {
if (value instanceof admin.firestore.Timestamp)
return value.toMillis();
if (typeof value === 'number')
return value;
return 0;
}
async function getUserTokens(db, userId, userData) {
const tokens = [];
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(userId).get()).data();
const legacyToken = data === null || data === void 0 ? void 0 : data.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken);
}
const tokenSnap = await db.collection('users').doc(userId).collection('fcmTokens').get();
tokenSnap.docs.forEach((doc) => {
var _a;
const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t);
}
});
return tokens;
} }
//# sourceMappingURL=streakReminder.js.map //# sourceMappingURL=streakReminder.js.map

File diff suppressed because one or more lines are too long

65
functions/dist/notifications/time.js vendored Normal file
View File

@ -0,0 +1,65 @@
"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.chicagoDateKey = chicagoDateKey;
exports.toMillis = toMillis;
const admin = __importStar(require("firebase-admin"));
/**
* Shared date/time helpers for the scheduled jobs, consolidated from per-file copies
* (streakReminder's chicagoDateKey/toMillis, scheduledOutcomesReminder's millisFromFirestoreValue).
* The CST assignment helpers in questions/assignDailyQuestion.ts are a separate, unit-tested set and
* stay there.
*/
/** YYYY-MM-DD for the given instant in America/Chicago (DST-safe; en-CA gives ISO date order). */
function chicagoDateKey(d) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
/** Best-effort milliseconds from a Firestore Timestamp, a raw number, or any `{ toMillis() }` value. */
function toMillis(value) {
if (typeof value === 'number')
return value;
if (value instanceof admin.firestore.Timestamp)
return value.toMillis();
if (value != null && typeof value.toMillis === 'function') {
return value.toMillis();
}
return 0;
}
//# sourceMappingURL=time.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"time.js","sourceRoot":"","sources":["../../src/notifications/time.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAUA,wCAOC;AAGD,4BAOC;AA3BD,sDAAuC;AAEvC;;;;;GAKG;AAEH,kGAAkG;AAClG,SAAgB,cAAc,CAAC,CAAO;IACpC,OAAO,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;QACtC,QAAQ,EAAE,iBAAiB;QAC3B,IAAI,EAAE,SAAS;QACf,KAAK,EAAE,SAAS;QAChB,GAAG,EAAE,SAAS;KACf,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;AACd,CAAC;AAED,wGAAwG;AACxG,SAAgB,QAAQ,CAAC,KAAc;IACrC,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3C,IAAI,KAAK,YAAY,KAAK,CAAC,SAAS,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAA;IACvE,IAAI,KAAK,IAAI,IAAI,IAAI,OAAQ,KAAgC,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;QACtF,OAAQ,KAAoC,CAAC,QAAQ,EAAE,CAAA;IACzD,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC"}

View File

@ -39,6 +39,8 @@ exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst; exports.timestampAt6PmCst = timestampAt6PmCst;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const log_1 = require("../log");
/** /**
* Scheduled function that assigns one daily question per couple every day. * Scheduled function that assigns one daily question per couple every day.
* *
@ -52,16 +54,13 @@ const admin = __importStar(require("firebase-admin"));
* Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write * Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write
* only (`allow write: if false` in rules). * only (`allow write: if false` in rules).
*/ */
exports.assignDailyQuestion = functions.pubsub exports.assignDailyQuestion = (0, scheduler_1.onSchedule)({ schedule: '0 23 * * *', timeZone: 'America/Chicago', memory: '512MiB', timeoutSeconds: 300 }, async () => {
.schedule('0 23 * * *')
.timeZone('America/Chicago')
.onRun(async () => {
const db = admin.firestore(); const db = admin.firestore();
const today = cstDateString(); const today = cstDateString();
const nextDay = nextCstDateString(); const nextDay = nextCstDateString();
const questionId = await pickDailyQuestionId(today); const questionId = await pickDailyQuestionId(today);
if (!questionId) { if (!questionId) {
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment'); log_1.logger.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
return; return;
} }
const couplesSnap = await db.collection('couples').get(); const couplesSnap = await db.collection('couples').get();
@ -88,11 +87,11 @@ exports.assignDailyQuestion = functions.pubsub
// Already assigned for today — idempotent. // Already assigned for today — idempotent.
return; return;
} }
console.error(`[assignDailyQuestion] failed for ${coupleId}:`, err); log_1.logger.error(`[assignDailyQuestion] failed for ${coupleId}`, { error: String(err) });
} }
}); });
await Promise.all(writes); await Promise.all(writes);
console.log(`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`); log_1.logger.log(`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`);
}); });
/** /**
* Callable function for immediate daily question assignment. * Callable function for immediate daily question assignment.

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler'
import { logger } from '../log'
/** /**
* Cloud Function: aggregateOutcomeStats * Cloud Function: aggregateOutcomeStats
@ -90,28 +91,30 @@ export function extractCoupleOutcome(
return { deltas } return { deltas }
} }
export const aggregateOutcomeStats = functions.pubsub.schedule('every 24 hours').onRun(async () => { export const aggregateOutcomeStats = onSchedule(
const db = admin.firestore() { schedule: 'every 24 hours', memory: '512MiB', timeoutSeconds: 300 },
async () => {
const db = admin.firestore()
const couples: CoupleOutcome[] = [] const couples: CoupleOutcome[] = []
const couplesSnap = await db.collection('couples').get() const couplesSnap = await db.collection('couples').get()
for (const couple of couplesSnap.docs) { for (const couple of couplesSnap.docs) {
const outcomesSnap = await couple.ref.collection('outcomes').get() const outcomesSnap = await couple.ref.collection('outcomes').get()
couples.push( couples.push(
extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() }))) extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() })))
)
}
const stats = aggregate(couples)
await db.collection('aggregate_stats').doc('outcomes').set({
...stats,
totalCouples: couples.length,
generatedAt: admin.firestore.Timestamp.now(),
})
logger.log(
`[aggregateOutcomeStats] ${couples.length} couples → ` +
FOLLOWUP_DAYS.map((d) => `${d}:${stats.windows[d].suppressed ? 'suppressed' : stats.windows[d].feltCloserPct + '%'}`).join(' ')
) )
} }
)
const stats = aggregate(couples)
await db.collection('aggregate_stats').doc('outcomes').set({
...stats,
totalCouples: couples.length,
generatedAt: admin.firestore.Timestamp.now(),
})
console.log(
`[aggregateOutcomeStats] ${couples.length} couples → ` +
FOLLOWUP_DAYS.map((d) => `${d}:${stats.windows[d].suppressed ? 'suppressed' : stats.windows[d].feltCloserPct + '%'}`).join(' ')
)
return null
})

View File

@ -1,7 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler'
import { recipientInQuietHours } from '../notifications/quietHours' import { recipientInQuietHours } from '../notifications/quietHours'
import { pruneDeadTokens } from '../notifications/pruneTokens' import { sendPushToUser } from '../notifications/push'
import { toMillis } from '../notifications/time'
import { logger } from '../log'
/** /**
* Cloud Function: scheduledOutcomesReminder * Cloud Function: scheduledOutcomesReminder
@ -23,9 +25,9 @@ const DAY_MS = 24 * 60 * 60 * 1000
const REMINDER_DAYS = [30, 60, 90] as const const REMINDER_DAYS = [30, 60, 90] as const
const DAY_KEY_MAP: Record<number, OutcomeDayKey> = { 30: 'day_30', 60: 'day_60', 90: 'day_90' } const DAY_KEY_MAP: Record<number, OutcomeDayKey> = { 30: 'day_30', 60: 'day_60', 90: 'day_90' }
export const scheduledOutcomesReminder = functions.pubsub export const scheduledOutcomesReminder = onSchedule(
.schedule('every 24 hours') { schedule: 'every 24 hours', timeoutSeconds: 180 },
.onRun(async () => { async () => {
const db = admin.firestore() const db = admin.firestore()
const messaging = admin.messaging() const messaging = admin.messaging()
const now = Date.now() const now = Date.now()
@ -42,7 +44,7 @@ export const scheduledOutcomesReminder = functions.pubsub
for (const coupleDoc of couplesSnap.docs) { for (const coupleDoc of couplesSnap.docs) {
const coupleId = coupleDoc.id const coupleId = coupleDoc.id
const data = coupleDoc.data() ?? {} const data = coupleDoc.data() ?? {}
const createdAt = millisFromFirestoreValue(data.createdAt) const createdAt = toMillis(data.createdAt)
if (createdAt <= 0) continue if (createdAt <= 0) continue
const ageDays = Math.floor((now - createdAt) / DAY_MS) const ageDays = Math.floor((now - createdAt) / DAY_MS)
@ -73,26 +75,16 @@ export const scheduledOutcomesReminder = functions.pubsub
} }
} }
await Promise.all( // Per-recipient failures are isolated so one bad send can't abort the batch.
await Promise.allSettled(
notifications.map((notification) => notifications.map((notification) =>
sendOutcomeReminder(db, messaging, notification) sendOutcomeReminder(db, messaging, notification)
) )
) )
console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`) logger.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`)
})
function millisFromFirestoreValue(value: unknown): number {
if (typeof value === 'number') return value
if (value instanceof admin.firestore.Timestamp) return value.toMillis()
if (
value != null &&
typeof (value as { toMillis?: unknown }).toMillis === 'function'
) {
return (value as { toMillis: () => number }).toMillis()
} }
return 0 )
}
async function sendOutcomeReminder( async function sendOutcomeReminder(
db: admin.firestore.Firestore, db: admin.firestore.Firestore,
@ -104,7 +96,7 @@ async function sendOutcomeReminder(
// Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate). // Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate).
if (recipientInQuietHours(userData)) { if (recipientInQuietHours(userData)) {
console.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`) logger.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`)
return return
} }
@ -122,66 +114,22 @@ async function sendOutcomeReminder(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const tokens = await getUserTokens(db, notification.userId, userData) await sendPushToUser(
if (tokens.length === 0) { db,
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`) messaging,
return notification.userId,
} {
notification: {
const message: admin.messaging.Message = { title: notification.title,
token: tokens[0], body: notification.body,
notification: { },
title: notification.title, android: { notification: { channelId: 'reminders' } }, // E-OBS
body: notification.body, data: {
type: 'outcome_reminder',
coupleId: notification.coupleId,
day: String(notification.day),
},
}, },
android: { notification: { channelId: 'reminders' } }, // E-OBS userData,
data: {
type: 'outcome_reminder',
coupleId: notification.coupleId,
day: String(notification.day),
},
}
const sendResults = await Promise.allSettled(
tokens.map((token) => messaging.send({ ...message, token }))
) )
sendResults.forEach((result, index) => {
if (result.status === 'rejected') {
console.warn(
`[sendOutcomeReminder] FCM send to ${tokens[index]} failed:`,
result.reason
)
}
})
await pruneDeadTokens(db, notification.userId, tokens, sendResults)
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string,
userData?: admin.firestore.DocumentData
): Promise<string[]> {
const tokens: string[] = []
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
const legacyToken = data?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
const tokenSnapshot = await db
.collection('users')
.doc(userId)
.collection('fcmTokens')
.get()
tokenSnapshot.docs.forEach((doc) => {
const token = doc.data()?.token
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
tokens.push(token)
}
})
return tokens
} }

View File

@ -1,7 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler'
import { recipientInQuietHours } from './quietHours' import { recipientInQuietHours } from './quietHours'
import { pruneDeadTokens } from './pruneTokens' import { sendPushToUser } from './push'
import { logger } from '../log'
/** /**
* Proactive daily question reminder. * Proactive daily question reminder.
@ -20,10 +21,9 @@ import { pruneDeadTokens } from './pruneTokens'
* 5. Record automated_daily_reminder/{docId} so this function is idempotent * 5. Record automated_daily_reminder/{docId} so this function is idempotent
* on re-runs. * on re-runs.
*/ */
export const sendDailyQuestionProactiveReminder = functions.pubsub export const sendDailyQuestionProactiveReminder = onSchedule(
.schedule('0 16 * * *') { schedule: '0 16 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 },
.timeZone('America/Chicago') async () => {
.onRun(async () => {
const db = admin.firestore() const db = admin.firestore()
const messaging = admin.messaging() const messaging = admin.messaging()
const now = Date.now() const now = Date.now()
@ -39,7 +39,8 @@ export const sendDailyQuestionProactiveReminder = functions.pubsub
let notified = 0 let notified = 0
let skipped = 0 let skipped = 0
await Promise.all( // Per-couple failures are isolated so one bad couple can't abort the whole run.
await Promise.allSettled(
expiringSnap.docs.map(async (questionDoc) => { expiringSnap.docs.map(async (questionDoc) => {
const coupleRef = questionDoc.ref.parent.parent const coupleRef = questionDoc.ref.parent.parent
if (!coupleRef) return if (!coupleRef) return
@ -82,7 +83,7 @@ export const sendDailyQuestionProactiveReminder = functions.pubsub
} }
// Send FCM + notification_queue entry to every user in the couple. // Send FCM + notification_queue entry to every user in the couple.
await Promise.all( await Promise.allSettled(
userIds.map((userId) => userIds.map((userId) =>
sendReminder(db, messaging, userId, coupleRef.id, dateKey) sendReminder(db, messaging, userId, coupleRef.id, dateKey)
) )
@ -91,10 +92,11 @@ export const sendDailyQuestionProactiveReminder = functions.pubsub
}) })
) )
console.log( logger.log(
`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}` `[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`
) )
}) }
)
async function sendReminder( async function sendReminder(
db: admin.firestore.Firestore, db: admin.firestore.Firestore,
@ -108,11 +110,11 @@ async function sendReminder(
// Respect the user's Daily Reminder toggle (default on) and quiet hours. // Respect the user's Daily Reminder toggle (default on) and quiet hours.
if (userData?.notifDailyReminder === false) { if (userData?.notifDailyReminder === false) {
console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`) logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`)
return return
} }
if (recipientInQuietHours(userData)) { if (recipientInQuietHours(userData)) {
console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`) logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`)
return return
} }
@ -129,61 +131,22 @@ async function sendReminder(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
// FCM push. await sendPushToUser(
const tokens = await getUserTokens(db, userId, userData) db,
if (tokens.length === 0) return messaging,
userId,
const sendResults = await Promise.allSettled( {
tokens.map((token) => notification: {
messaging.send({ title: "Tonight's question is waiting.",
token, body: 'Answer together before it expires.',
notification: { },
title: "Tonight's question is waiting.", android: { notification: { channelId: 'reminders' } }, // E-OBS
body: 'Answer together before it expires.', data: {
}, type: 'daily_question_reminder',
android: { notification: { channelId: 'reminders' } }, // E-OBS couple_id: coupleId,
data: { question_date: questionDate,
type: 'daily_question_reminder', },
couple_id: coupleId, },
question_date: questionDate, userData,
},
})
)
) )
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(
`[sendDailyQuestionProactiveReminder] FCM failed for token ${tokens[i]}:`,
result.reason
)
}
})
await pruneDeadTokens(db, userId, tokens, sendResults)
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string,
userData?: admin.firestore.DocumentData
): Promise<string[]> {
const tokens: string[] = []
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
const legacyToken = data?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
const tokenSnap = await db
.collection('users')
.doc(userId)
.collection('fcmTokens')
.get()
tokenSnap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t)
}
})
return tokens
} }

View File

@ -1,7 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler'
import { recipientInQuietHours } from './quietHours' import { recipientInQuietHours } from './quietHours'
import { pruneDeadTokens } from './pruneTokens' import { sendPushToUser } from './push'
import { logger } from '../log'
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
@ -22,143 +23,141 @@ interface QueuedNotification {
data: Record<string, string> data: Record<string, string>
} }
export const unlockDueMemoryCapsules = functions.pubsub export const unlockDueMemoryCapsules = onSchedule('every 1 hours', async () => {
.schedule('every 1 hours') const db = admin.firestore()
.onRun(async () => { const messaging = admin.messaging()
const db = admin.firestore() const now = Date.now()
const messaging = admin.messaging()
const now = Date.now()
const snapshot = await db const snapshot = await db
.collectionGroup('capsules') .collectionGroup('capsules')
.where('status', '==', 'sealed') .where('status', '==', 'sealed')
.where('unlockAt', '<=', now) .where('unlockAt', '<=', now)
.limit(100) .limit(100)
.get() .get()
const notifications: QueuedNotification[] = [] const notifications: QueuedNotification[] = []
for (const capsuleDoc of snapshot.docs) { for (const capsuleDoc of snapshot.docs) {
const capsuleRef = capsuleDoc.ref const capsuleRef = capsuleDoc.ref
const coupleRef = capsuleRef.parent.parent const coupleRef = capsuleRef.parent.parent
if (!coupleRef) continue if (!coupleRef) continue
const capsuleNotifications = await db.runTransaction(async (tx) => { const capsuleNotifications = await db.runTransaction(async (tx) => {
const freshCapsule = await tx.get(capsuleRef) const freshCapsule = await tx.get(capsuleRef)
const capsule = freshCapsule.data() ?? {} const capsule = freshCapsule.data() ?? {}
if (capsule.status !== 'sealed' || Number(capsule.unlockAt ?? 0) > now) { if (capsule.status !== 'sealed' || Number(capsule.unlockAt ?? 0) > now) {
return [] as QueuedNotification[] return [] as QueuedNotification[]
} }
const coupleDoc = await tx.get(coupleRef) const coupleDoc = await tx.get(coupleRef)
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (userIds.length === 0) return [] as QueuedNotification[] if (userIds.length === 0) return [] as QueuedNotification[]
tx.update(capsuleRef, { tx.update(capsuleRef, {
status: 'unlocked', status: 'unlocked',
unlockedAt: now, unlockedAt: now,
unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(), unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(),
})
const capsuleId = capsuleRef.id
const coupleId = coupleRef.id
const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0
? capsule.title.trim()
: 'A memory capsule'
return userIds.map((userId) => ({
userId,
type: 'memory_capsule_unlocked' as const,
title: 'Your memory capsule opened',
body: `${title} is ready to read together.`,
data: { couple_id: coupleId, capsule_id: capsuleId },
}))
}) })
notifications.push(...capsuleNotifications) const capsuleId = capsuleRef.id
} const coupleId = coupleRef.id
const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0
? capsule.title.trim()
: 'A memory capsule'
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))) return userIds.map((userId) => ({
console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`) userId,
}) type: 'memory_capsule_unlocked' as const,
title: 'Your memory capsule opened',
body: `${title} is ready to read together.`,
data: { couple_id: coupleId, capsule_id: capsuleId },
}))
})
export const sendChallengeDayReminders = functions.pubsub notifications.push(...capsuleNotifications)
.schedule('every 24 hours') }
.onRun(async () => {
const db = admin.firestore()
const messaging = admin.messaging()
const now = Date.now()
const snapshot = await db // Per-recipient failures are isolated so one bad send can't abort the batch.
.collectionGroup('challenges') await Promise.allSettled(notifications.map((notification) => sendNotification(db, messaging, notification)))
.where('status', '==', 'active') logger.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`)
.limit(100) })
.get()
const notifications: QueuedNotification[] = [] export const sendChallengeDayReminders = onSchedule('every 24 hours', async () => {
const db = admin.firestore()
const messaging = admin.messaging()
const now = Date.now()
for (const challengeDoc of snapshot.docs) { const snapshot = await db
const challengeRef = challengeDoc.ref .collectionGroup('challenges')
const coupleRef = challengeRef.parent.parent .where('status', '==', 'active')
if (!coupleRef) continue .limit(100)
.get()
const challengeNotifications = await db.runTransaction(async (tx) => { const notifications: QueuedNotification[] = []
const freshChallenge = await tx.get(challengeRef)
const challenge = freshChallenge.data() ?? {}
if (challenge.status !== 'active') return [] as QueuedNotification[]
const startedAt = Number(challenge.startedAt ?? 0) for (const challengeDoc of snapshot.docs) {
if (startedAt <= 0 || startedAt > now) return [] as QueuedNotification[] const challengeRef = challengeDoc.ref
const coupleRef = challengeRef.parent.parent
if (!coupleRef) continue
const challengeId = typeof challenge.challengeId === 'string' const challengeNotifications = await db.runTransaction(async (tx) => {
? challenge.challengeId const freshChallenge = await tx.get(challengeRef)
: challengeRef.id const challenge = freshChallenge.data() ?? {}
const catalogEntry = CHALLENGE_TITLES[challengeId] ?? { if (challenge.status !== 'active') return [] as QueuedNotification[]
title: 'Connection Challenge',
durationDays: 7,
}
const day = Math.floor((now - startedAt) / DAY_MS) + 1
if (day < 1 || day > catalogEntry.durationDays) return [] as QueuedNotification[]
const coupleDoc = await tx.get(coupleRef) const startedAt = Number(challenge.startedAt ?? 0)
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] if (startedAt <= 0 || startedAt > now) return [] as QueuedNotification[]
if (userIds.length === 0) return [] as QueuedNotification[]
const completions = (challenge.completions ?? {}) as Record<string, number[]> const challengeId = typeof challenge.challengeId === 'string'
const reminderSent = (challenge.challengeReminderSent ?? {}) as Record<string, boolean> ? challenge.challengeId
const dueUserIds = userIds.filter((userId) => { : challengeRef.id
const completedDays = completions[userId] ?? [] const catalogEntry = CHALLENGE_TITLES[challengeId] ?? {
const alreadyCompleted = completedDays.map(Number).includes(day) title: 'Connection Challenge',
const alreadySent = reminderSent[reminderKey(userId, day)] === true durationDays: 7,
return !alreadyCompleted && !alreadySent }
}) const day = Math.floor((now - startedAt) / DAY_MS) + 1
if (day < 1 || day > catalogEntry.durationDays) return [] as QueuedNotification[]
if (dueUserIds.length === 0) return [] as QueuedNotification[] const coupleDoc = await tx.get(coupleRef)
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (userIds.length === 0) return [] as QueuedNotification[]
const updates: Record<string, unknown> = { const completions = (challenge.completions ?? {}) as Record<string, number[]>
lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(), const reminderSent = (challenge.challengeReminderSent ?? {}) as Record<string, boolean>
} const dueUserIds = userIds.filter((userId) => {
dueUserIds.forEach((userId) => { const completedDays = completions[userId] ?? []
updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true const alreadyCompleted = completedDays.map(Number).includes(day)
}) const alreadySent = reminderSent[reminderKey(userId, day)] === true
tx.update(challengeRef, updates) return !alreadyCompleted && !alreadySent
const coupleId = coupleRef.id
return dueUserIds.map((userId) => ({
userId,
type: 'challenge_day_ready' as const,
title: `Day ${day} is ready`,
body: `${catalogEntry.title}: today's connection prompt is waiting.`,
data: { couple_id: coupleId, challenge_id: challengeId, day: String(day) },
}))
}) })
notifications.push(...challengeNotifications) if (dueUserIds.length === 0) return [] as QueuedNotification[]
}
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification))) const updates: Record<string, unknown> = {
console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`) lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(),
}) }
dueUserIds.forEach((userId) => {
updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true
})
tx.update(challengeRef, updates)
const coupleId = coupleRef.id
return dueUserIds.map((userId) => ({
userId,
type: 'challenge_day_ready' as const,
title: `Day ${day} is ready`,
body: `${catalogEntry.title}: today's connection prompt is waiting.`,
data: { couple_id: coupleId, challenge_id: challengeId, day: String(day) },
}))
})
notifications.push(...challengeNotifications)
}
// Per-recipient failures are isolated so one bad send can't abort the batch.
await Promise.allSettled(notifications.map((notification) => sendNotification(db, messaging, notification)))
logger.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`)
})
function reminderKey(userId: string, day: number): string { function reminderKey(userId: string, day: number): string {
return `${userId.replace(/[^\w-]/g, '_')}_${day}` return `${userId.replace(/[^\w-]/g, '_')}_${day}`
@ -175,12 +174,12 @@ async function sendNotification(
// Challenge-day reminders are retention nudges → respect the promotional opt-out (default on). // Challenge-day reminders are retention nudges → respect the promotional opt-out (default on).
// (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.) // (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.)
if (notification.type === 'challenge_day_ready' && userData?.notifPromotional === false) { if (notification.type === 'challenge_day_ready' && userData?.notifPromotional === false) {
console.log(`[sendNotification] skip ${notification.userId} — promotional off`) logger.log(`[sendNotification] skip ${notification.userId} — promotional off`)
return return
} }
// Honor the recipient's quiet hours for every scheduled push. // Honor the recipient's quiet hours for every scheduled push.
if (recipientInQuietHours(userData)) { if (recipientInQuietHours(userData)) {
console.log(`[sendNotification] skip ${notification.userId} — quiet hours`) logger.log(`[sendNotification] skip ${notification.userId} — quiet hours`)
return return
} }
@ -196,72 +195,26 @@ async function sendNotification(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const tokens = await getUserTokens(db, notification.userId, userData) await sendPushToUser(
if (tokens.length === 0) { db,
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`) messaging,
return notification.userId,
} {
const message: admin.messaging.Message = {
token: tokens[0],
notification: {
title: notification.title,
body: notification.body,
},
// E-OBS: challenge reminders → Reminders channel; capsule-unlocked → partner-activity channel.
android: {
notification: { notification: {
channelId: notification.type === 'challenge_day_ready' ? 'reminders' : 'partner_activity', title: notification.title,
body: notification.body,
},
// E-OBS: challenge reminders → Reminders channel; capsule-unlocked → partner-activity channel.
android: {
notification: {
channelId: notification.type === 'challenge_day_ready' ? 'reminders' : 'partner_activity',
},
},
data: {
type: notification.type,
...notification.data,
}, },
}, },
data: { userData,
type: notification.type,
...notification.data,
},
}
const sendResults = await Promise.allSettled(
tokens.map((token) => messaging.send({ ...message, 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(`[sendNotification] some notifications failed:`, failures)
}
await pruneDeadTokens(db, notification.userId, tokens, sendResults)
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string,
userData?: admin.firestore.DocumentData
): Promise<string[]> {
const tokens: string[] = []
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
const legacyToken = data?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
const tokenSnapshot = await db
.collection('users')
.doc(userId)
.collection('fcmTokens')
.get()
tokenSnapshot.docs.forEach((doc) => {
const token = doc.data()?.token
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
tokens.push(token)
}
})
return tokens
} }

View File

@ -1,7 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler'
import { recipientInQuietHours } from './quietHours' import { recipientInQuietHours } from './quietHours'
import { pruneDeadTokens } from './pruneTokens' import { sendPushToUser } from './push'
import { logger } from '../log'
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000 const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000
@ -18,10 +19,9 @@ const REENGAGEMENT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000
* *
* Requires a Firestore composite index on couples: lastAnsweredAt ASC. * Requires a Firestore composite index on couples: lastAnsweredAt ASC.
*/ */
export const sendReengagementReminder = functions.pubsub export const sendReengagementReminder = onSchedule(
.schedule('0 12 * * *') { schedule: '0 12 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 },
.timeZone('America/Chicago') async () => {
.onRun(async () => {
const db = admin.firestore() const db = admin.firestore()
const messaging = admin.messaging() const messaging = admin.messaging()
const now = Date.now() const now = Date.now()
@ -38,7 +38,8 @@ export const sendReengagementReminder = functions.pubsub
let notified = 0 let notified = 0
let skipped = 0 let skipped = 0
await Promise.all( // Per-couple failures are isolated so one bad couple can't abort the whole run.
await Promise.allSettled(
snap.docs.map(async (coupleDoc) => { snap.docs.map(async (coupleDoc) => {
const data = coupleDoc.data() const data = coupleDoc.data()
const coupleId = coupleDoc.id const coupleId = coupleDoc.id
@ -65,13 +66,14 @@ export const sendReengagementReminder = functions.pubsub
if (!claimed) { skipped++; return } if (!claimed) { skipped++; return }
await Promise.all(userIds.map((uid) => sendNudge(db, messaging, uid, coupleId))) await Promise.allSettled(userIds.map((uid) => sendNudge(db, messaging, uid, coupleId)))
notified += userIds.length notified += userIds.length
}) })
) )
console.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`) logger.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`)
}) }
)
async function sendNudge( async function sendNudge(
db: admin.firestore.Firestore, db: admin.firestore.Firestore,
@ -84,11 +86,11 @@ async function sendNudge(
// Re-engagement is a promotional nudge — respect the opt-out (default on) and quiet hours. // Re-engagement is a promotional nudge — respect the opt-out (default on) and quiet hours.
if (userData?.notifPromotional === false) { if (userData?.notifPromotional === false) {
console.log(`[sendReengagementReminder] skip ${userId} — promotional off`) logger.log(`[sendReengagementReminder] skip ${userId} — promotional off`)
return return
} }
if (recipientInQuietHours(userData)) { if (recipientInQuietHours(userData)) {
console.log(`[sendReengagementReminder] skip ${userId} — quiet hours`) logger.log(`[sendReengagementReminder] skip ${userId} — quiet hours`)
return return
} }
@ -100,42 +102,21 @@ async function sendNudge(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const tokens = await getUserTokens(db, userId, userData) await sendPushToUser(
if (tokens.length === 0) return db,
messaging,
const results = await Promise.allSettled( userId,
tokens.map((token) => {
messaging.send({ notification: {
token, title: "It's been a while.",
notification: { body: "Tonight's question is a good reason to reconnect.",
title: "It's been a while.", },
body: "Tonight's question is a good reason to reconnect.", android: { notification: { channelId: 'reminders' } }, // E-OBS
}, data: {
android: { notification: { channelId: 'reminders' } }, // E-OBS type: 'reengagement',
data: { couple_id: coupleId,
type: 'reengagement', },
couple_id: coupleId, },
}, userData,
})
)
) )
await pruneDeadTokens(db, userId, tokens, results)
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string,
userData?: admin.firestore.DocumentData
): Promise<string[]> {
const tokens: string[] = []
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
const legacy = data?.fcmToken
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
const snap = await db.collection('users').doc(userId).collection('fcmTokens').get()
snap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
return tokens
} }

View File

@ -1,7 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler'
import { recipientInQuietHours } from './quietHours' import { recipientInQuietHours } from './quietHours'
import { pruneDeadTokens } from './pruneTokens' import { sendPushToUser } from './push'
import { chicagoDateKey, toMillis } from './time'
import { logger } from '../log'
/** /**
* Streak reminder an evening "don't lose your streak" nudge. * Streak reminder an evening "don't lose your streak" nudge.
@ -16,10 +18,9 @@ import { pruneDeadTokens } from './pruneTokens'
* local day true per-timezone firing is a future refinement; quiet-hours suppression keeps a mistimed * local day true per-timezone firing is a future refinement; quiet-hours suppression keeps a mistimed
* fire from landing at a bad local hour. (Streak day-boundary note in the plan.) * fire from landing at a bad local hour. (Streak day-boundary note in the plan.)
*/ */
export const sendStreakReminder = functions.pubsub export const sendStreakReminder = onSchedule(
.schedule('0 19 * * *') { schedule: '0 19 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 },
.timeZone('America/Chicago') async () => {
.onRun(async () => {
const db = admin.firestore() const db = admin.firestore()
const messaging = admin.messaging() const messaging = admin.messaging()
const todayKey = chicagoDateKey(new Date()) const todayKey = chicagoDateKey(new Date())
@ -59,13 +60,14 @@ export const sendStreakReminder = functions.pubsub
}) })
) )
results.forEach((r) => { results.forEach((r) => {
if (r.status === 'rejected') console.warn('[sendStreakReminder] couple failed:', r.reason) if (r.status === 'rejected') logger.warn('[sendStreakReminder] couple failed', { error: String(r.reason) })
}) })
console.log( logger.log(
`[sendStreakReminder] scanned ${coupleSnap.size} streak couples; notified ${notified}; skipped ${skipped}` `[sendStreakReminder] scanned ${coupleSnap.size} streak couples; notified ${notified}; skipped ${skipped}`
) )
}) }
)
async function sendStreakNudge( async function sendStreakNudge(
db: admin.firestore.Firestore, db: admin.firestore.Firestore,
@ -80,11 +82,11 @@ async function sendStreakNudge(
// Respect the user's Streak Reminder toggle (default on) and quiet hours. // Respect the user's Streak Reminder toggle (default on) and quiet hours.
if (userData?.notifStreakReminder === false) { if (userData?.notifStreakReminder === false) {
console.log(`[sendStreakReminder] skip ${userId} — streak reminder off`) logger.log(`[sendStreakReminder] skip ${userId} — streak reminder off`)
return return
} }
if (recipientInQuietHours(userData)) { if (recipientInQuietHours(userData)) {
console.log(`[sendStreakReminder] skip ${userId} — quiet hours`) logger.log(`[sendStreakReminder] skip ${userId} — quiet hours`)
return return
} }
@ -103,61 +105,15 @@ async function sendStreakNudge(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const tokens = await getUserTokens(db, userId, userData) await sendPushToUser(
if (tokens.length === 0) return db,
messaging,
const sendResults = await Promise.allSettled( userId,
tokens.map((token) => {
messaging.send({ notification: { title, body },
token, android: { notification: { channelId: 'reminders' } },
notification: { title, body }, data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey },
android: { notification: { channelId: 'reminders' } }, },
data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey }, userData,
})
)
) )
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[sendStreakReminder] FCM failed for token ${tokens[i]}:`, result.reason)
}
})
await pruneDeadTokens(db, userId, tokens, sendResults)
}
/** YYYY-MM-DD for the given instant in America/Chicago (DST-safe; en-CA gives ISO date order). */
function chicagoDateKey(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
function toMillis(value: unknown): number {
if (value instanceof admin.firestore.Timestamp) return value.toMillis()
if (typeof value === 'number') return value
return 0
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string,
userData?: admin.firestore.DocumentData
): Promise<string[]> {
const tokens: string[] = []
const data = userData ?? (await db.collection('users').doc(userId).get()).data()
const legacyToken = data?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
const tokenSnap = await db.collection('users').doc(userId).collection('fcmTokens').get()
tokenSnap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t)
}
})
return tokens
} }

View File

@ -0,0 +1,28 @@
import * as admin from 'firebase-admin'
/**
* Shared date/time helpers for the scheduled jobs, consolidated from per-file copies
* (streakReminder's chicagoDateKey/toMillis, scheduledOutcomesReminder's millisFromFirestoreValue).
* The CST assignment helpers in questions/assignDailyQuestion.ts are a separate, unit-tested set and
* stay there.
*/
/** YYYY-MM-DD for the given instant in America/Chicago (DST-safe; en-CA gives ISO date order). */
export function chicagoDateKey(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
/** Best-effort milliseconds from a Firestore Timestamp, a raw number, or any `{ toMillis() }` value. */
export function toMillis(value: unknown): number {
if (typeof value === 'number') return value
if (value instanceof admin.firestore.Timestamp) return value.toMillis()
if (value != null && typeof (value as { toMillis?: unknown }).toMillis === 'function') {
return (value as { toMillis: () => number }).toMillis()
}
return 0
}

View File

@ -1,6 +1,7 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler'
import { logger } from '../log'
/** /**
* Scheduled function that assigns one daily question per couple every day. * Scheduled function that assigns one daily question per couple every day.
@ -15,17 +16,16 @@ import * as admin from 'firebase-admin'
* Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write * Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write
* only (`allow write: if false` in rules). * only (`allow write: if false` in rules).
*/ */
export const assignDailyQuestion = functions.pubsub export const assignDailyQuestion = onSchedule(
.schedule('0 23 * * *') { schedule: '0 23 * * *', timeZone: 'America/Chicago', memory: '512MiB', timeoutSeconds: 300 },
.timeZone('America/Chicago') async () => {
.onRun(async () => {
const db = admin.firestore() const db = admin.firestore()
const today = cstDateString() const today = cstDateString()
const nextDay = nextCstDateString() const nextDay = nextCstDateString()
const questionId = await pickDailyQuestionId(today) const questionId = await pickDailyQuestionId(today)
if (!questionId) { if (!questionId) {
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment') logger.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
return return
} }
@ -53,15 +53,16 @@ export const assignDailyQuestion = functions.pubsub
// Already assigned for today — idempotent. // Already assigned for today — idempotent.
return return
} }
console.error(`[assignDailyQuestion] failed for ${coupleId}:`, err) logger.error(`[assignDailyQuestion] failed for ${coupleId}`, { error: String(err) })
} }
}) })
await Promise.all(writes) await Promise.all(writes)
console.log( logger.log(
`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}` `[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`
) )
}) }
)
/** /**
* Callable function for immediate daily question assignment. * Callable function for immediate daily question assignment.