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

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

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

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

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

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 }

View File

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