This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

View 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 }