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 }