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:
parent
4040abbf28
commit
6681bf1518
|
|
@ -36,8 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||
exports.aggregateOutcomeStats = exports.MIN_COHORT = exports.FOLLOWUP_DAYS = exports.SCORE_KEYS = void 0;
|
||||
exports.aggregate = aggregate;
|
||||
exports.extractCoupleOutcome = extractCoupleOutcome;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
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.FOLLOWUP_DAYS = ['day_30', 'day_60', 'day_90'];
|
||||
exports.MIN_COHORT = 50;
|
||||
|
|
@ -83,7 +84,7 @@ function extractCoupleOutcome(outcomeDocs) {
|
|||
}
|
||||
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 couples = [];
|
||||
const couplesSnap = await db.collection('couples').get();
|
||||
|
|
@ -93,8 +94,7 @@ exports.aggregateOutcomeStats = functions.pubsub.schedule('every 24 hours').onRu
|
|||
}
|
||||
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() }));
|
||||
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(' '));
|
||||
return null;
|
||||
});
|
||||
//# sourceMappingURL=aggregateOutcomes.js.map
|
||||
|
|
@ -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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"}
|
||||
|
|
@ -34,16 +34,16 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.scheduledOutcomesReminder = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const scheduler_1 = require("firebase-functions/v2/scheduler");
|
||||
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 REMINDER_DAYS = [30, 60, 90];
|
||||
const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' };
|
||||
exports.scheduledOutcomesReminder = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
exports.scheduledOutcomesReminder = (0, scheduler_1.onSchedule)({ schedule: 'every 24 hours', timeoutSeconds: 180 }, async () => {
|
||||
var _a, _b;
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
|
|
@ -53,7 +53,7 @@ exports.scheduledOutcomesReminder = functions.pubsub
|
|||
for (const coupleDoc of couplesSnap.docs) {
|
||||
const coupleId = coupleDoc.id;
|
||||
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)
|
||||
continue;
|
||||
const ageDays = Math.floor((now - createdAt) / DAY_MS);
|
||||
|
|
@ -82,26 +82,16 @@ exports.scheduledOutcomesReminder = functions.pubsub
|
|||
notifications.push({ userId, coupleId, day: dayLabel, title, body });
|
||||
}
|
||||
}
|
||||
await Promise.all(notifications.map((notification) => sendOutcomeReminder(db, messaging, notification)));
|
||||
console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`);
|
||||
// Per-recipient failures are isolated so one bad send can't abort the batch.
|
||||
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) {
|
||||
const userDoc = await db.collection('users').doc(notification.userId).get();
|
||||
const userData = userDoc.data();
|
||||
// Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate).
|
||||
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;
|
||||
}
|
||||
await db
|
||||
|
|
@ -117,13 +107,7 @@ async function sendOutcomeReminder(db, messaging, notification) {
|
|||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const tokens = await getUserTokens(db, notification.userId, userData);
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`);
|
||||
return;
|
||||
}
|
||||
const message = {
|
||||
token: tokens[0],
|
||||
await (0, push_1.sendPushToUser)(db, messaging, notification.userId, {
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
|
|
@ -134,34 +118,6 @@ async function sendOutcomeReminder(db, messaging, notification) {
|
|||
coupleId: notification.coupleId,
|
||||
day: String(notification.day),
|
||||
},
|
||||
};
|
||||
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;
|
||||
}, userData);
|
||||
}
|
||||
//# sourceMappingURL=scheduledOutcomesReminder.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sendDailyQuestionProactiveReminder = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const scheduler_1 = require("firebase-functions/v2/scheduler");
|
||||
const quietHours_1 = require("./quietHours");
|
||||
const pruneTokens_1 = require("./pruneTokens");
|
||||
const push_1 = require("./push");
|
||||
const log_1 = require("../log");
|
||||
/**
|
||||
* Proactive daily question reminder.
|
||||
*
|
||||
|
|
@ -55,10 +56,7 @@ const pruneTokens_1 = require("./pruneTokens");
|
|||
* 5. Record automated_daily_reminder/{docId} so this function is idempotent
|
||||
* on re-runs.
|
||||
*/
|
||||
exports.sendDailyQuestionProactiveReminder = functions.pubsub
|
||||
.schedule('0 16 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
exports.sendDailyQuestionProactiveReminder = (0, scheduler_1.onSchedule)({ schedule: '0 16 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const now = Date.now();
|
||||
|
|
@ -71,7 +69,8 @@ exports.sendDailyQuestionProactiveReminder = functions.pubsub
|
|||
.get();
|
||||
let notified = 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;
|
||||
const coupleRef = questionDoc.ref.parent.parent;
|
||||
if (!coupleRef)
|
||||
|
|
@ -122,21 +121,21 @@ exports.sendDailyQuestionProactiveReminder = functions.pubsub
|
|||
return;
|
||||
}
|
||||
// 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;
|
||||
}));
|
||||
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) {
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
const userData = userDoc.data();
|
||||
// Respect the user's Daily Reminder toggle (default on) and quiet hours.
|
||||
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;
|
||||
}
|
||||
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
|
||||
console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`);
|
||||
log_1.logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`);
|
||||
return;
|
||||
}
|
||||
// In-app notification record.
|
||||
|
|
@ -151,12 +150,7 @@ async function sendReminder(db, messaging, userId, coupleId, questionDate) {
|
|||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
// FCM push.
|
||||
const tokens = await getUserTokens(db, userId, userData);
|
||||
if (tokens.length === 0)
|
||||
return;
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({
|
||||
token,
|
||||
await (0, push_1.sendPushToUser)(db, messaging, userId, {
|
||||
notification: {
|
||||
title: "Tonight's question is waiting.",
|
||||
body: 'Answer together before it expires.',
|
||||
|
|
@ -167,33 +161,6 @@ async function sendReminder(db, messaging, userId, coupleId, questionDate) {
|
|||
couple_id: coupleId,
|
||||
question_date: questionDate,
|
||||
},
|
||||
})));
|
||||
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;
|
||||
}, userData);
|
||||
}
|
||||
//# sourceMappingURL=dailyQuestionReminder.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const scheduler_1 = require("firebase-functions/v2/scheduler");
|
||||
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 CHALLENGE_TITLES = {
|
||||
gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
|
||||
|
|
@ -45,9 +46,7 @@ const CHALLENGE_TITLES = {
|
|||
quality_time: { title: 'Quality Time', durationDays: 7 },
|
||||
deep_conversations: { title: 'Deep Conversations', durationDays: 7 },
|
||||
};
|
||||
exports.unlockDueMemoryCapsules = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
exports.unlockDueMemoryCapsules = (0, scheduler_1.onSchedule)('every 1 hours', async () => {
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const now = Date.now();
|
||||
|
|
@ -94,12 +93,11 @@ exports.unlockDueMemoryCapsules = functions.pubsub
|
|||
});
|
||||
notifications.push(...capsuleNotifications);
|
||||
}
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)));
|
||||
console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`);
|
||||
// Per-recipient failures are isolated so one bad send can't abort the batch.
|
||||
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
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
exports.sendChallengeDayReminders = (0, scheduler_1.onSchedule)('every 24 hours', async () => {
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const now = Date.now();
|
||||
|
|
@ -166,8 +164,9 @@ exports.sendChallengeDayReminders = functions.pubsub
|
|||
});
|
||||
notifications.push(...challengeNotifications);
|
||||
}
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)));
|
||||
console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`);
|
||||
// Per-recipient failures are isolated so one bad send can't abort the batch.
|
||||
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) {
|
||||
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).
|
||||
// (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) {
|
||||
console.log(`[sendNotification] skip ${notification.userId} — promotional off`);
|
||||
log_1.logger.log(`[sendNotification] skip ${notification.userId} — promotional off`);
|
||||
return;
|
||||
}
|
||||
// Honor the recipient's quiet hours for every scheduled push.
|
||||
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;
|
||||
}
|
||||
await db
|
||||
|
|
@ -197,13 +196,7 @@ async function sendNotification(db, messaging, notification) {
|
|||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const tokens = await getUserTokens(db, notification.userId, userData);
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`);
|
||||
return;
|
||||
}
|
||||
const message = {
|
||||
token: tokens[0],
|
||||
await (0, push_1.sendPushToUser)(db, messaging, notification.userId, {
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
|
|
@ -215,38 +208,6 @@ async function sendNotification(db, messaging, notification) {
|
|||
},
|
||||
},
|
||||
data: Object.assign({ type: notification.type }, notification.data),
|
||||
};
|
||||
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;
|
||||
}, userData);
|
||||
}
|
||||
//# sourceMappingURL=gameRetention.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sendReengagementReminder = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const scheduler_1 = require("firebase-functions/v2/scheduler");
|
||||
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 TEN_DAYS_MS = 10 * 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.
|
||||
*/
|
||||
exports.sendReengagementReminder = functions.pubsub
|
||||
.schedule('0 12 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
exports.sendReengagementReminder = (0, scheduler_1.onSchedule)({ schedule: '0 12 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const now = Date.now();
|
||||
|
|
@ -69,7 +67,8 @@ exports.sendReengagementReminder = functions.pubsub
|
|||
.get();
|
||||
let notified = 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;
|
||||
const data = coupleDoc.data();
|
||||
const coupleId = coupleDoc.id;
|
||||
|
|
@ -100,21 +99,21 @@ exports.sendReengagementReminder = functions.pubsub
|
|||
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;
|
||||
}));
|
||||
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) {
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
const userData = userDoc.data();
|
||||
// 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) {
|
||||
console.log(`[sendReengagementReminder] skip ${userId} — promotional off`);
|
||||
log_1.logger.log(`[sendReengagementReminder] skip ${userId} — promotional off`);
|
||||
return;
|
||||
}
|
||||
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
|
||||
console.log(`[sendReengagementReminder] skip ${userId} — quiet hours`);
|
||||
log_1.logger.log(`[sendReengagementReminder] skip ${userId} — quiet hours`);
|
||||
return;
|
||||
}
|
||||
await db.collection('users').doc(userId).collection('notification_queue').add({
|
||||
|
|
@ -124,11 +123,7 @@ async function sendNudge(db, messaging, userId, coupleId) {
|
|||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const tokens = await getUserTokens(db, userId, userData);
|
||||
if (tokens.length === 0)
|
||||
return;
|
||||
const results = await Promise.allSettled(tokens.map((token) => messaging.send({
|
||||
token,
|
||||
await (0, push_1.sendPushToUser)(db, messaging, userId, {
|
||||
notification: {
|
||||
title: "It's been a while.",
|
||||
body: "Tonight's question is a good reason to reconnect.",
|
||||
|
|
@ -138,22 +133,6 @@ async function sendNudge(db, messaging, userId, coupleId) {
|
|||
type: 'reengagement',
|
||||
couple_id: coupleId,
|
||||
},
|
||||
})));
|
||||
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;
|
||||
}, userData);
|
||||
}
|
||||
//# sourceMappingURL=reengagement.js.map
|
||||
|
|
@ -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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"}
|
||||
|
|
@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sendStreakReminder = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const scheduler_1 = require("firebase-functions/v2/scheduler");
|
||||
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.
|
||||
*
|
||||
|
|
@ -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
|
||||
* fire from landing at a bad local hour. (Streak day-boundary note in the plan.)
|
||||
*/
|
||||
exports.sendStreakReminder = functions.pubsub
|
||||
.schedule('0 19 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
exports.sendStreakReminder = (0, scheduler_1.onSchedule)({ schedule: '0 19 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
|
||||
const db = admin.firestore();
|
||||
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();
|
||||
let notified = 0;
|
||||
let skipped = 0;
|
||||
|
|
@ -70,8 +69,8 @@ exports.sendStreakReminder = functions.pubsub
|
|||
return;
|
||||
}
|
||||
// Already did a shared action today → the streak is safe, no nudge needed.
|
||||
const lastMs = toMillis(couple.lastAnsweredAt);
|
||||
if (lastMs > 0 && chicagoDateKey(new Date(lastMs)) === todayKey) {
|
||||
const lastMs = (0, time_1.toMillis)(couple.lastAnsweredAt);
|
||||
if (lastMs > 0 && (0, time_1.chicagoDateKey)(new Date(lastMs)) === todayKey) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
|
|
@ -99,20 +98,20 @@ exports.sendStreakReminder = functions.pubsub
|
|||
}));
|
||||
results.forEach((r) => {
|
||||
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) {
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
const userData = userDoc.data();
|
||||
// Respect the user's Streak Reminder toggle (default on) and quiet hours.
|
||||
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;
|
||||
}
|
||||
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
|
||||
console.log(`[sendStreakReminder] skip ${userId} — quiet hours`);
|
||||
log_1.logger.log(`[sendStreakReminder] skip ${userId} — quiet hours`);
|
||||
return;
|
||||
}
|
||||
const title = `🔥 Keep your ${streak}-day streak`;
|
||||
|
|
@ -128,53 +127,10 @@ async function sendStreakNudge(db, messaging, userId, coupleId, streak, dateKey)
|
|||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const tokens = await getUserTokens(db, userId, userData);
|
||||
if (tokens.length === 0)
|
||||
return;
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({
|
||||
token,
|
||||
await (0, push_1.sendPushToUser)(db, messaging, userId, {
|
||||
notification: { title, body },
|
||||
android: { notification: { channelId: 'reminders' } },
|
||||
data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey },
|
||||
})));
|
||||
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;
|
||||
}, userData);
|
||||
}
|
||||
//# sourceMappingURL=streakReminder.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -39,6 +39,8 @@ exports.parseCstDate = parseCstDate;
|
|||
exports.timestampAt6PmCst = timestampAt6PmCst;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
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.
|
||||
*
|
||||
|
|
@ -52,16 +54,13 @@ const admin = __importStar(require("firebase-admin"));
|
|||
* Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write
|
||||
* only (`allow write: if false` in rules).
|
||||
*/
|
||||
exports.assignDailyQuestion = functions.pubsub
|
||||
.schedule('0 23 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
exports.assignDailyQuestion = (0, scheduler_1.onSchedule)({ schedule: '0 23 * * *', timeZone: 'America/Chicago', memory: '512MiB', timeoutSeconds: 300 }, async () => {
|
||||
const db = admin.firestore();
|
||||
const today = cstDateString();
|
||||
const nextDay = nextCstDateString();
|
||||
const questionId = await pickDailyQuestionId(today);
|
||||
if (!questionId) {
|
||||
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
|
||||
log_1.logger.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
|
||||
return;
|
||||
}
|
||||
const couplesSnap = await db.collection('couples').get();
|
||||
|
|
@ -88,11 +87,11 @@ exports.assignDailyQuestion = functions.pubsub
|
|||
// Already assigned for today — idempotent.
|
||||
return;
|
||||
}
|
||||
console.error(`[assignDailyQuestion] failed for ${coupleId}:`, err);
|
||||
log_1.logger.error(`[assignDailyQuestion] failed for ${coupleId}`, { error: String(err) });
|
||||
}
|
||||
});
|
||||
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.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler'
|
||||
import { logger } from '../log'
|
||||
|
||||
/**
|
||||
* Cloud Function: aggregateOutcomeStats
|
||||
|
|
@ -90,7 +91,9 @@ export function extractCoupleOutcome(
|
|||
return { deltas }
|
||||
}
|
||||
|
||||
export const aggregateOutcomeStats = functions.pubsub.schedule('every 24 hours').onRun(async () => {
|
||||
export const aggregateOutcomeStats = onSchedule(
|
||||
{ schedule: 'every 24 hours', memory: '512MiB', timeoutSeconds: 300 },
|
||||
async () => {
|
||||
const db = admin.firestore()
|
||||
|
||||
const couples: CoupleOutcome[] = []
|
||||
|
|
@ -109,9 +112,9 @@ export const aggregateOutcomeStats = functions.pubsub.schedule('every 24 hours')
|
|||
generatedAt: admin.firestore.Timestamp.now(),
|
||||
})
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
`[aggregateOutcomeStats] ${couples.length} couples → ` +
|
||||
FOLLOWUP_DAYS.map((d) => `${d}:${stats.windows[d].suppressed ? 'suppressed' : stats.windows[d].feltCloserPct + '%'}`).join(' ')
|
||||
)
|
||||
return null
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler'
|
||||
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
|
||||
|
|
@ -23,9 +25,9 @@ const DAY_MS = 24 * 60 * 60 * 1000
|
|||
const REMINDER_DAYS = [30, 60, 90] as const
|
||||
const DAY_KEY_MAP: Record<number, OutcomeDayKey> = { 30: 'day_30', 60: 'day_60', 90: 'day_90' }
|
||||
|
||||
export const scheduledOutcomesReminder = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
export const scheduledOutcomesReminder = onSchedule(
|
||||
{ schedule: 'every 24 hours', timeoutSeconds: 180 },
|
||||
async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
|
@ -42,7 +44,7 @@ export const scheduledOutcomesReminder = functions.pubsub
|
|||
for (const coupleDoc of couplesSnap.docs) {
|
||||
const coupleId = coupleDoc.id
|
||||
const data = coupleDoc.data() ?? {}
|
||||
const createdAt = millisFromFirestoreValue(data.createdAt)
|
||||
const createdAt = toMillis(data.createdAt)
|
||||
if (createdAt <= 0) continue
|
||||
|
||||
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) =>
|
||||
sendOutcomeReminder(db, messaging, notification)
|
||||
)
|
||||
)
|
||||
|
||||
console.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()
|
||||
logger.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
)
|
||||
|
||||
async function sendOutcomeReminder(
|
||||
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).
|
||||
if (recipientInQuietHours(userData)) {
|
||||
console.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`)
|
||||
logger.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -122,14 +114,11 @@ async function sendOutcomeReminder(
|
|||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const tokens = await getUserTokens(db, notification.userId, userData)
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const message: admin.messaging.Message = {
|
||||
token: tokens[0],
|
||||
await sendPushToUser(
|
||||
db,
|
||||
messaging,
|
||||
notification.userId,
|
||||
{
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
|
|
@ -140,48 +129,7 @@ async function sendOutcomeReminder(
|
|||
coupleId: notification.coupleId,
|
||||
day: String(notification.day),
|
||||
},
|
||||
}
|
||||
|
||||
const sendResults = await Promise.allSettled(
|
||||
tokens.map((token) => messaging.send({ ...message, token }))
|
||||
},
|
||||
userData,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler'
|
||||
import { recipientInQuietHours } from './quietHours'
|
||||
import { pruneDeadTokens } from './pruneTokens'
|
||||
import { sendPushToUser } from './push'
|
||||
import { logger } from '../log'
|
||||
|
||||
/**
|
||||
* Proactive daily question reminder.
|
||||
|
|
@ -20,10 +21,9 @@ import { pruneDeadTokens } from './pruneTokens'
|
|||
* 5. Record automated_daily_reminder/{docId} so this function is idempotent
|
||||
* on re-runs.
|
||||
*/
|
||||
export const sendDailyQuestionProactiveReminder = functions.pubsub
|
||||
.schedule('0 16 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
export const sendDailyQuestionProactiveReminder = onSchedule(
|
||||
{ schedule: '0 16 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 },
|
||||
async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
|
@ -39,7 +39,8 @@ export const sendDailyQuestionProactiveReminder = functions.pubsub
|
|||
let notified = 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) => {
|
||||
const coupleRef = questionDoc.ref.parent.parent
|
||||
if (!coupleRef) return
|
||||
|
|
@ -82,7 +83,7 @@ export const sendDailyQuestionProactiveReminder = functions.pubsub
|
|||
}
|
||||
|
||||
// Send FCM + notification_queue entry to every user in the couple.
|
||||
await Promise.all(
|
||||
await Promise.allSettled(
|
||||
userIds.map((userId) =>
|
||||
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}`
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
async function sendReminder(
|
||||
db: admin.firestore.Firestore,
|
||||
|
|
@ -108,11 +110,11 @@ async function sendReminder(
|
|||
|
||||
// Respect the user's Daily Reminder toggle (default on) and quiet hours.
|
||||
if (userData?.notifDailyReminder === false) {
|
||||
console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`)
|
||||
logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`)
|
||||
return
|
||||
}
|
||||
if (recipientInQuietHours(userData)) {
|
||||
console.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`)
|
||||
logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -129,14 +131,11 @@ async function sendReminder(
|
|||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// FCM push.
|
||||
const tokens = await getUserTokens(db, userId, userData)
|
||||
if (tokens.length === 0) return
|
||||
|
||||
const sendResults = await Promise.allSettled(
|
||||
tokens.map((token) =>
|
||||
messaging.send({
|
||||
token,
|
||||
await sendPushToUser(
|
||||
db,
|
||||
messaging,
|
||||
userId,
|
||||
{
|
||||
notification: {
|
||||
title: "Tonight's question is waiting.",
|
||||
body: 'Answer together before it expires.',
|
||||
|
|
@ -147,43 +146,7 @@ async function sendReminder(
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler'
|
||||
import { recipientInQuietHours } from './quietHours'
|
||||
import { pruneDeadTokens } from './pruneTokens'
|
||||
import { sendPushToUser } from './push'
|
||||
import { logger } from '../log'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
|
|
@ -22,9 +23,7 @@ interface QueuedNotification {
|
|||
data: Record<string, string>
|
||||
}
|
||||
|
||||
export const unlockDueMemoryCapsules = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
export const unlockDueMemoryCapsules = onSchedule('every 1 hours', async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
|
@ -78,13 +77,12 @@ export const unlockDueMemoryCapsules = functions.pubsub
|
|||
notifications.push(...capsuleNotifications)
|
||||
}
|
||||
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)))
|
||||
console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`)
|
||||
})
|
||||
// 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(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`)
|
||||
})
|
||||
|
||||
export const sendChallengeDayReminders = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
export const sendChallengeDayReminders = onSchedule('every 24 hours', async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
|
@ -156,9 +154,10 @@ export const sendChallengeDayReminders = functions.pubsub
|
|||
notifications.push(...challengeNotifications)
|
||||
}
|
||||
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)))
|
||||
console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`)
|
||||
})
|
||||
// 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 {
|
||||
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).
|
||||
// (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.)
|
||||
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
|
||||
}
|
||||
// Honor the recipient's quiet hours for every scheduled push.
|
||||
if (recipientInQuietHours(userData)) {
|
||||
console.log(`[sendNotification] skip ${notification.userId} — quiet hours`)
|
||||
logger.log(`[sendNotification] skip ${notification.userId} — quiet hours`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -196,14 +195,11 @@ async function sendNotification(
|
|||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const tokens = await getUserTokens(db, notification.userId, userData)
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const message: admin.messaging.Message = {
|
||||
token: tokens[0],
|
||||
await sendPushToUser(
|
||||
db,
|
||||
messaging,
|
||||
notification.userId,
|
||||
{
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
|
|
@ -218,50 +214,7 @@ async function sendNotification(
|
|||
type: notification.type,
|
||||
...notification.data,
|
||||
},
|
||||
}
|
||||
|
||||
const sendResults = await Promise.allSettled(
|
||||
tokens.map((token) => messaging.send({ ...message, token }))
|
||||
},
|
||||
userData,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler'
|
||||
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 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.
|
||||
*/
|
||||
export const sendReengagementReminder = functions.pubsub
|
||||
.schedule('0 12 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
export const sendReengagementReminder = onSchedule(
|
||||
{ schedule: '0 12 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 },
|
||||
async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
|
@ -38,7 +38,8 @@ export const sendReengagementReminder = functions.pubsub
|
|||
let notified = 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) => {
|
||||
const data = coupleDoc.data()
|
||||
const coupleId = coupleDoc.id
|
||||
|
|
@ -65,13 +66,14 @@ export const sendReengagementReminder = functions.pubsub
|
|||
|
||||
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
|
||||
})
|
||||
)
|
||||
|
||||
console.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`)
|
||||
})
|
||||
logger.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`)
|
||||
}
|
||||
)
|
||||
|
||||
async function sendNudge(
|
||||
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.
|
||||
if (userData?.notifPromotional === false) {
|
||||
console.log(`[sendReengagementReminder] skip ${userId} — promotional off`)
|
||||
logger.log(`[sendReengagementReminder] skip ${userId} — promotional off`)
|
||||
return
|
||||
}
|
||||
if (recipientInQuietHours(userData)) {
|
||||
console.log(`[sendReengagementReminder] skip ${userId} — quiet hours`)
|
||||
logger.log(`[sendReengagementReminder] skip ${userId} — quiet hours`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -100,13 +102,11 @@ async function sendNudge(
|
|||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const tokens = await getUserTokens(db, userId, userData)
|
||||
if (tokens.length === 0) return
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
tokens.map((token) =>
|
||||
messaging.send({
|
||||
token,
|
||||
await sendPushToUser(
|
||||
db,
|
||||
messaging,
|
||||
userId,
|
||||
{
|
||||
notification: {
|
||||
title: "It's been a while.",
|
||||
body: "Tonight's question is a good reason to reconnect.",
|
||||
|
|
@ -116,26 +116,7 @@ async function sendNudge(
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { onSchedule } from 'firebase-functions/v2/scheduler'
|
||||
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.
|
||||
|
|
@ -16,10 +18,9 @@ import { pruneDeadTokens } from './pruneTokens'
|
|||
* 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.)
|
||||
*/
|
||||
export const sendStreakReminder = functions.pubsub
|
||||
.schedule('0 19 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
export const sendStreakReminder = onSchedule(
|
||||
{ schedule: '0 19 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 },
|
||||
async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const todayKey = chicagoDateKey(new Date())
|
||||
|
|
@ -59,13 +60,14 @@ export const sendStreakReminder = functions.pubsub
|
|||
})
|
||||
)
|
||||
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}`
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
async function sendStreakNudge(
|
||||
db: admin.firestore.Firestore,
|
||||
|
|
@ -80,11 +82,11 @@ async function sendStreakNudge(
|
|||
|
||||
// Respect the user's Streak Reminder toggle (default on) and quiet hours.
|
||||
if (userData?.notifStreakReminder === false) {
|
||||
console.log(`[sendStreakReminder] skip ${userId} — streak reminder off`)
|
||||
logger.log(`[sendStreakReminder] skip ${userId} — streak reminder off`)
|
||||
return
|
||||
}
|
||||
if (recipientInQuietHours(userData)) {
|
||||
console.log(`[sendStreakReminder] skip ${userId} — quiet hours`)
|
||||
logger.log(`[sendStreakReminder] skip ${userId} — quiet hours`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -103,61 +105,15 @@ async function sendStreakNudge(
|
|||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const tokens = await getUserTokens(db, userId, userData)
|
||||
if (tokens.length === 0) return
|
||||
|
||||
const sendResults = await Promise.allSettled(
|
||||
tokens.map((token) =>
|
||||
messaging.send({
|
||||
token,
|
||||
await sendPushToUser(
|
||||
db,
|
||||
messaging,
|
||||
userId,
|
||||
{
|
||||
notification: { title, body },
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
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.
|
||||
|
|
@ -15,17 +16,16 @@ import * as admin from 'firebase-admin'
|
|||
* Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write
|
||||
* only (`allow write: if false` in rules).
|
||||
*/
|
||||
export const assignDailyQuestion = functions.pubsub
|
||||
.schedule('0 23 * * *')
|
||||
.timeZone('America/Chicago')
|
||||
.onRun(async () => {
|
||||
export const assignDailyQuestion = onSchedule(
|
||||
{ schedule: '0 23 * * *', timeZone: 'America/Chicago', memory: '512MiB', timeoutSeconds: 300 },
|
||||
async () => {
|
||||
const db = admin.firestore()
|
||||
const today = cstDateString()
|
||||
const nextDay = nextCstDateString()
|
||||
|
||||
const questionId = await pickDailyQuestionId(today)
|
||||
if (!questionId) {
|
||||
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
|
||||
logger.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -53,15 +53,16 @@ export const assignDailyQuestion = functions.pubsub
|
|||
// Already assigned for today — idempotent.
|
||||
return
|
||||
}
|
||||
console.error(`[assignDailyQuestion] failed for ${coupleId}:`, err)
|
||||
logger.error(`[assignDailyQuestion] failed for ${coupleId}`, { error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(writes)
|
||||
console.log(
|
||||
logger.log(
|
||||
`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} couples for ${today}`
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Callable function for immediate daily question assignment.
|
||||
|
|
|
|||
Loading…
Reference in New Issue