Files
modstagram-back/index.js
payacom e1abb40cc6 Reduce media bandwidth: compress uploads, add long-lived cache headers, gzip API
- Resize/re-encode every uploaded image server-side (posts, avatars, stories,
  shop products/logo, chat messages) via a shared sharp-based optimizer —
  caps dimensions to what's actually displayed and re-compresses in place,
  cutting a typical raw camera photo by ~90% with no visible quality loss
- Cap uploaded video resolution/bitrate (re-encode only when above 1280px/
  ~2.5Mbps) alongside the existing faststart step; apply faststart+cap to
  story and chat videos too (previously stored completely raw)
- Add long-lived Cache-Control to all /storage static routes so clients stop
  re-downloading unchanged media on every request
- Add gzip/brotli compression for JSON API responses (feed/explore/chat)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:55:09 +03:30

718 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require('dotenv').config();
const express = require('express');
const app = express();
const compression = require('compression');
const path = require('path');
const crypto = require('crypto');
const { optimizeImageFile } = require('./utils/imageOptimize');
let optimizeVideoFaststart = async () => {};
try {
({ optimizeVideoFaststart } = require('./utils/postVideoProcessing'));
} catch (error) {
console.warn('[index] postVideoProcessing unavailable:', error.message);
}
const http = require('http').createServer(app);
const cors = require('cors');
const jwt = require('jsonwebtoken');
const moment = require('moment-jalaali');
const MessageModel = require('./models/MessageModel');
const fs = require('fs-extra');
const cron = require('node-cron');
const UserModel = require('./models/UserModel');
const {
purgeExpiredDeactivatedAccounts,
purgeExpiredTombstones
} = require('./utils/accountLifecycle');
const blockCheck = require('./middlewares/blockCheck');
const chatPeerBlock = require('./middlewares/chatPeerBlock');
const createRateLimiter = require('./utils/createRateLimiter');
const { default: axios } = require('axios');
const { markOnline, markOffline } = require('./utils/presence');
const { invalidateUnreadMessages } = require('./utils/unreadMessagesCache');
const allowedOrigins = [
'http://localhost:3000',
'http://localhost:3001', // اضافه شد — مهم برای توسعه فرانت
'http://localhost:3002',
'http://localhost:3003',
'http://localhost:3004',
'http://localhost',
'https://modstagram.com',
'http://modstagram.com',
'http://193.151.143.243:3004',
'https://www.modstagram.com',
'https://app.modstagram.com',
'https://www.app.modstagram.com',
'https://panel.modstagram.com',
'https://www.panel.modstagram.com',
'ionic://localhost',
'capacitor://localhost',
// IPهای محلی (برای تست در شبکه داخلی)/
/^http:\/\/192\.168\.\d{1,3}\.\d{1,3}:\d+$/,
/^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$/,
];
app.use(cors({
origin: (origin, callback) => {
// در محیط توسعه، همه originها مجاز باشن
if (process.env.NODE_ENV !== 'production') {
return callback(null, true);
}
// اگر origin وجود نداشته باشه (مثل درخواست از موبایل یا Postman) اجازه بده
if (!origin) {
return callback(null, true);
}
// چک لیست یا RegExp
const isAllowed = allowedOrigins.some(item => {
if (typeof item === 'string') return item === origin;
if (item instanceof RegExp) return item.test(origin);
return false;
});
if (isAllowed) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-no-toast'],
credentials: true,
optionsSuccessStatus: 200 // برای مرورگرهای قدیمی
}));
// Socket.IO CORS
const io = require('socket.io')(http, {
cors: {
origin: (origin, callback) => {
if (process.env.NODE_ENV !== 'production') {
return callback(null, true);
}
if (!origin) {
return callback(null, true);
}
const isAllowed = allowedOrigins.some(item => {
if (typeof item === 'string') return item === origin;
if (item instanceof RegExp) return item.test(origin);
return false;
});
if (isAllowed) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true
}
});
app.set('io', io);
// gzip/brotli فقط برای پاسخ‌های متنی (JSON فید/اکسپلور/چت) — فیلترِ پیش‌فرضِ compression
// خودش مدیا (image/*، video/*) رو compressible نمی‌دونه و رد می‌کنه، پس نیازی به فیلترِ
// دستی نیست؛ فقط باید قبل از هر res.json/res.send دیگه‌ای بیاد
app.use(compression());
// افزایش limit برای Base64 (1000mb کافیه)
app.use(express.json({ limit: '1000mb' }));
app.use(express.urlencoded({ limit: '1000mb', extended: true }));
// کش طولانی برای مدیا — بدون این، مرورگر/اپ هر بار همون عکس/ویدیوی قبلی رو دوباره
// دانلود می‌کرد. immutable نمی‌ذاریم چون مسیر آپلود تازه (optimizeImageFile/
// optimizeVideoFaststart) گاهی چند ثانیه بعدِ همون ریسپانس، بایت‌های همون فایل رو در جا
// عوض می‌کنه؛ بدونِ immutable مرورگر با ETag/Last-Modified همچنان رواعتبارسنجی می‌کنه
const STATIC_MEDIA_OPTIONS = { maxAge: '365d' };
// مسیرهای مشخص قبل از /storage عمومی — پروفایل از هر دو محل (قدیم و جدید) سرو می‌شود
app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles'), STATIC_MEDIA_OPTIONS));
app.use('/storage/profiles', express.static(path.join(__dirname, 'storage/profiles'), STATIC_MEDIA_OPTIONS));
app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/carts', express.static(path.join(__dirname, 'storage/carts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages'), STATIC_MEDIA_OPTIONS));
app.use('/storage/messages', express.static(path.join(__dirname, 'storage/messages'), STATIC_MEDIA_OPTIONS));
app.use('/storage/shop-chat', express.static(path.join(__dirname, '../storage/shop-chat'), STATIC_MEDIA_OPTIONS));
app.use('/storage/shop-chat', express.static(path.join(__dirname, 'storage/shop-chat'), STATIC_MEDIA_OPTIONS));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, '../storage/chat-rooms'), STATIC_MEDIA_OPTIONS));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, 'storage/chat-rooms'), STATIC_MEDIA_OPTIONS));
app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets'), STATIC_MEDIA_OPTIONS));
app.use('/storage/tickets', express.static(path.join(__dirname, 'storage/tickets'), STATIC_MEDIA_OPTIONS));
app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising'), STATIC_MEDIA_OPTIONS));
app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage', express.static(path.join(__dirname, 'storage'), STATIC_MEDIA_OPTIONS));
require('./boot');
// غیرفعال کردن پارس‌کننده‌های بدنه برای روت‌های آپلود فایل
app.use((req, res, next) => {
if (req.path === '/api/v1/posts/create' && req.method === 'POST') {
console.log('DEBUG: Skipping body parsers for /api/v1/posts/create');
return next();
}
express.json()(req, res, next);
});
app.use((req, res, next) => {
if (req.path === '/api/v1/posts/create' && req.method === 'POST') {
return next();
}
express.urlencoded({ extended: true })(req, res, next);
});
require('./middlewares')(app);
const limiter = createRateLimiter('global', {
windowMs: 3 * 60 * 1000,
max: 3000,
standardHeaders: true,
legacyHeaders: false,
message: 'تعداد درخواست‌های شما بیشتر از حد مجاز است، لطفاً بعداً دوباره تلاش کنید.',
handler: (req, res) => {
const retryAfter = Math.ceil((req.rateLimit.resetTime - Date.now()) / 1000);
res.set('Retry-After', retryAfter);
res.status(429).json({
error: `شما بیش از حد مجاز درخواست ارسال کرده‌اید. لطفاً بعد از ${retryAfter} ثانیه دوباره تلاش کنید.`
});
}
});
app.use(limiter);
app.set('trust proxy', 'loopback');
require('./routes')(app);
const { enrichMessage, emitChatEvents, parseForwardedContent } = require('./helpers/chatHelpers')
const {
parseSelfDestructSeconds,
buildExpiresAt,
activeMessageFilter,
purgeExpiredMessages,
scheduleMessageExpiry,
deleteMessagesAndNotify,
isWithinDeleteWindow,
markSelfDestructViewed,
startPendingSelfDestructForViewer
} = require('./helpers/expiredMessages')
const { purgeInactiveChatRooms } = require('./controllers/application/chatRooms/chatRoomHelpers')
const { trackOfferMessageForCommission } = require('./utils/offerReplyGate')
const {
parseViewOnce,
isViewOnceMediaType,
sanitizeMessageForViewer,
openViewOnceMessage,
completeViewOnceMessage
} = require('./helpers/viewOnceMessages')
const mongoose = require('mongoose')
app.get('/api/v1/chat', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const userId = String(req.user._id)
const { senderId, receiverId, limit, page } = req.query
if (!senderId || !receiverId) {
return res.status(400).json({ error: 'senderId and receiverId are required' })
}
if (String(senderId) !== userId) {
return res.status(403).json({ error: 'Forbidden' })
}
if (
!mongoose.Types.ObjectId.isValid(String(senderId)) ||
!mongoose.Types.ObjectId.isValid(String(receiverId))
) {
return res.status(400).json({ error: 'Invalid senderId or receiverId' })
}
const sid = new mongoose.Types.ObjectId(String(senderId))
const rid = new mongoose.Types.ObjectId(String(receiverId))
const pageNumber = parseInt(page) || 1
const limitPerPage = parseInt(limit) || 40
const filter = activeMessageFilter({
$or: [
{ senderId: sid, receiverId: rid },
{ senderId: rid, receiverId: sid }
]
})
const totalMessagesCount = await MessageModel.countDocuments(filter)
const totalPages = Math.ceil(totalMessagesCount / limitPerPage) || 1
const skip = (pageNumber - 1) * limitPerPage
const messages = await MessageModel.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limitPerPage)
await MessageModel.updateMany(
{ _id: { $in: messages.map((m) => m._id) }, receiverId: sid },
{ $set: { readStatus: 1 } }
)
invalidateUnreadMessages(String(sid)).catch(() => {})
await startPendingSelfDestructForViewer(io, sid, rid)
const enriched = await Promise.all(
messages.map((m) =>
enrichMessage(sanitizeMessageForViewer(m, sid))
)
)
res.json({ messages: enriched.reverse(), totalPages })
} catch (error) {
console.error('Error fetching messages:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.post('/api/v1/chat', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const senderId = String(req.user._id)
const { receiverId, content, replyToId, forwardedFrom: fwdBody, selfDestructSeconds, fileType } = req.body
// اگر کلاینت اشتباهاً آبجکت E2EE فرستاد، به رشته تبدیل کن
const contentInput =
content && typeof content === 'object' ? JSON.stringify(content) : content
const { body, forwardedFrom: fwdParsed } = parseForwardedContent(contentInput)
const destructSec = parseSelfDestructSeconds(selfDestructSeconds)
if (!receiverId) {
return res.status(422).json({ error: 'receiverId is required' })
}
const payload = {
senderId,
receiverId,
content: body || contentInput,
replyToId: replyToId || undefined,
forwardedFrom: fwdBody || fwdParsed || undefined,
selfDestructSeconds: destructSec || undefined,
fileType: fileType || undefined
}
const newMessage = new MessageModel(payload)
await newMessage.save()
const formatted = await enrichMessage(newMessage)
res.status(201).json({ data: formatted })
emitChatEvents(io, formatted, senderId, receiverId)
invalidateUnreadMessages(String(receiverId)).catch(() => {})
trackOfferMessageForCommission(io, senderId, receiverId).catch((err) =>
console.error('trackOfferMessageForCommission error:', err)
)
} catch (error) {
console.error('Error sending message:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.post('/api/v1/chat/reaction', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const userId = String(req.user._id)
const { messageId, emoji } = req.body
if (!messageId || !emoji) {
return res.status(400).json({ error: 'messageId and emoji are required' })
}
const msg = await MessageModel.findById(messageId)
if (!msg) return res.status(404).json({ error: 'Message not found' })
const sid = String(msg.senderId)
const rid = String(msg.receiverId)
if (userId !== sid && userId !== rid) {
return res.status(403).json({ error: 'Forbidden' })
}
let reactions = Array.isArray(msg.reactions) ? [...msg.reactions] : []
const idx = reactions.findIndex((r) => String(r.userId) === userId)
if (idx >= 0 && reactions[idx].emoji === emoji) {
reactions.splice(idx, 1)
} else if (idx >= 0) {
reactions[idx] = { userId, emoji }
} else {
reactions.push({ userId, emoji })
}
msg.reactions = reactions
await msg.save()
const formatted = await enrichMessage(msg)
io.to(`chat:${sid}:${rid}`).to(`chat:${rid}:${sid}`).emit('messageReaction', {
messageId: String(msg._id),
reactions: formatted.reactions
})
res.json({ data: formatted })
} catch (error) {
console.error('Error updating reaction:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.get('/api/v1/chat/read', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const userId = String(req.user._id)
const { senderId, receiverId } = req.query
if (String(senderId) !== userId) {
return res.status(403).json({ error: 'Forbidden' })
}
const messages = await MessageModel.find(
{ senderId: receiverId, receiverId: senderId }
)
await MessageModel.updateMany({ _id: { $in: messages.map(message => message._id) } }, { $set: { readStatus: 1 } })
invalidateUnreadMessages(userId).catch(() => {})
await startPendingSelfDestructForViewer(io, userId, receiverId)
res.status(200).json({ message: 'Message read status updated successfully' })
} catch (error) {
console.error('Error updating message read status:', error)
res.status(500).json({ error: 'Server error' })
}
})
// این endpoint قبلاً بدون auth بود و کلید SMS را لو می‌داد — غیرفعال شد
app.post('/api/v1/notification/send-sms', [blockCheck], async (req, res) => {
return res.status(410).json({
error: true,
message: 'این سرویس غیرفعال شده است'
})
})
app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => {
try {
const senderId = String(req.user._id)
const { receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body
const destructSec = parseSelfDestructSeconds(selfDestructSeconds)
const viewOnceFlag = parseViewOnce(viewOnce)
const resolvedFileType = fileType || undefined
if (!receiverId) {
return res.status(422).json({ error: 'receiverId is required' })
}
if (viewOnceFlag && !isViewOnceMediaType(resolvedFileType)) {
return res.status(422).json({ error: 'View once is only for image, video, and voice' })
}
const { file } = req.files
let fileUrl = null
if (file) {
const uploadDir = path.join(__dirname, '../storage/messages')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
// اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (file.name) گرفته نمی‌شه — فقط
// پسوندش؛ وگرنه یه filename مثل ../../../../etc/passwd می‌تونست از uploadDir
// بیرون بزنه (path traversal / نوشتنِ فایلِ دلخواه روی سرور)
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(file.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath)
if (resolvedFileType === 'image' || (file.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
} else if (resolvedFileType === 'video' || (file.type || '').startsWith('video/')) {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[chat/file] video optimize background error:', err.message)
})
}
fileUrl = `/messages/${uniqueFileName}`
}
const newMessage = new MessageModel({
senderId,
receiverId,
content: content || '',
file: fileUrl,
fileType: resolvedFileType,
replyToId: replyToId || undefined,
selfDestructSeconds: destructSec || undefined,
viewOnce: viewOnceFlag
})
await newMessage.save()
const formatted = await enrichMessage(newMessage)
res.status(201).json({ data: formatted })
emitChatEvents(io, formatted, senderId, receiverId)
invalidateUnreadMessages(String(receiverId)).catch(() => {})
trackOfferMessageForCommission(io, senderId, receiverId).catch((err) =>
console.error('trackOfferMessageForCommission error:', err)
)
} catch (error) {
console.error('Error sending message:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.post('/api/v1/chat/view-once/open', [blockCheck], async (req, res) => {
try {
const userId = String(req.user._id)
const { messageId } = req.body
if (!messageId) {
return res.status(422).json({ error: 'messageId is required' })
}
const result = await openViewOnceMessage(messageId, userId)
if (result.error) {
return res.status(result.status || 400).json({ error: result.error })
}
res.json({
file: result.file,
fileType: result.fileType,
messageId: String(result.message._id)
})
} catch (error) {
console.error('view-once open error:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.post('/api/v1/chat/view-once/complete', [blockCheck], async (req, res) => {
try {
const userId = String(req.user._id)
const { messageId } = req.body
if (!messageId) {
return res.status(422).json({ error: 'messageId is required' })
}
const result = await completeViewOnceMessage(io, messageId, userId)
if (result.error) {
return res.status(result.status || 400).json({ error: result.error })
}
res.json({ deletedIds: result.deletedIds })
} catch (error) {
console.error('view-once complete error:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.delete('/api/v1/chat', [blockCheck], async (req, res) => {
try {
const userId = String(req.user._id)
const { messageIds } = req.body
if (!Array.isArray(messageIds) || messageIds.length === 0) {
return res.status(422).json({ message: 'پیامی انتخاب نشده است' })
}
const messages = await MessageModel.find({
_id: { $in: messageIds },
senderId: userId
})
if (!messages.length) {
return res.status(403).json({ message: 'فقط پیام‌های خودتان قابل حذف هستند' })
}
const deletable = messages.filter((m) => isWithinDeleteWindow(m.createdAt))
const tooOld = messages.filter((m) => !isWithinDeleteWindow(m.createdAt))
if (!deletable.length) {
return res.status(403).json({
message: 'فقط پیام‌های ۲۴ ساعت گذشته قابل حذف هستند'
})
}
const deletedIds = await deleteMessagesAndNotify(io, deletable, { deletedBy: userId })
res.json({
deletedIds,
skippedIds: tooOld.map((m) => String(m._id)),
message:
tooOld.length > 0
? `${deletedIds.length} پیام حذف شد. ${tooOld.length} پیام قدیمی‌تر از ۲۴ ساعت بودند.`
: 'پیام‌ها حذف شدند'
})
} catch (error) {
console.error('Error deleting messages:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.post('/api/v1/chat/self-destruct/view', [blockCheck], async (req, res) => {
try {
const userId = String(req.user._id)
const { messageId } = req.body
if (!messageId) {
return res.status(422).json({ error: 'messageId is required' })
}
const result = await markSelfDestructViewed(io, messageId, userId)
if (!result.message) {
return res.status(404).json({ error: 'پیام یافت نشد' })
}
res.json({
messageId: String(result.message._id),
expiresAt: result.expiresAt
})
} catch (error) {
console.error('self-destruct view error:', error)
res.status(500).json({ error: 'Server error' })
}
})
// Errors
require('./middlewares/exception')(app)
require('./middlewares/404')(app)
io.use((socket, next) => {
try {
const token =
socket.handshake.auth?.token ||
(socket.handshake.headers?.authorization || '').split(' ')[1]
if (!token) {
return next(new Error('Unauthorized'))
}
const decoded = jwt.verify(token, process.env.APP_SECRET)
if (!decoded?.id) {
return next(new Error('Unauthorized'))
}
socket.data.userId = String(decoded.id)
next()
} catch (err) {
next(new Error('Unauthorized'))
}
})
io.on('connection', (socket) => {
const authenticatedUserId = () => String(socket.data.userId || '')
// اتصالِ سوکت خودش یعنی کاربر الان آنلاینه — بدون این، وضعیتِ presence فقط با
// درخواست‌های HTTP (middlewares/lastOnline.js) رفرش می‌شد و کاربری که فقط اپ رو
// باز نگه داشته (بدون درخواستِ REST جدید) بعد از ۹۰ ثانیه اشتباهاً آفلاین می‌شد
const uidOnConnect = authenticatedUserId()
if (uidOnConnect) void markOnline(uidOnConnect)
socket.on('disconnect', () => {
const uid = authenticatedUserId()
if (uid) void markOffline(uid)
})
socket.on('joinUser', () => {
const id = authenticatedUserId()
if (id) socket.join(`user:${id}`)
})
socket.on('joinChat', ({ receiverId }) => {
const uid = authenticatedUserId()
const rid = receiverId != null ? String(receiverId).trim() : ''
if (uid && rid) {
socket.join(`chat:${uid}:${rid}`)
socket.join(`chat:${rid}:${uid}`)
}
})
socket.on('joinGroupRoom', async ({ roomId }) => {
const uid = authenticatedUserId()
const rid = roomId != null ? String(roomId).trim() : ''
if (!uid || !rid || !mongoose.Types.ObjectId.isValid(rid)) return
try {
const ChatRoomModel = require('./models/ChatRoomModel')
const room = await ChatRoomModel.findById(rid).select('members').lean()
if (!room?.members?.some((m) => String(m) === uid)) return
socket.join(`group:${rid}`)
} catch (e) {
console.error('joinGroupRoom auth error', e)
}
})
socket.on('typing', ({ receiverId }) => {
const sid = authenticatedUserId()
const rid = String(receiverId || '').trim()
if (!sid || !rid) return
io.to(`chat:${rid}:${sid}`).emit('userTyping', { userId: sid })
})
socket.on('stopTyping', ({ receiverId }) => {
const sid = authenticatedUserId()
const rid = String(receiverId || '').trim()
if (!sid || !rid) return
io.to(`chat:${rid}:${sid}`).emit('userStoppedTyping', { userId: sid })
})
socket.on('messageSeen', async ({ messageId, senderId, receiverId }) => {
try {
const uid = authenticatedUserId()
if (!uid || String(receiverId) !== uid) return
if (messageId) {
await MessageModel.findByIdAndUpdate(messageId, { readStatus: 1 })
invalidateUnreadMessages(String(receiverId)).catch(() => {})
await markSelfDestructViewed(io, messageId, receiverId)
io.to(`chat:${receiverId}:${senderId}`).emit('messageStatusUpdate', {
messageId,
status: 'seen'
})
}
} catch (e) {
console.error('messageSeen error', e)
}
})
})
// Free Project
require('./services/AdvertisingCron')
require('./services/ProjectCron')
require('./services/OfferReplyCron').setIo(io)
require('./services/OrderLifecycleCron').setIo(io)
require('./services/BookingLifecycleCron').setIo(io)
require('./services/BookingRepeatExpiryCron').setIo(io)
require('./services/SmartNotificationsCron').setIo(io)
require('./services/InstagramMediaSyncCron')
require('./utils/notifyFollowers').setIo(io)
require('./utils/partnerMatch').setIo(io)
cron.schedule('*/30 * * * * *', () => {
purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err))
})
cron.schedule('0 * * * *', () => {
purgeInactiveChatRooms(io).catch((err) =>
console.error('inactive chat rooms cron:', err)
)
})
cron.schedule('0 0 1 * *', async () => {
try {
// درخواست همکاری رایگان یک‌بار در کل عمر حساب است — دیگر ماهانه ریست نمی‌شود
await UserModel.updateMany({}, {
daily_free_request: 1,
last_free_request_date: new Date()
})
console.log('Monthly free requests reset for all users.')
} catch (error) {
console.error('Error resetting daily free requests:', error)
}
}, {
scheduled: true,
timezone: 'Asia/Tehran'
})
cron.schedule('0 3 * * *', async () => {
try {
const purged = await purgeExpiredDeactivatedAccounts()
await purgeExpiredTombstones()
if (purged) console.log(`Purged ${purged} deactivated accounts`)
} catch (error) {
console.error('Account lifecycle cron error:', error)
}
}, {
scheduled: true,
timezone: 'Asia/Tehran'
})
const port = process.env.APP_PORT || 3002;
http.listen(port, () => {
console.log(`HTTP server is running on port ${port}`);
});