full
This commit is contained in:
53
services/BookingLifecycleCron.js
Normal file
53
services/BookingLifecycleCron.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const cron = require('node-cron')
|
||||
const BookingModel = require('../models/BookingModel')
|
||||
const { notifyUser } = require('../utils/notifyUser')
|
||||
|
||||
const PAYMENT_TIMEOUT_MINUTES = 60
|
||||
|
||||
let ioInstance = null
|
||||
|
||||
/** Called once from index.js right after the socket.io server is created — cron jobs have no `req`. */
|
||||
function setIo (io) {
|
||||
ioInstance = io
|
||||
}
|
||||
|
||||
/** Cancel unpaid bookings that missed the deposit-payment window, freeing their time slot back up. */
|
||||
const autoCancelUnpaidBookings = async () => {
|
||||
const cutoff = new Date(Date.now() - PAYMENT_TIMEOUT_MINUTES * 60 * 1000)
|
||||
|
||||
try {
|
||||
const bookings = await BookingModel.find({
|
||||
status: 'pending_payment',
|
||||
createdAt: { $lte: cutoff }
|
||||
})
|
||||
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
booking.status = 'cancelled'
|
||||
await booking.save()
|
||||
await notifyUser(ioInstance, booking.buyer, {
|
||||
project_post_id: booking._id,
|
||||
type: 'booking',
|
||||
title: 'لغو رزرو',
|
||||
description: `رزرو خدمت «${booking.serviceTitle}» به دلیل عدم پرداخت بیعانه در بازه زمانی مجاز لغو شد.`
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[BookingLifecycleCron] failed to cancel booking ${booking._id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (bookings.length) {
|
||||
console.log(`[BookingLifecycleCron] auto-cancelled ${bookings.length} unpaid booking(s)`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BookingLifecycleCron] auto-cancel failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// هر ۱۰ دقیقه: لغو رزروهای بدون پرداخت بیعانه خارج از مهلت ۶۰ دقیقهای
|
||||
cron.schedule('*/10 * * * *', autoCancelUnpaidBookings)
|
||||
|
||||
module.exports = {
|
||||
autoCancelUnpaidBookings,
|
||||
setIo
|
||||
}
|
||||
57
services/BookingRepeatExpiryCron.js
Normal file
57
services/BookingRepeatExpiryCron.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const cron = require('node-cron')
|
||||
const BookingConfigModel = require('../models/BookingConfigModel')
|
||||
const { notifyUser } = require('../utils/notifyUser')
|
||||
|
||||
let ioInstance = null
|
||||
|
||||
/** Called once from index.js right after the socket.io server is created — cron jobs have no `req`. */
|
||||
function setIo (io) {
|
||||
ioInstance = io
|
||||
}
|
||||
|
||||
/** Auto-deactivate configs whose "تکرار هفته" window has ended, and notify the owner so they can renew it themselves. */
|
||||
const expireWeeklyRepeatConfigs = async () => {
|
||||
try {
|
||||
const configs = await BookingConfigModel.find({
|
||||
status: 'active',
|
||||
weeklyRepeatCount: { $ne: null },
|
||||
repeatStartedAt: { $ne: null }
|
||||
})
|
||||
|
||||
const now = Date.now()
|
||||
let expiredCount = 0
|
||||
|
||||
for (const config of configs) {
|
||||
const expiresAt = new Date(config.repeatStartedAt).getTime() + config.weeklyRepeatCount * 7 * 24 * 60 * 60 * 1000
|
||||
if (expiresAt > now) continue
|
||||
|
||||
try {
|
||||
config.status = 'inactive'
|
||||
await config.save()
|
||||
expiredCount += 1
|
||||
await notifyUser(ioInstance, config.owner, {
|
||||
project_post_id: config._id,
|
||||
type: 'booking',
|
||||
title: 'پایان تکرار هفتگی رزرو',
|
||||
description: `مهلت تکرار هفتگی (${config.weeklyRepeatCount} هفته) برای رزرو «${config.name}» به پایان رسید و غیرفعال شد. برای ادامه، آن را دوباره فعال کنید.`
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[BookingRepeatExpiryCron] failed to expire config ${config._id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredCount) {
|
||||
console.log(`[BookingRepeatExpiryCron] auto-deactivated ${expiredCount} config(s) whose weekly repeat window ended`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BookingRepeatExpiryCron] check failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// هر ساعت: بررسی پایان مهلت تکرار هفتگی رزروها
|
||||
cron.schedule('0 * * * *', expireWeeklyRepeatConfigs)
|
||||
|
||||
module.exports = {
|
||||
expireWeeklyRepeatConfigs,
|
||||
setIo
|
||||
}
|
||||
131
services/InstagramGraphService.js
Normal file
131
services/InstagramGraphService.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const axios = require('axios')
|
||||
|
||||
const AUTHORIZE_TOKEN_URL = 'https://api.instagram.com/oauth/access_token'
|
||||
const GRAPH_BASE_URL = 'https://graph.instagram.com'
|
||||
|
||||
/**
|
||||
* Exchange the short-lived OAuth `code` (from the Instagram Login redirect)
|
||||
* for a short-lived (1hr) user access token.
|
||||
*/
|
||||
async function exchangeCodeForToken (code) {
|
||||
const clientId = process.env.INSTAGRAM_CLIENT_ID
|
||||
const clientSecret = process.env.INSTAGRAM_APP_SECRET
|
||||
const redirectUri = process.env.INSTAGRAM_REDIRECT_URI
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error('Instagram OAuth is not configured (INSTAGRAM_CLIENT_ID/APP_SECRET/REDIRECT_URI)')
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
})
|
||||
|
||||
const { data } = await axios.post(AUTHORIZE_TOKEN_URL, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken: data?.access_token,
|
||||
userId: data?.user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a short-lived token for a long-lived one (~60 days).
|
||||
*/
|
||||
async function exchangeForLongLivedToken (shortLivedToken) {
|
||||
const clientSecret = process.env.INSTAGRAM_APP_SECRET
|
||||
if (!clientSecret) {
|
||||
throw new Error('INSTAGRAM_APP_SECRET is not configured')
|
||||
}
|
||||
|
||||
const { data } = await axios.get(`${GRAPH_BASE_URL}/access_token`, {
|
||||
params: {
|
||||
grant_type: 'ig_exchange_token',
|
||||
client_secret: clientSecret,
|
||||
access_token: shortLivedToken,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const expiresInSeconds = Number(data?.expires_in) || 60 * 24 * 60 * 60
|
||||
return {
|
||||
accessToken: data?.access_token,
|
||||
expiresAt: new Date(Date.now() + expiresInSeconds * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a long-lived token before it expires (must be at least 24h old, valid).
|
||||
*/
|
||||
async function refreshLongLivedToken (longLivedToken) {
|
||||
const { data } = await axios.get(`${GRAPH_BASE_URL}/refresh_access_token`, {
|
||||
params: {
|
||||
grant_type: 'ig_refresh_token',
|
||||
access_token: longLivedToken,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const expiresInSeconds = Number(data?.expires_in) || 60 * 24 * 60 * 60
|
||||
return {
|
||||
accessToken: data?.access_token,
|
||||
expiresAt: new Date(Date.now() + expiresInSeconds * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic profile info for the connected Instagram Business/Creator account.
|
||||
*/
|
||||
async function getProfile (accessToken) {
|
||||
const { data } = await axios.get(`${GRAPH_BASE_URL}/me`, {
|
||||
params: {
|
||||
fields: 'id,username,account_type,name,biography,profile_picture_url',
|
||||
access_token: accessToken,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
return {
|
||||
id: data?.id,
|
||||
username: data?.username,
|
||||
accountType: data?.account_type || null,
|
||||
name: data?.name || null,
|
||||
biography: data?.biography || null,
|
||||
profilePictureUrl: data?.profile_picture_url || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated media list (posts + reels). `after` is a Graph API paging cursor.
|
||||
*/
|
||||
async function getMedia (accessToken, { after } = {}) {
|
||||
const { data } = await axios.get(`${GRAPH_BASE_URL}/me/media`, {
|
||||
params: {
|
||||
fields: 'id,caption,media_type,media_product_type,media_url,permalink,timestamp',
|
||||
limit: 25,
|
||||
access_token: accessToken,
|
||||
...(after ? { after } : {}),
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
return {
|
||||
items: Array.isArray(data?.data) ? data.data : [],
|
||||
nextCursor: data?.paging?.cursors?.after || null,
|
||||
hasNext: Boolean(data?.paging?.next),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exchangeCodeForToken,
|
||||
exchangeForLongLivedToken,
|
||||
refreshLongLivedToken,
|
||||
getProfile,
|
||||
getMedia,
|
||||
}
|
||||
63
services/InstagramMediaSyncCron.js
Normal file
63
services/InstagramMediaSyncCron.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const cron = require('node-cron')
|
||||
const UserModel = require('../models/UserModel')
|
||||
const InstagramGraphService = require('../services/InstagramGraphService')
|
||||
const { decryptSecret, encryptSecret } = require('../utils/secretCipher')
|
||||
const { importInstagramMedia } = require('../utils/instagramMediaImport')
|
||||
|
||||
const TOKEN_REFRESH_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 // ۷ روز قبل انقضا رفرش کن
|
||||
|
||||
/**
|
||||
* برای کاربرانی که «همگامسازی خودکار» رو فعال کردن، پستهای جدید اینستاگرامشون رو
|
||||
* وارد مدستاگرام میکنه. هر کاربر جدا try/catch میشه تا خطای یک حساب بقیه رو متوقف نکنه.
|
||||
* این تنها کانسیومر decryptSecret تو کل بکاند بود قبل از این فایل — همون الگوی رمزگشایی
|
||||
* که instagramController.js موقع رمزنگاری استفاده میکنه رو برعکس اجرا میکنه.
|
||||
*/
|
||||
async function runInstagramMediaSync () {
|
||||
const users = await UserModel.find({
|
||||
'instagram_sync_prefs.auto_update': true,
|
||||
instagram_access_token_enc: { $ne: null },
|
||||
})
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
let accessToken = decryptSecret(user.instagram_access_token_enc)
|
||||
|
||||
const expiresAt = user.instagram_token_expires_at ? new Date(user.instagram_token_expires_at).getTime() : 0
|
||||
if (expiresAt && expiresAt - Date.now() < TOKEN_REFRESH_WINDOW_MS) {
|
||||
const refreshed = await InstagramGraphService.refreshLongLivedToken(accessToken)
|
||||
accessToken = refreshed.accessToken
|
||||
await UserModel.updateOne(
|
||||
{ _id: user._id },
|
||||
{
|
||||
$set: {
|
||||
instagram_access_token_enc: encryptSecret(accessToken),
|
||||
instagram_token_expires_at: refreshed.expiresAt,
|
||||
},
|
||||
},
|
||||
{ strict: false }
|
||||
)
|
||||
}
|
||||
|
||||
const { items } = await InstagramGraphService.getMedia(accessToken)
|
||||
for (const item of items) {
|
||||
await importInstagramMedia(user, item)
|
||||
}
|
||||
|
||||
await UserModel.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { instagram_last_synced_at: new Date() } },
|
||||
{ strict: false }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`[InstagramMediaSyncCron] user ${user._id} failed:`, err?.response?.data || err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cron.schedule('0 * * * *', () => {
|
||||
runInstagramMediaSync().catch((err) => {
|
||||
console.error('[InstagramMediaSyncCron] run failed:', err.message)
|
||||
})
|
||||
}, { scheduled: true, timezone: 'Asia/Tehran' })
|
||||
|
||||
module.exports = { runInstagramMediaSync }
|
||||
81
services/OrderLifecycleCron.js
Normal file
81
services/OrderLifecycleCron.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const cron = require('node-cron')
|
||||
const OrderModel = require('../models/OrderModel')
|
||||
const { releaseOrderFunds, PAYMENT_TIMEOUT_MINUTES } = require('../controllers/application/orders/orderController')
|
||||
const { notifyUser } = require('../utils/notifyUser')
|
||||
|
||||
let ioInstance = null
|
||||
|
||||
/** Called once from index.js right after the socket.io server is created — cron jobs have no `req`. */
|
||||
function setIo (io) {
|
||||
ioInstance = io
|
||||
}
|
||||
|
||||
/** Auto-release seller funds 7 days after fulfillment if the buyer never confirmed receipt. */
|
||||
const autoReleaseFulfilledOrders = async () => {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
try {
|
||||
const orders = await OrderModel.find({
|
||||
status: { $in: ['shipped', 'ready_for_pickup'] },
|
||||
walletReleased: false,
|
||||
fulfilledAt: { $lte: sevenDaysAgo }
|
||||
})
|
||||
|
||||
for (const order of orders) {
|
||||
try {
|
||||
await releaseOrderFunds(order, 'auto_released', ioInstance)
|
||||
} catch (err) {
|
||||
console.error(`[OrderLifecycleCron] failed to release order ${order._id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (orders.length) {
|
||||
console.log(`[OrderLifecycleCron] auto-released ${orders.length} order(s)`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OrderLifecycleCron] auto-release query failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel unpaid orders that missed the 60-minute payment window. */
|
||||
const autoCancelUnpaidOrders = async () => {
|
||||
const cutoff = new Date(Date.now() - PAYMENT_TIMEOUT_MINUTES * 60 * 1000)
|
||||
|
||||
try {
|
||||
const orders = await OrderModel.find({ status: 'pending_payment', createdAt: { $lte: cutoff } })
|
||||
|
||||
for (const order of orders) {
|
||||
try {
|
||||
order.status = 'cancelled'
|
||||
order.cancelReason = 'عدم پرداخت در بازه زمانی مجاز'
|
||||
await order.save()
|
||||
await notifyUser(ioInstance, order.buyer, {
|
||||
project_post_id: order._id,
|
||||
type: 'shop_order',
|
||||
title: 'لغو سفارش',
|
||||
description: 'سفارش شما به دلیل عدم پرداخت در بازه زمانی مجاز لغو شد.'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[OrderLifecycleCron] failed to cancel order ${order._id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (orders.length) {
|
||||
console.log(`[OrderLifecycleCron] auto-cancelled ${orders.length} unpaid order(s)`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OrderLifecycleCron] auto-cancel failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// هر ساعت: بررسی سفارشهای تحویلشدهی بدون تایید خریدار برای آزادسازی خودکار وجه
|
||||
cron.schedule('0 * * * *', autoReleaseFulfilledOrders)
|
||||
|
||||
// هر ۱۰ دقیقه: لغو سفارشهای پرداختنشدهی خارج از مهلت ۶۰ دقیقهای
|
||||
cron.schedule('*/10 * * * *', autoCancelUnpaidOrders)
|
||||
|
||||
module.exports = {
|
||||
autoReleaseFulfilledOrders,
|
||||
autoCancelUnpaidOrders,
|
||||
setIo
|
||||
}
|
||||
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 }
|
||||
@@ -1,7 +1,7 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
exports.sign = (data) => {
|
||||
return jwt.sign(data, process.env.APP_SECRET)
|
||||
exports.sign = (data, options = {}) => {
|
||||
return jwt.sign(data, process.env.APP_SECRET, options)
|
||||
}
|
||||
exports.verify = (token) => {
|
||||
try {
|
||||
@@ -11,3 +11,10 @@ exports.verify = (token) => {
|
||||
return false
|
||||
}
|
||||
}
|
||||
exports.decode = (token) => {
|
||||
try {
|
||||
return jwt.verify(token, process.env.APP_SECRET)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user