full
This commit is contained in:
245
services/SmartNotificationsCron.js
Normal file
245
services/SmartNotificationsCron.js
Normal file
@@ -0,0 +1,245 @@
|
||||
const cron = require('node-cron')
|
||||
const UserModel = require('../models/UserModel')
|
||||
const UserFollowModel = require('../models/UserFollowModel')
|
||||
const PostModel = require('../models/PostModel')
|
||||
const NotificationModel = require('../models/NotificationModel')
|
||||
const SmartNotificationLogModel = require('../models/SmartNotificationLogModel')
|
||||
const { postStatusFilter } = require('../utils/moderationStatus')
|
||||
const { sendPushToTokens } = require('../utils/pushNotification')
|
||||
const { getRankedExplorePosts, getPredictedTrendPosts } = require('../controllers/application/users/getUserController')
|
||||
|
||||
let ioInstance = null
|
||||
|
||||
/** یکبار از index.js صدا زده میشه — مثل الگوی OrderLifecycleCron.js */
|
||||
function setIo (io) {
|
||||
ioInstance = io
|
||||
}
|
||||
|
||||
const MAX_POSTS_PER_RUN = 10
|
||||
const MAX_RECIPIENTS_PER_POST = 500
|
||||
|
||||
/**
|
||||
* برای یک پست، کاربرانی با همون تخصص خالق رو پیدا میکنه (نه اجرای کامل الگوریتم پرسنالایز
|
||||
* برای همهی کاربرها که برای یه کرون خیلی سنگینه)، فالوورهای موجود و کاربران بلاکشده رو کنار
|
||||
* میذاره، و فقط کسایی که قبلاً دربارهی همین پست/نوع نوتیف نشدن رو نوتیف میکنه
|
||||
*/
|
||||
async function notifyForPost (post, type, buildMessage) {
|
||||
// نکتهی مهم: قبلاً اینجا `if (!post?.expertise) return` بود که چون بیشتر کاربرهای عادی
|
||||
// (غیرحرفهای) اصلاً فیلد expertise رو پر نمیکنن، عملاً برای اکثر پستها هیچ نوتیفی
|
||||
// ارسال نمیشد — اینجوری کل سیستم «نوتیف هوشمند» در عمل ساکت بود. حالا وقتی expertise
|
||||
// موجوده برای هدفگیری دقیقتر استفاده میشه، وگرنه بهجاش کاربران فعال اخیر (بهجز
|
||||
// فالوورها/بلاکشدهها/خودِ نویسنده) هدف قرار میگیرن — یعنی دیگه هیچ پستی بهخاطر نبودِ
|
||||
// expertise بهکل نادیده گرفته نمیشه
|
||||
if (!post?.user_id) return
|
||||
|
||||
const followerIds = await UserFollowModel.find({ followingId: post.user_id })
|
||||
.distinct('followerId')
|
||||
|
||||
const baseFilter = {
|
||||
_id: { $nin: [...followerIds, post.user_id] },
|
||||
blocked_users: { $ne: post.user_id }
|
||||
}
|
||||
const candidates = await UserModel.find(
|
||||
post.expertise ? { ...baseFilter, expertise: post.expertise } : baseFilter
|
||||
)
|
||||
.select('_id fcm_tokens')
|
||||
.sort({ updatedAt: -1 })
|
||||
.limit(MAX_RECIPIENTS_PER_POST)
|
||||
.lean()
|
||||
|
||||
if (candidates.length === 0) return
|
||||
|
||||
const candidateIds = candidates.map((u) => u._id)
|
||||
const alreadyNotified = await SmartNotificationLogModel.find({
|
||||
user_id: { $in: candidateIds },
|
||||
post_id: post._id,
|
||||
type
|
||||
}).distinct('user_id')
|
||||
const alreadyNotifiedSet = new Set(alreadyNotified.map(String))
|
||||
|
||||
const fresh = candidates.filter((u) => !alreadyNotifiedSet.has(String(u._id)))
|
||||
if (fresh.length === 0) return
|
||||
|
||||
const { title, description } = buildMessage(post)
|
||||
|
||||
await NotificationModel.insertMany(
|
||||
fresh.map((u) => ({
|
||||
user_id: u._id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
project_post_id: post._id,
|
||||
actor_id: post.user_id
|
||||
}))
|
||||
)
|
||||
|
||||
await SmartNotificationLogModel.insertMany(
|
||||
fresh.map((u) => ({ user_id: u._id, post_id: post._id, type })),
|
||||
{ ordered: false }
|
||||
).catch(() => {}) // برخورد با یونیک ایندکس روی درخواستهای موازی رو نادیده بگیر
|
||||
|
||||
if (ioInstance) {
|
||||
fresh.forEach((u) => {
|
||||
try {
|
||||
ioInstance.to(`user:${String(u._id)}`).emit('notification', { type, title, description })
|
||||
} catch (_) {}
|
||||
})
|
||||
}
|
||||
|
||||
const tokens = fresh.flatMap((u) => u.fcm_tokens || [])
|
||||
if (tokens.length > 0) {
|
||||
await sendPushToTokens(tokens, {
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
data: { project_post_id: String(post._id) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const basePostFilter = () => ({ status: postStatusFilter(), is_archived: { $ne: true } })
|
||||
|
||||
/** پستهای پربازدید ۳ روز اخیر — یادآوری روزانه */
|
||||
async function runTrending3DayJob () {
|
||||
try {
|
||||
const { posts } = await getRankedExplorePosts({
|
||||
windowDays: 3,
|
||||
page: 1,
|
||||
limit: MAX_POSTS_PER_RUN,
|
||||
postFilter: basePostFilter(),
|
||||
decodedToken: null
|
||||
})
|
||||
for (const post of posts) {
|
||||
await notifyForPost(post, 'trending_post_3day', () => ({
|
||||
title: 'پست پربازدید',
|
||||
description: 'یک پست پربازدید توی ۳ روز اخیر رو از دست ندید'
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SmartNotificationsCron] trending-3day job failed:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** ترندهای جدید هفته — یادآوری هفتگی، بر پایهی شتاب رشد نه فقط محبوبیت خام */
|
||||
async function runWeeklyTrendJob () {
|
||||
try {
|
||||
const { posts } = await getPredictedTrendPosts({
|
||||
page: 1,
|
||||
limit: MAX_POSTS_PER_RUN,
|
||||
postFilter: basePostFilter(),
|
||||
decodedToken: null
|
||||
})
|
||||
for (const post of posts) {
|
||||
await notifyForPost(post, 'weekly_new_trend', () => ({
|
||||
title: 'ترند جدید این هفته',
|
||||
description: 'یک ترند تازه این هفته داره داغ میشه'
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SmartNotificationsCron] weekly-trend job failed:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const INACTIVITY_THRESHOLD_MS = 24 * 60 * 60 * 1000
|
||||
const MAX_INACTIVE_USERS_PER_RUN = 200
|
||||
|
||||
/**
|
||||
* برای کاربرانی که بیش از ۲۴ ساعت وارد اپ نشدن: اگه یکی از افرادی که دنبال میکنن، بعد از
|
||||
* آخرین آنلاینبودنشون پست تازهای گذاشته باشه، یادآوری میفرسته («فلان کاربر پست جدید
|
||||
* گذاشت») تا برگردن اپ رو باز کنن — دقیقاً طبق درخواست کاربر. هر ساعت اجرا میشه؛ بهخاطر
|
||||
* دیدوگ SmartNotificationLog (کلید user_id+post_id+type) برای یک پستِ خاص فقط یکبار
|
||||
* نوتیف میره، نه هر بار که کرون اجرا میشه
|
||||
*/
|
||||
async function runInactivityReengagementJob () {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - INACTIVITY_THRESHOLD_MS)
|
||||
const inactiveUsers = await UserModel.find({
|
||||
last_online: { $ne: null, $lt: cutoff },
|
||||
account_status: { $ne: 'deactivated' }
|
||||
})
|
||||
.select('_id fcm_tokens fcm_web_tokens last_online')
|
||||
.sort({ last_online: 1 })
|
||||
.limit(MAX_INACTIVE_USERS_PER_RUN)
|
||||
.lean()
|
||||
|
||||
for (const user of inactiveUsers) {
|
||||
const followingIds = await UserFollowModel.find({ followerId: user._id })
|
||||
.distinct('followingId')
|
||||
if (followingIds.length === 0) continue
|
||||
|
||||
const post = await PostModel.findOne({
|
||||
...basePostFilter(),
|
||||
user_id: { $in: followingIds },
|
||||
createdAt: { $gt: user.last_online }
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.select('_id user_id')
|
||||
.lean()
|
||||
if (!post) continue
|
||||
|
||||
const alreadyNotified = await SmartNotificationLogModel.exists({
|
||||
user_id: user._id,
|
||||
post_id: post._id,
|
||||
type: 'inactivity_reengagement'
|
||||
})
|
||||
if (alreadyNotified) continue
|
||||
|
||||
const author = await UserModel.findById(post.user_id)
|
||||
.select('first_name last_name user_name')
|
||||
.lean()
|
||||
if (!author) continue
|
||||
const authorName =
|
||||
`${author.first_name || ''} ${author.last_name || ''}`.trim() ||
|
||||
author.user_name ||
|
||||
'یکی از افرادی که دنبال میکنید'
|
||||
|
||||
const title = 'دلمون براتون تنگ شده بود'
|
||||
const description = `${authorName} یک پست جدید گذاشت`
|
||||
|
||||
await NotificationModel.create({
|
||||
user_id: user._id,
|
||||
type: 'inactivity_reengagement',
|
||||
title,
|
||||
description,
|
||||
project_post_id: post._id,
|
||||
actor_id: post.user_id
|
||||
})
|
||||
await SmartNotificationLogModel.create({
|
||||
user_id: user._id,
|
||||
post_id: post._id,
|
||||
type: 'inactivity_reengagement'
|
||||
}).catch(() => {}) // برخورد با یونیک ایندکس روی اجرای موازی رو نادیده بگیر
|
||||
|
||||
if (ioInstance) {
|
||||
try {
|
||||
ioInstance.to(`user:${String(user._id)}`).emit('notification', {
|
||||
type: 'inactivity_reengagement',
|
||||
title,
|
||||
description
|
||||
})
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const tokens = [...(user.fcm_tokens || []), ...(user.fcm_web_tokens || [])]
|
||||
if (tokens.length > 0) {
|
||||
await sendPushToTokens(tokens, {
|
||||
title,
|
||||
description,
|
||||
type: 'inactivity_reengagement',
|
||||
data: { project_post_id: String(post._id) }
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SmartNotificationsCron] inactivity-reengagement job failed:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// هر روز ساعت ۹ صبح به وقت تهران
|
||||
cron.schedule('0 9 * * *', runTrending3DayJob, { scheduled: true, timezone: 'Asia/Tehran' })
|
||||
// هر شنبه ساعت ۹ صبح به وقت تهران — شروع هفتهی ایرانی
|
||||
cron.schedule('0 9 * * 6', runWeeklyTrendJob, { scheduled: true, timezone: 'Asia/Tehran' })
|
||||
// هر ساعت — چون آستانهی غیرفعالی (۲۴ ساعت) برای کاربرهای مختلف در زمانهای مختلفی از روز رد میشه
|
||||
cron.schedule('0 * * * *', runInactivityReengagementJob, { scheduled: true, timezone: 'Asia/Tehran' })
|
||||
|
||||
module.exports = { setIo, runTrending3DayJob, runWeeklyTrendJob, runInactivityReengagementJob }
|
||||
Reference in New Issue
Block a user