Compare commits

..

3 Commits

Author SHA1 Message Date
payacom
2c6d8a4434 Initial commit 2026-07-07 13:23:55 +03:30
payacom
a7e4f68495 Initial commit 2026-07-07 12:49:09 +03:30
payacom
5719818b9d Initial commit 2026-07-07 12:29:12 +03:30
918 changed files with 15912 additions and 639152 deletions

View File

@@ -1,58 +0,0 @@
# App
APP_PORT=3000
APP_URL=https://api.example.com/api/v1
APP_SITE=https://example.com
APP_SITE_CallBack=https://example.com/api/v1
# رشته‌ی تصادفیِ طولانی برای امضای JWT — با `openssl rand -hex 32` بساز، هیچ‌وقت
# مقدار واقعی این کلید نباید داخل گیت کامیت بشه
APP_SECRET=replace-with-a-long-random-hex-string
NODE_ENV=production
# Mongo
DATABASE_URL=mongodb://username:password@localhost:27017/modstagram?authSource=admin
MONGO_HOST=localhost
MONGO_DBNAME=modstagram
MONGO_USERNAME=
MONGO_PASSWORD=
MONGO_PORT=27017
# SMS (SMS.ir)
SMS_SECRET=
SMS_VERIFY_KEY=
SMS_IR_API_KEY=
SMS_IR_TEMPLATE_ID=
# ZarinPal
MERCHENT_CODE=
ZARINPAL_MERCHANT_ID=
# Instagram (Instagram API with Instagram Login — Business/Creator accounts only)
# INSTAGRAM_REDIRECT_URI باید صفحه‌ی callback خودِ فرانت باشه (نه یه مسیر بک‌اند)،
# دقیقاً همون redirect_uri که سمتِ کلاینت موقعِ ساختِ authorize URL استفاده می‌شه
INSTAGRAM_CLIENT_ID=
INSTAGRAM_APP_SECRET=
INSTAGRAM_REDIRECT_URI=https://example.com/auth/instagram/callback
INSTAGRAM_TOKEN_ENC_KEY=
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# SMTP for email OTP (Google users password reset)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
# Firebase Cloud Messaging (پوش نوتیفیکیشن) — Service Account JSON از Firebase Console
# (Project Settings -> Service Accounts -> Generate new private key)، base64 شده
FIREBASE_SERVICE_ACCOUNT_BASE64=
FIREBASE_PROJECT_ID=
# Redis — کش/session/rate-limit/وضعیت آنلاین (اختیاریه؛ اگه در دسترس نباشه اپ با
# fallback مستقیم به Mongo/memory ادامه می‌ده، کرش نمی‌کنه)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0

9
.gitignore vendored
View File

@@ -6,9 +6,8 @@ logs/*
node_modules/
*.mp4
*.mov
storage/courses/videos/*.mp4
storage/posts/videos/*.mp4
*.mp4
*.mov
*.avi
# فایل‌های آپلودی کاربران (عکس/ویدیو محصولات، پست‌ها، پروفایل، استوری، دوره‌ها) —
# روی سرور واقعی وجود دارن، نیازی نیست توی گیت ذخیره بشن و فقط حجم رپو رو زیاد می‌کنن
storage/*
!storage/.gitkeep

1
.npmrc
View File

@@ -1 +0,0 @@
registry=https://mirror-npm.runflare.com

View File

@@ -1,5 +1,2 @@
const startMongoDB = require('./mongo')
const { connectRedis } = require('../utils/redisClient')
startMongoDB()
connectRedis()

View File

@@ -1,26 +1,24 @@
const mongoose = require('mongoose')
require('dotenv').config()
const { ensureUserSparseUniqueIndexes } = require('../utils/ensureUserIndexes')
const { ensureGeoData } = require('../utils/ensureGeoData')
// const mongoose = require('mongoose')
// const { MONGO_DBNAME, MONGO_HOST, MONGO_PORT } = process.env
// mongoose.connection.on('error', (error) => {
// console.log('mongodb connection failed! ', error.message)
// })
// const startMongoDB = () => {
// mongoose.connect(`mongodb://${MONGO_HOST}:${MONGO_PORT}/${MONGO_DBNAME}`, {
// })
// }
// module.exports = startMongoDB
const mongoose = require('mongoose')
mongoose.connection.on('error', (error) => {
console.log('mongodb connection failed! ', error.message)
})
mongoose.connection.on('open', function () {
console.log('Database Connected! ')
ensureUserSparseUniqueIndexes().catch((err) => {
console.error('[boot] ensureUserSparseUniqueIndexes failed:', err)
})
ensureGeoData().catch((err) => {
console.error('[boot] ensureGeoData failed:', err)
})
})
const startMongoDB = () => {
mongoose.connect(process.env.DATABASE_URL, {
authSource: 'admin'
})
}
module.exports = startMongoDB

View File

@@ -0,0 +1,74 @@
const AcademyCategoryModel = require('../../../models/AcademyCategoryModel');
// GET all categories
exports.getAll = async (req, res) => {
try {
const categories = await AcademyCategoryModel.find({}).sort({ createdAt: -1 });
res.status(200).json({ success: true, categories });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};
// CREATE category
exports.create = async (req, res) => {
try {
const { title, status } = req.body;
if (!title) {
return res.status(400).json({ success: false, message: "title is required" });
}
const newCategory = new AcademyCategoryModel({
title,
status: status ?? true,
});
await newCategory.save();
res.status(201).json({ success: true, category: newCategory });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};
// UPDATE category
exports.update = async (req, res) => {
try {
const { id } = req.params;
const { title, status } = req.body;
const updated = await AcademyCategoryModel.findByIdAndUpdate(
id,
{ title, status },
{ new: true, runValidators: true }
);
if (!updated) {
return res.status(404).json({ success: false, message: "category not found" });
}
res.status(200).json({ success: true, category: updated });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};
// DELETE
exports.remove = async (req, res) => {
try {
const { id } = req.params;
const deleted = await AcademyCategoryModel.findByIdAndDelete(id);
if (!deleted) {
return res.status(404).json({ success: false, message: "category not found" });
}
res.status(200).json({
success: true,
message: 'category deleted successfully'
});
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
const moment = require('moment-jalaali')
const MessageModel = require('../models/MessageModel')
const UserModel = require('../models/UserModel')
function formatTimeOnly(date) {
return moment(date).locale('fa').format('HH:mm:ss')
}
async function enrichMessage(message) {
const doc = message._doc ? { ...message._doc } : { ...message }
let replyTo = null
if (doc.replyToId) {
const parent = await MessageModel.findById(doc.replyToId).lean()
if (parent) {
const parentUser =
String(parent.senderId) === String(doc.senderId)
? await UserModel.findById(doc.senderId).select('first_name last_name user_name').lean()
: await UserModel.findById(parent.senderId).select('first_name last_name user_name').lean()
replyTo = {
_id: parent._id,
content: (parent.content || 'پیام').slice(0, 200),
senderName: parentUser
? `${parentUser.first_name || ''} ${parentUser.last_name || ''}`.trim() || parentUser.user_name
: 'کاربر'
}
}
}
return {
...doc,
createdAt: formatTimeOnly(doc.createdAt),
replyTo,
forwardedFrom: doc.forwardedFrom || undefined
}
}
function emitChatEvents(io, message, senderId, receiverId) {
const payload = message
const roomA = `chat:${senderId}:${receiverId}`
const roomB = `chat:${receiverId}:${senderId}`
io.to(roomA).to(roomB).emit('newMessage', payload)
io.to(`user:${senderId}`).to(`user:${receiverId}`).emit('chatListUpdate', {
senderId,
receiverId,
message: payload
})
}
function parseForwardedContent(content) {
if (!content) return { body: '', forwardedFrom: null }
try {
const line = content.split('\n')[0]
const j = JSON.parse(line)
if (j.forwardedFrom) {
return {
forwardedFrom: j.forwardedFrom,
body: content.replace(/^\{.*\}\n?/, '').trim()
}
}
} catch (_) {
/* not json */
}
return { body: content, forwardedFrom: null }
}
module.exports = {
formatTimeOnly,
enrichMessage,
emitChatEvents,
parseForwardedContent
}

View File

@@ -0,0 +1,357 @@
const express = require('express');
const app = express();
const path = require('path');
const http = require('http').createServer(app);
const cors = require('cors');
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 blockCheck = require('./middlewares/blockCheck');
const rateLimit = require('express-rate-limit');
const { default: axios } = require('axios');
const allowedOrigins = [
'http://localhost:3000',
'http://localhost:3001', // اضافه شد — مهم برای توسعه فرانت
'http://localhost:3002',
'http://localhost:3003',
'http://localhost',
'https://modstagram.com',
'https://www.modstagram.com',
'https://api.modstagram.com',
'https://panel.modstagram.com',
'https://www.panel.modstagram.com',
'https://panel.modstagram.ir',
'https://www.panel.modstagram.ir',
'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
}
});
// افزایش limit برای Base64 (1000mb کافیه)
app.use(express.json({ limit: '1000mb' }));
app.use(express.urlencoded({ limit: '1000mb', extended: true }));
app.use('/storage', express.static(path.join(__dirname, 'storage')));
app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles')));
app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts')));
app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts')));
app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages')));
app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets')));
app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising')));
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services')));
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 = rateLimit({
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')
app.get('/api/v1/chat', async (req, res) => {
try {
const { senderId, receiverId, limit, page } = req.query
const pageNumber = parseInt(page) || 1
const limitPerPage = parseInt(limit) || 40
const filter = {
$or: [
{ senderId, receiverId },
{ senderId: receiverId, receiverId: senderId }
]
}
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: senderId },
{ $set: { readStatus: 1 } }
)
const enriched = await Promise.all(messages.map((m) => enrichMessage(m)))
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], async (req, res) => {
try {
const { senderId, receiverId, content, replyToId, forwardedFrom: fwdBody } = req.body
const { body, forwardedFrom: fwdParsed } = parseForwardedContent(content)
const payload = {
senderId,
receiverId,
content: body || content,
replyToId: replyToId || undefined,
forwardedFrom: fwdBody || fwdParsed || 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)
} catch (error) {
console.error('Error sending message:', error)
res.status(500).json({ error: 'Server error' })
}
})
app.get('/api/v1/chat/read', async (req, res) => {
try {
const { senderId, receiverId } = req.query // شناسه فرستنده و گیرنده از درخواست دریافت شود
// پیدا کردن تمام پیام‌هایی که کاربر دوم مشاهده کرده است و کاربر اول آنها را ارسال کرده است
const messages = await MessageModel.find(
{ senderId: receiverId, receiverId: senderId } // برای مواقعی که شناسه‌ها معکوس باشند
)
// به روزرسانی وضعیت خوانده شده یا نشده بودن پیام‌ها به "خوانده شده"
await MessageModel.updateMany({ _id: { $in: messages.map(message => message._id) } }, { $set: { readStatus: 1 } })
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' })
}
})
app.post("/api/v1/notification/send-sms", async (req, res) => {
const { receiverId, message } = req.body;
const user = await UserModel.findById(receiverId);
if (!user || !user.mobile) return res.status(404).json({ error: "User not found" });
console.log("3");
const mobile = user.mobile
const user_name = user.user_name
const data = JSON.stringify({
mobile,
templateId: '876533',
parameters: [
{ name: 'USER', value: user_name }
]
})
const config = {
method: 'post',
url: 'https://api.sms.ir/v1/send/verify',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
'x-api-key': '2JKxGbTzE64t0YTkjip6RYHnm9mq1haMlidF8WWwQunwubHFVW6iaMmfFQbMXEDt'
},
data
}
axios(config)
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error)
})
res.status(200).json({ success: true });
});
app.post('/api/v1/chat/file', [blockCheck], async (req, res) => {
try {
const { senderId, receiverId, content, replyToId, fileType } = req.body
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 })
}
const uniqueFileName = `${Date.now()}-${file.name}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath)
fileUrl = `/messages/${uniqueFileName}`
}
const newMessage = new MessageModel({
senderId,
receiverId,
content: content || '',
file: fileUrl,
fileType: fileType || undefined,
replyToId: replyToId || undefined
})
await newMessage.save()
const formatted = await enrichMessage(newMessage)
res.status(201).json({ data: formatted })
emitChatEvents(io, formatted, senderId, receiverId)
} catch (error) {
console.error('Error sending message:', error)
res.status(500).json({ error: 'Server error' })
}
})
// Errors
require('./middlewares/exception')(app)
require('./middlewares/404')(app)
io.on('connection', (socket) => {
socket.on('joinUser', ({ userId }) => {
if (userId) socket.join(`user:${userId}`)
})
socket.on('joinChat', ({ userId, receiverId }) => {
if (userId && receiverId) {
socket.join(`chat:${userId}:${receiverId}`)
socket.join(`chat:${receiverId}:${userId}`)
}
})
socket.on('typing', ({ senderId, receiverId }) => {
io.to(`chat:${receiverId}:${senderId}`).emit('userTyping', { userId: senderId })
})
socket.on('stopTyping', ({ senderId, receiverId }) => {
io.to(`chat:${receiverId}:${senderId}`).emit('userStoppedTyping', { userId: senderId })
})
socket.on('messageSeen', async ({ messageId, senderId, receiverId }) => {
try {
if (messageId) {
await MessageModel.findByIdAndUpdate(messageId, { readStatus: 1 })
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')
cron.schedule('0 0 1 * *', async () => {
try {
await UserModel.updateMany({}, {
daily_free_request: 1,
last_free_request_date: new Date(),
monthly_free_offer: 1,
last_free_offer_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'
})
module.exports = (port) => {
http.listen(port, () => {
console.log(`HTTP server is running on port ${port}`)
})
}

View File

@@ -0,0 +1,18 @@
[Wed Mar 13 00:31:27 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3NjUHdHNE5ma0hGdHpQV040MkV0OUx0M285SkdBcGp6OFA5SGpoNHZHUzFEajkwc0ZWQWRWSlNBNFlOMWxxOC9k","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 00:31:27 2024] [VerificationCode] {"VerificationCodeId":634458365,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 00:34:14 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3M0WTRVeG9SeVNtN0xnMWUzQWo4WG1Oam9mNUw2SkJPV04rMGVOMUJ6Njhpb1RXMTl2LzFEdGRESXhZazBwaHBk","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 00:34:15 2024] [VerificationCode] {"VerificationCodeId":634459857,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 00:34:48 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3NGN0NVV3V1K01VRTg3TGY1by9PcmtlSVpWaFpBa0l4RXNCOERHMzIxMDRkL1RYbWRhOXc5ZmovT0lFV0FDamJs","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 00:34:48 2024] [VerificationCode] {"VerificationCodeId":634460165,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 00:34:57 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3NyNmxiRjUwVlF6UUlkSStnOWpOc0lLSyt5VHJjSjFRa0xsTkk0dFB2L3VSaE1PTDdQREY4WGlzcUFQeE01QWRP","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 00:34:57 2024] [VerificationCode] {"VerificationCodeId":634460241,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 00:39:39 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3N6MHErTmJEVzMydjZRcVJQaHNLb0R3MzJKcWk5bGJYNTh5UzFmSEpSSUY0b0Q4MFppdDRoeFlxWTZZSEQrL1hL","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 00:39:39 2024] [VerificationCode] {"VerificationCodeId":634462768,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 00:40:18 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3NLRlVYejdBcTJxMGl6dXJVSTRsQkNiOGh2M241WTZrSGI3Z0JCY3kxeFZvUzVCTnRkdmVWalFzd25KQzIzV21I","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 00:40:18 2024] [VerificationCode] {"VerificationCodeId":634463121,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 00:41:38 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3NOMUs3ZVN3eDU0OVBZdXR1VU1Yd3dhS2lyWmMrWkpjTGpUVVkzWDQ4ZU10VDBTWnFZc0gwYlVSZHArS1FBU2ZL","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 00:41:38 2024] [VerificationCode] {"VerificationCodeId":634463845,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 01:11:21 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3M4em1oSG05eU9XbVlna0U1TDRjRWdlRi8zTE10YnAzYVZia0hRTmQ3bWV4cEUyUUl4TCtPMncvWStrNi9TSjNt","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 01:11:21 2024] [VerificationCode] {"VerificationCodeId":634470192,"IsSuccessful":true,"Message":"your verification code is sent"}
[Wed Mar 13 02:55:34 2024] [Token] {"TokenKey":"OVlma3FmZmJOTjNoY1A3bmxRbE5odHBHTTNZbGpjcGxvcVVJTmZIMDFHdjkvWDNYaG55WlB0cXNpaWRmclVCeHlXNFVrd2xxZThZQktIVEdGak9Xd29QTGNhMWtpYXlWUmwwNXcvZUdxQjhmbHFyenpPNVlDVk9ZbGZsUmplQ3MwSFZidjNtY0p1T3RwQVFlbkxJdjFHZ0xUaFpqZW9CdllCR21oNFNyaXRUczFaVUVSemZ3Rzk0TW0zRG50Q3VU","IsSuccessful":true,"Message":" درخواست Token با موفقیت انجام شد"}
[Wed Mar 13 02:55:34 2024] [VerificationCode] {"VerificationCodeId":634481796,"IsSuccessful":true,"Message":"your verification code is sent"}

View File

@@ -0,0 +1,9 @@
module.exports = (app) => {
app.use((req, res, next) => {
res.status(404).send({
code: 'Not Found',
status: 404,
message: 'requested resource could not be found!'
})
})
}

View File

@@ -0,0 +1,55 @@
const AdminModel = require('../models/AdminModel')
const jwt = require('jsonwebtoken')
module.exports = async (req, res, next) => {
if (!('authorization' in req.headers)) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'you are not authorized!'
})
}
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
let decodedToken
try {
decodedToken = jwt.verify(token, process.env.APP_SECRET)
} catch (err) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'Your token is not valid!',
error: err.message
})
}
if (!decodedToken) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'Your token is not valid!'
})
}
try {
// پیدا کردن کاربر با استفاده از اطلاعات موجود در توکن
const admin = await AdminModel.findOne({ _id: decodedToken.id })
if (!admin) {
return res.status(403).send({
status: 'error',
code: 403,
message: 'Access denied!'
})
}
// ادامه مسیر در صورتی که کاربر ادمین باشد
req.admin = admin
next()
} catch (error) {
return res.status(500).send({
status: 'error',
code: 500,
message: 'Internal Server Error',
error: error.message
})
}
}

View File

@@ -0,0 +1,21 @@
const TokenService = require('../services/TokenService')
module.exports = (req, res, next) => {
console.log(1);
if (!('authorization' in req.headers)) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'you are not authorized!'
})
}
const [, tokenValue] = req.headers.authorization.split(' ')
const token = TokenService.verify(tokenValue)
if (!token) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'your token is not valid!'
})
}
next()
}

View File

@@ -0,0 +1,47 @@
const UserModel = require('../models/UserModel') // مسیر درست به مدل کاربر
const jwt = require('jsonwebtoken')
module.exports = async (req, res, next) => {
console.log(2);
try {
if (!('authorization' in req.headers)) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'You are not authorized!'
})
}
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
if (!decodedToken) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'Your token is not valid!'
})
}
const user = await UserModel.findById(decodedToken.id)
if (user.block_status === true) {
return res.status(403).send({
status: 'error',
code: 403,
message: 'حساب کاربری شما مسدود شده است، برای اطلاعات بیشتر با پشتیبانی تماس بگیرید!',
type: 'block'
})
}
req.user = user // کاربر را به درخواست اضافه کنید تا در سایر middleware ها و کنترلرها استفاده شود.
next()
} catch (error) {
console.error('Error in blockCheck middleware:', error)
res.status(500).send({
status: 'error',
code: 500,
message: 'Server error'
})
}
}

View File

@@ -0,0 +1,11 @@
module.exports = (app) => {
app.use((error, req, res, next) => {
const status = error.status || 500
res.status(500).send({
code: 'Eception',
status,
en_message: error.message,
message: 'خطایی در عملیات مورد نظر رخ داده است.'
})
})
}

View File

@@ -0,0 +1,17 @@
const bodyParser = require('body-parser')
const cors = require('cors')
const formData = require('express-form-data')
const session = require('express-session')
const lastOnline = require('./lastOnline')
module.exports = (app) => {
app.use(cors()) // develop
app.use(formData.parse())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(session({
secret: 'your_secret_key', // کلید مخفی برای امنیت جلسات
resave: false,
saveUninitialized: false
}))
app.use(lastOnline)
}

View File

@@ -0,0 +1,53 @@
const UserModel = require('../models/UserModel') // مسیر درست به مدل کاربر
const jwt = require('jsonwebtoken')
module.exports = async (req, res, next) => {
console.log(3);
try {
console.log(31);
if (!('authorization' in req.headers)) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'You are not authorized!'
})
}
console.log(32);
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
if (!decodedToken) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'Your token is not valid!'
})
}
console.log(33);
const user = await UserModel.findById(decodedToken.id)
if (!user.user_name ||
!user.first_name ||
!user.last_name ||
!user.user_type ||
!user.password) {
return res.status(403).send({
status: 'error',
code: 403,
message: 'لطفا از بخش تنظیمات، ثبت نام خود را کامل کنید',
type: 'not_register'
})
}
console.log(34);
req.user = user // کاربر را به درخواست اضافه کنید تا در سایر middleware ها و کنترلرها استفاده شود.
next()
} catch (error) {
console.log(35);
console.error('not_register middleware:', error)
res.status(500).send({
status: 'error',
code: 500,
message: 'Server error'
})
}
}

View File

@@ -0,0 +1,50 @@
const UserModel = require('../models/UserModel') // مسیر درست به مدل کاربر
const jwt = require('jsonwebtoken')
module.exports = async (req, res, next) => {
try {
if (!('authorization' in req.headers)) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'You are not authorized!'
})
}
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
if (!decodedToken) {
return res.status(401).send({
status: 'error',
code: 401,
message: 'Your token is not valid!'
})
}
const user = await UserModel.findById(decodedToken.id)
if (!user.user_name ||
!user.first_name ||
!user.last_name ||
!user.password) {
return res.status(403).send({
status: 'error',
code: 403,
message: 'لطفا از بخش تنظیمات، ثبت نام خود را کامل کنید',
type: 'not_register'
})
}
req.user = user // کاربر را به درخواست اضافه کنید تا در سایر middleware ها و کنترلرها استفاده شود.
next()
} catch (error) {
console.error('not_register middleware:', error)
res.status(500).send({
status: 'error',
code: 500,
message: 'Server error'
})
}
}

View File

@@ -0,0 +1,21 @@
const jwt = require('jsonwebtoken')
const UserModel = require('../models/UserModel')
module.exports = async (req, res, next) => {
if (req.header && req.header('Authorization')) {
const token = await req.header('Authorization').split(' ')[1]
const decodedToken = await jwt.verify(token, process.env.APP_SECRET)
const user = await UserModel.findById(decodedToken.id)
if (user) {
try {
user.last_online = new Date()
await user.save() // ذخیره تغییرات در دیتابیس
} catch (error) {
// بررسی و پردازش خطاها
console.error('Error updating last online time:', error)
}
}
}
next()
}

View File

@@ -0,0 +1,24 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs-extra');
const uploadDir = path.join(__dirname, '../storage/posts');
fs.ensureDirSync(uploadDir); // مطمئن میشه مسیر وجود داره
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const name = file.originalname.replace(ext, '').replace(/\s/g, '_');
cb(null, `${Date.now()}-${name}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) return cb(new Error('فقط تصاویر مجاز است'));
cb(null, true);
};
const upload = multer({ storage, fileFilter, limits: { files: 10 } }); // حداکثر ۱۰ فایل
module.exports = upload;

View File

@@ -0,0 +1,190 @@
const mongoose = require('mongoose');
const mongoosePaginate = require('mongoose-paginate-v2');
const timestamp = require('mongoose-timestamp');
const AcademyCategorySchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'عنوان دسته‌بندی الزامی است'],
trim: true,
minLength: [2, 'عنوان باید حداقل ۲ کاراکتر باشد'],
maxLength: [100, 'عنوان باید حداکثر ۱۰۰ کاراکتر باشد'],
unique: true,
index: true,
},
slug: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
index: true,
},
description: {
type: String,
required: false,
trim: true,
maxLength: 500,
default: null,
},
icon: {
type: String,
required: false,
default: null,
},
color: {
type: String,
required: false,
default: '#3B82F6',
match: /^#[0-9A-F]{6}$/i,
},
parent: {
type: mongoose.Schema.Types.ObjectId,
ref: 'AcademyCategory',
required: false,
default: null,
index: true,
},
level: {
type: Number,
required: true,
default: 0,
min: 0,
max: 3,
},
order: {
type: Number,
required: true,
default: 0,
index: true,
},
image: {
type: String,
required: false,
default: null,
},
course_count: {
type: Number,
required: true,
default: 0,
min: 0,
},
status: {
type: String,
enum: ['active', 'inactive', 'deleted'],
required: true,
default: 'active',
index: true,
},
is_featured: {
type: Boolean,
required: true,
default: false,
index: true,
},
seo_title: {
type: String,
required: false,
trim: true,
maxLength: 70,
},
seo_description: {
type: String,
required: false,
trim: true,
maxLength: 160,
},
created_by: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
updated_by: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: false,
},
}, {
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// Virtual برای زیردسته‌ها
AcademyCategorySchema.virtual('children', {
ref: 'AcademyCategory',
localField: '_id',
foreignField: 'parent',
justOne: false,
options: { sort: { order: 1, title: 1 } }
});
// Virtual برای دوره‌های این دسته
AcademyCategorySchema.virtual('courses', {
ref: 'Course',
localField: '_id',
foreignField: 'category_id',
justOne: false,
match: { status: 'accept' }
});
// Middleware: قبل از save
AcademyCategorySchema.pre('save', async function(next) {
if (this.isModified('title') || this.isNew) {
// تولید slug
this.slug = this.title
.replace(/[^\u0600-\u06FF\uFB8A\u067E\u0686\u06AF\u200C\uFB8E\u0698a-zA-Z0-9\s]/g, '')
.trim()
.replace(/\s+/g, '-')
.toLowerCase();
// اگه parent داره، سطح رو محاسبه کن
if (this.parent) {
const parent = await this.constructor.findById(this.parent);
if (parent) {
this.level = parent.level + 1;
}
} else {
this.level = 0;
}
}
next();
});
// استاتیک متدها
AcademyCategorySchema.statics = {
// دریافت درخت دسته‌بندی‌ها
async getTree() {
const categories = await this.find({ status: 'active', parent: null })
.sort({ order: 1, title: 1 })
.populate({
path: 'children',
match: { status: 'active' },
options: { sort: { order: 1, title: 1 } },
populate: {
path: 'children',
match: { status: 'active' },
options: { sort: { order: 1, title: 1 } }
}
});
return categories;
},
// افزایش تعداد دوره‌ها
async incrementCourseCount(categoryId, increment = 1) {
return this.findByIdAndUpdate(categoryId, {
$inc: { course_count: increment }
});
},
// دریافت دسته‌بندی با اسلاگ
async getBySlug(slug) {
return this.findOne({ slug, status: 'active' })
.populate('children')
.populate('parent');
}
};
AcademyCategorySchema.plugin(timestamp);
AcademyCategorySchema.plugin(mongoosePaginate);
module.exports = mongoose.model('AcademyCategory', AcademyCategorySchema);

View File

@@ -0,0 +1,71 @@
const mongoose = require('mongoose');
const timestamp = require('mongoose-timestamp');
const mongoosePaginate = require('mongoose-paginate-v2');
const modelSchema = new mongoose.Schema({
course_images: {
type: [String], // آرایه‌ای از مسیرهای تصاویر
required: false,
default: [],
},
course_video: {
type: String, // مسیر ویدئو
required: false,
trim: true,
default: null,
},
type: {
type: String, // نوع پست: 'image' یا 'video'
required: true,
enum: ['image', 'video'],
},
files: [
{
path: { type: String, required: false },
type: { type: String, enum: ['image', 'video'], required: false },
}
],
caption: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 1000,
default: null,
},
status: {
type: String,
required: false,
trim: true,
enum: ['pending', 'accept', 'reject'],
default: 'accept',
},
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
likes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
}],
comments: {
type: Object,
default: null,
},
courseId: {
type: String,
},
is_free: {
type: Boolean,
},
file_name: {
type: String,
},
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model('AcademyContent', modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,25 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
user_id: {
type: String,
required: true,
trim: true,
default: 0,
},
course_id: {
type: String,
required: true,
trim: true,
default: 0,
},
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("Likes", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,55 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
academy_image: {
type: String,
required: false,
trim: true,
default: null,
},
academy_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null,
},
userId: {
type: String,
required: true,
},
rate: {
type: Number,
trim: true,
default: 0
},
number_of_rate: {
type: Number,
trim: true,
default: 0
},
sheba: {
type: String,
required: false,
default: null
},
bio: {
type: String,
required: false,
default: null
},
tag: {
type: Object,
required: false,
default: null
},
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("Academy", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,24 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
price: { type: Number, required: true }, // قیمت نهایی شامل مالیات
discountAmount: { type: Number, default: 0 }, // مبلغ تخفیف
taxAmount: { type: Number, default: 0 }, // مبلغ مالیات
taxRate: { type: Number, default: 9 }, // درصد مالیات
course_name: { type: String, required: true },
course_id: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true },
academy_id: { type: mongoose.Schema.Types.ObjectId, ref: "Academy", required: true },
user_id: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
payment_authority: { type: String },
payment_ref_id: { type: String },
status: { type: String, enum: ["pending", "success", "failed", "settled"], default: "success" },
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("AcademyPayment", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,80 @@
const mongoose = require('mongoose')
const mongoosePaginate = require('mongoose-paginate-v2')
const timestamp = require('mongoose-timestamp')
const adminSchema = new mongoose.Schema({
mobile: {
type: String,
required: false,
trim: true,
unique: true
},
otp: {
type: String,
required: false,
trim: true,
minLength: 6,
maxLength: 6
},
user_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
password: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
first_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
last_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
user_type: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
profile_image: {
type: String,
required: false,
trim: true
},
gender: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
last_online: {
type: Date, // ذخیره تاریخ و زمان آخرین بازدید
default: Date.now // تنظیم تاریخ به تاریخ فعلی
}
})
adminSchema.plugin(timestamp)
adminSchema.plugin(mongoosePaginate)
const AdminModel = mongoose.model('Admin', adminSchema)
module.exports = AdminModel

View File

@@ -0,0 +1,23 @@
const mongoose = require('mongoose')
const mongoosePaginate = require('mongoose-paginate-v2')
const timestamp = require('mongoose-timestamp')
const advertisingCategorySchema = new mongoose.Schema({
title: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
status: {
type: Boolean,
required: false,
default: true
}
})
advertisingCategorySchema.plugin(timestamp)
advertisingCategorySchema.plugin(mongoosePaginate)
const AdvertisingCategoryModel = mongoose.model('AdvertisingCategory', advertisingCategorySchema)
module.exports = AdvertisingCategoryModel

View File

@@ -0,0 +1,32 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const advertisingCommentSchema = new mongoose.Schema({
advertisingId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Advertising',
required: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
text: {
type: String,
required: true,
trim: true
},
status: {
type: String,
enum: ['pending', 'accepted', 'rejected'],
default: 'pending'
}
})
advertisingCommentSchema.plugin(timestamp)
advertisingCommentSchema.plugin(mongoosePaginate)
const AdvertisingComment = mongoose.model('AdvertisingComment', advertisingCommentSchema)
module.exports = AdvertisingComment

View File

@@ -0,0 +1,27 @@
const mongoose = require('mongoose')
const mongoosePaginate = require('mongoose-paginate-v2')
const timestamp = require('mongoose-timestamp')
const advertisingFeaturesSchema = new mongoose.Schema({
title: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
status: {
type: Boolean,
required: false,
default: true
},
type: {
type: String,
default: 'boolean'
}
})
advertisingFeaturesSchema.plugin(timestamp)
advertisingFeaturesSchema.plugin(mongoosePaginate)
const AdvertisingFeaturesModel = mongoose.model('AdvertisingFeatures', advertisingFeaturesSchema)
module.exports = AdvertisingFeaturesModel

View File

@@ -0,0 +1,20 @@
const mongoose = require('mongoose')
const advertisingLikeSchema = new mongoose.Schema({
advertisingId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Advertising',
required: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
})
// تعریف مدل AdvertisingLikeModel
const AdvertisingLikeModel = mongoose.model('AdvertisingLike', advertisingLikeSchema)
module.exports = AdvertisingLikeModel

View File

@@ -0,0 +1,230 @@
const mongoose = require('mongoose')
const mongoosePaginate = require('mongoose-paginate-v2')
const timestamp = require('mongoose-timestamp')
const advertisingSchema = new mongoose.Schema({
title: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
category: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
description: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 1024,
default: null
},
province: {
type: Object,
default: null
},
city: {
type: Object,
default: null
},
neighbourhood: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
address: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
lat: {
type: String,
trim: true,
minLength: 1,
maxLength: 256,
default: null
},
lng: {
type: String,
trim: true,
minLength: 1,
maxLength: 256,
default: null
},
images: [{
type: String,
required: false,
trim: true,
default: null
}],
services: [{
title: {
type: String,
required: true,
trim: true
},
originalPrice: {
type: String,
required: true
},
discountPrice: {
type: String,
required: false
},
discountPercentage: {
type: Number,
required: false
},
status: {
type: String,
default: "UnderReview",
required: false
},
image: {
type: String,
required: false,
trim: true
}
}],
features: [{
title: {
type: String,
required: false,
trim: true
},
value: {
type: Boolean,
required: true
}
}],
contactInfo: {
phone: {
type: String,
required: false,
trim: true,
default: null
},
mobile: {
type: String,
required: false,
trim: true,
default: null
},
telegramLink: {
type: String,
required: false,
trim: true,
default: null
},
whatsappNumber: {
type: String,
required: false,
trim: true,
default: null
},
instagramLink: {
type: String,
required: false,
trim: true,
default: null
},
saveInfoForNextAds: {
type: Boolean,
required: true,
default: false
}
},
type: {
type: String,
enum: ['free', 'normal', 'special', 'highlight'],
default: 'normal'
},
status: {
type: String,
enum: ['pre_payment', 'paid', 'accepted', 'rejected', 'expired'],
default: 'pre_payment'
},
payment_status: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
creator_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
reject_reason: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 1024,
default: null
},
mostDiscountPercentage: {
type: Number,
required: false
},
showDiscount: {
type: Boolean,
required: false,
default: false
},
likesCount: {
type: Number,
default: 0
},
commentsCount: {
type: Number,
default: 0
},
ratings: [{
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
rating: {
type: Number,
required: true,
min: 1,
max: 5
}
}],
averageRating: {
type: Number,
default: 0
},
ratingsCount: {
type: Number,
default: 0
},
viewCount: {
type: Number,
default: 0
},
acceptedAt: {
type: Date,
default: null
}
})
advertisingSchema.plugin(timestamp)
advertisingSchema.plugin(mongoosePaginate)
const AdvertisingModel = mongoose.model('Advertising', advertisingSchema)
module.exports = AdvertisingModel

View File

@@ -0,0 +1,113 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const advertisingProfileSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
profile_image: {
type: String,
required: false,
trim: true,
default: null
},
image_count: {
type: Number,
default: 0
},
like_count: {
type: Number,
default: 0
},
comment_count: {
type: Number,
default: 0
},
vitrine_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
category: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
about_us: {
type: String,
required: false,
minLength: 1,
maxLength: 1024,
default: null
},
province: {
type: Object,
default: null
},
city: {
type: Object,
default: null
},
lat: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
lng: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
address: {
type: String,
required: false,
trim: true,
default: null
},
neighbourhood: {
type: String,
required: false,
trim: true,
default: null
},
adTotalRatings: {
type: Number,
default: 0
},
adRatingsCount: {
type: Number,
default: 0
},
adAverageRating: {
type: Number,
default: 0
},
contactInfo: {
phone: String,
mobile: String,
telegramLink: String,
whatsappNumber: String,
instagramLink: String,
saveInfoForNextAds: { type: Boolean, default: false }
}
})
advertisingProfileSchema.plugin(timestamp)
advertisingProfileSchema.plugin(mongoosePaginate)
const AdvertisingProfileModel = mongoose.model('AdvertisingProfile', advertisingProfileSchema)
module.exports = AdvertisingProfileModel

View File

@@ -0,0 +1,26 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const advertisingRatingSchema = new mongoose.Schema({
advertisingId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Advertising',
required: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
rating: {
type: Number,
required: true,
min: 1,
max: 5
}
})
advertisingRatingSchema.plugin(timestamp)
const AdvertisingRatingModel = mongoose.model('AdvertisingRating', advertisingRatingSchema)
module.exports = AdvertisingRatingModel

View File

@@ -0,0 +1,25 @@
const mongoose = require('mongoose')
const advertisingTypeSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
minLength: 1,
maxLength: 255
},
price: {
type: Number,
required: true,
trim: true,
minLength: 1,
maxLength: 255
},
status: {
type: String,
default: 'active'
}
})
const AdvertisingTypeModel = mongoose.model('AdvertisingType', advertisingTypeSchema)
module.exports = AdvertisingTypeModel

View File

@@ -0,0 +1,54 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const commentSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
project: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Project',
required: false
},
offer: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Offer',
required: false
},
creator: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
rating: {
type: Number,
required: true
},
comment: {
type: String,
trim: true
},
post: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Post',
required: false
},
comment_for: {
type: String,
enum: ['user', 'project', 'post'],
required: true
},
status: {
type: String,
enum: ['pending', 'accepted', 'rejected'],
default: 'pending'
}
})
commentSchema.plugin(timestamp)
commentSchema.plugin(mongoosePaginate)
const CommentModel = mongoose.model('Comment', commentSchema)
module.exports = CommentModel

View File

@@ -0,0 +1,40 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
user_id: {
type: String,
required: true,
trim: true,
},
course_id: {
type: String,
required: true,
trim: true,
},
comment: {
type: String,
required: true,
trim: true,
default: 0,
},
rate: {
type: String,
required: true,
trim: true,
default: 0,
},
status: {
type: String,
enum: ['pending', 'accepted', 'rejected'],
default: 'accepted'
}
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("CourseComment", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,79 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
price: {
type: String,
required: true,
},
cuorse_name: {
type: String,
required: true,
},
category:{
type: String
},
offer: {
type: String
},
academyId:{
type: String
},
caption: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 1000,
default: null,
},
status: {
type: String,
required: false,
trim: true,
enum: ['pending', 'accept', 'reject'],
default: 'accept',
},
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
likes: {
type: Number,
required: false,
trim: true,
default: 0,
},
comments: {
type: Object,
default: null,
},
type: {
type: String,
required: false,
trim: true,
enum: ['normal', 'pro', 'legend'],
default: "normal",
},
course_time: {
type: String
},
number_of_course_content: {
type: String
},
teacher_number: {
type: String
},
course_image: {
type: String
},
teacher_name: {
type: String
},
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("Course", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,22 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
price: {
type: String,
required: true,
},
cuorse_type: {
type: String,
required: false,
trim: true,
enum: ['normal', 'pro', 'legend'],
},
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("CoursePayment", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,47 @@
// const mongoose = require('mongoose')
// const timestamp = require('mongoose-timestamp')
// const subExpertiseSchema = new mongoose.Schema({
// id: {
// type: mongoose.Schema.Types.ObjectId,
// ref: 'ExpertiseModel'
// },
// name: {
// type: String
// }
// })
// const expertiseSchema = new mongoose.Schema({
// expertise: {
// type: String,
// unique: true
// },
// sub_expertise: [subExpertiseSchema]
// })
// expertiseSchema.plugin(timestamp)
// const ExpertiseModel = mongoose.model('Expertise', expertiseSchema)
// module.exports = ExpertiseModel
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const subExpertiseSchema = new mongoose.Schema({
name: {
type: String
}
})
const expertiseSchema = new mongoose.Schema({
expertise: {
type: String,
unique: true
},
sub_expertise: [subExpertiseSchema]
})
expertiseSchema.plugin(timestamp)
const ExpertiseModel = mongoose.model('Expertise', expertiseSchema)
module.exports = ExpertiseModel

View File

@@ -0,0 +1,15 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
user_id: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
course_id: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true },
price_paid: { type: Number, required: true }, // قیمت پرداخت شده
purchased_at: { type: Date, default: Date.now },
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("IsPaymentCourse", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,25 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const likeSchema = new mongoose.Schema({
postId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Post',
required: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
likes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}]
})
// تعریف مدل LikeModel
likeSchema.plugin(timestamp)
const LikeModel = mongoose.model('Like', likeSchema)
module.exports = LikeModel

View File

@@ -0,0 +1,51 @@
const mongoose = require('mongoose')
const { Schema } = mongoose
const messageSchema = new Schema({
senderId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
receiverId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
content: {
type: String,
required: false,
trim: true
},
file: {
type: String,
required: false
},
fileType: {
type: String,
enum: ['image', 'video', 'file', 'voice', 'location'],
required: false
},
replyToId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Message',
required: false
},
forwardedFrom: {
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
userName: String,
displayName: String
},
createdAt: {
type: Date,
default: Date.now
},
readStatus: {
type: Number,
default: 0
}
})
const MessageModel = mongoose.model('Message', messageSchema)
module.exports = MessageModel

View File

@@ -0,0 +1,37 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const notificationSchema = new mongoose.Schema({
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User', // مرجع به مدل کاربر
required: true
},
project_post_id: {
type: mongoose.Schema.Types.ObjectId,
required: false
},
type: {
type: String,
required: true
},
title: {
type: String,
required: true
},
description: {
type: String,
required: true
},
read: {
type: Boolean,
default: false // وضعیت خوانده شده یا نشده
}
})
notificationSchema.plugin(timestamp)
notificationSchema.plugin(mongoosePaginate)
const NotificationModel = mongoose.model('Notification', notificationSchema)
module.exports = NotificationModel

View File

@@ -0,0 +1,32 @@
const mongoose = require('mongoose')
const mongoosePaginate = require('mongoose-paginate-v2')
const timestamp = require('mongoose-timestamp')
const offerSchema = new mongoose.Schema({
sender: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
receiver: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
status: {
type: String,
enum: ['pending', 'accepted', 'rejected'], // وضعیت درخواست
default: 'pending'
},
transaction_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Payment',
default: null
}
})
offerSchema.plugin(timestamp)
offerSchema.plugin(mongoosePaginate)
const OfferModel = mongoose.model('Offer', offerSchema)
module.exports = OfferModel

View File

@@ -0,0 +1,21 @@
const mongoose = require('mongoose')
const offerTypeModelSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
minLength: 1,
maxLength: 255
},
price: {
type: Number,
required: true,
trim: true,
minLength: 1,
maxLength: 255
}
})
const OfferTypeModel = mongoose.model('OfferTypeModel', offerTypeModelSchema)
module.exports = OfferTypeModel

View File

@@ -0,0 +1,46 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const paymentSchema = new mongoose.Schema({
amount: {
type: Number,
required: true
},
status: {
type: String,
enum: ['pending', 'successful', 'failed'],
default: 'pending'
},
authority: {
type: String,
required: false
},
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
project_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Project'
},
advertising_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Advertising'
},
type: {
type: String,
required: true
},
installment_step: {
type: String,
required: false,
default: null
}
})
paymentSchema.plugin(timestamp)
paymentSchema.plugin(mongoosePaginate)
const PaymentModel = mongoose.model('Payment', paymentSchema)
module.exports = PaymentModel

View File

@@ -0,0 +1,60 @@
const mongoose = require('mongoose');
const timestamp = require('mongoose-timestamp');
const mongoosePaginate = require('mongoose-paginate-v2');
const modelSchema = new mongoose.Schema({
post_images: {
type: [String], // آرایه‌ای از مسیرهای تصاویر
required: false,
default: [],
},
post_video: {
type: String, // مسیر ویدئو
required: false,
trim: true,
default: null,
},
type: {
type: String, // نوع پست: 'image' یا 'video'
required: true,
enum: ['image', 'video'],
},
files: [
{
path: { type: String, required: true },
type: { type: String, enum: ['image', 'video'], required: true },
}
],
caption: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 1000,
default: null,
},
status: {
type: String,
required: false,
trim: true,
enum: ['pending', 'accept', 'reject'],
default: 'pending',
},
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
likes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
}],
comments: {
type: Object,
default: null,
},
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model('Post', modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,186 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const projectSchema = new mongoose.Schema({
title: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
expertise: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
sub_expertise: {
type: [String], // تغییر نوع به آرایه از استرینگ
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
gender: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
age: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
conversation_projects: {
type: Boolean,
required: false,
default: null
},
province: {
type: Object,
default: null
},
city: {
type: Object,
default: null
},
offer_time: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
offer_price: {
type: Number,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
description: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 1024,
default: null
},
project_type: {
type: String,
enum: ['free', 'normal', 'force', 'highlight'],
default: 'normal'
},
payment_status: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
// فیلد برای وضعیت پروژه
status: {
type: String,
enum: ['pre_payment', 'paid', 'accepted', 'rejected', 'done', 'cancled', 'ongoing'],
default: 'pre_payment'
},
public_status: {
type: String,
enum: ['public', 'private'],
default: 'public'
},
created_for_user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
default: null
},
creator_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
requested_users: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
selected_user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
default: null
},
final_price: {
type: Number,
required: false,
default: null
},
final_time: {
type: Number,
required: false,
default: null
},
installments: [{
installment_number: {
type: Number,
required: true
},
amount: {
type: Number,
required: true
},
due_date: {
type: Date,
required: true
}
}],
reject_reason: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 1024,
default: null
},
ratings: [{
creator_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
rating: {
type: Number,
required: true
},
comment: {
type: String,
trim: true
}
}],
acceptedAt: {
type: Date,
default: null
},
isExpired: {
type: Boolean,
default: false
}
})
projectSchema.plugin(timestamp)
// اضافه کردن پیجینیشن به مدل
projectSchema.plugin(mongoosePaginate)
const ProjectModel = mongoose.model('Project', projectSchema)
module.exports = ProjectModel

View File

@@ -0,0 +1,33 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const requestSchema = new mongoose.Schema({
project: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Project',
required: true
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
time: {
type: Number,
required: true
},
price: {
type: Number,
required: true
},
status: {
type: String,
enum: ['pending', 'accepted', 'rejected'],
default: 'pending'
}
})
requestSchema.plugin(timestamp)
const RequestModel = mongoose.model('Request', requestSchema)
module.exports = RequestModel

View File

@@ -0,0 +1,19 @@
// models/Setting.js
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const settingSchema = new mongoose.Schema({
key: {
type: String,
required: true,
unique: true
},
value: {
type: String,
required: true
}
})
settingSchema.plugin(timestamp)
const SettingModel = mongoose.model('Setting', settingSchema)
module.exports = SettingModel

View File

@@ -0,0 +1,19 @@
const mongoose = require('mongoose')
const provincesSchema = new mongoose.Schema({
id: Number,
name: String,
slug: String
})
const citiesSchema = new mongoose.Schema({
id: Number,
name: String,
slug: String,
province_id: Number
})
const ProvinceModel = mongoose.model('Province', provincesSchema)
const CityModel = mongoose.model('City', citiesSchema)
module.exports = { ProvinceModel, CityModel }

View File

@@ -0,0 +1,15 @@
const mongoose = require("mongoose");
const timestamp = require("mongoose-timestamp");
const mongoosePaginate = require("mongoose-paginate-v2");
const modelSchema = new mongoose.Schema({
type: { type: String, enum: ["course", "product", "service"], required: true },
tax: { type: Number, required: true, default: 9 }, // درصد مالیات
is_active: { type: Boolean, default: true },
});
modelSchema.plugin(timestamp);
modelSchema.plugin(mongoosePaginate);
const PostModel = mongoose.model("Tax", modelSchema);
module.exports = PostModel;

View File

@@ -0,0 +1,20 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const ticketMessageSchema = new mongoose.Schema({
text: { type: String, required: false },
file: {
type: String, // مسیر فایل در ذخیره شود
required: false // فایل اختیاری است
},
senderType: { type: String, enum: ['User', 'Admin'], required: true },
senderId: { type: mongoose.Schema.Types.ObjectId, refPath: 'senderType', required: true },
ticket: { type: mongoose.Schema.Types.ObjectId, ref: 'Ticket', required: true }
})
ticketMessageSchema.plugin(timestamp)
// اضافه کردن پیجینیشن به مدل
ticketMessageSchema.plugin(mongoosePaginate)
const TicketMessageModel = mongoose.model('TicketMessage', ticketMessageSchema)
module.exports = TicketMessageModel

View File

@@ -0,0 +1,24 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
const ticketSchema = new mongoose.Schema({
title: { type: String, required: true },
status: { type: String, enum: ['Pending', 'Answered', 'Customer Response', 'Closed'], default: 'Pending' },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
new_message: {
type: Boolean,
default: false // وضعیت خوانده شده یا نشده
}
})
ticketSchema.pre('save', function (next) {
this.updatedAt = Date.now()
next()
})
ticketSchema.plugin(timestamp)
// اضافه کردن پیجینیشن به مدل
ticketSchema.plugin(mongoosePaginate)
const TicketModel = mongoose.model('Ticket', ticketSchema)
module.exports = TicketModel

View File

@@ -0,0 +1,21 @@
const mongoose = require('mongoose')
const typeAndPriceSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
minLength: 1,
maxLength: 255
},
price: {
type: Number,
required: true,
trim: true,
minLength: 1,
maxLength: 255
}
})
const TypeAndPriceModel = mongoose.model('TypeAndPrice', typeAndPriceSchema)
module.exports = TypeAndPriceModel

View File

@@ -0,0 +1,381 @@
const mongoose = require('mongoose')
const mongoosePaginate = require('mongoose-paginate-v2')
const timestamp = require('mongoose-timestamp')
const userSchema = new mongoose.Schema({
mobile: {
type: String,
required: false,
trim: true,
unique: true
},
otp: {
type: String,
required: false,
trim: true,
minLength: 6,
maxLength: 6,
default: null
},
user_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
password: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
first_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
last_name: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
user_type: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
is_verified: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: 'none'
},
profile_image: {
type: String,
required: false,
trim: true,
default: null
},
national_card_image: {
type: String,
required: false,
trim: true,
default: null
},
expertise: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
sub_expertise: {
type: [String], // تغییر نوع به آرایه از استرینگ
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
gender: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
height: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
weight: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
size: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
eye_color: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
hair_color: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
cooperation_type: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
cooperation_abroad: {
type: Boolean,
required: false,
default: null
},
conversation_projects: {
type: Boolean,
required: false,
default: null
},
bio: {
type: String,
required: false,
minLength: 1,
maxLength: 1024,
default: null
},
national_code: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
birthday: {
type: Date,
required: false,
default: null
},
is_Register: {
type: String,
default: false
},
shaba: {
type: String,
required: false,
trim: true,
minLength: 1,
maxLength: 255,
default: null
},
address: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
province: {
type: Object,
default: null
},
city: {
type: Object,
default: null
},
lat: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
lng: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
show_location: {
type: Boolean,
required: false,
default: null
},
rate: {
type: String,
trim: true,
maxLength: 512,
default: null
},
referral_code: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
shop_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Shop',
default: null
},
last_online: {
type: Date, // ذخیره تاریخ و زمان آخرین بازدید
default: Date.now // تنظیم تاریخ به تاریخ فعلی
},
business_license: {
type: String,
required: false,
trim: true,
default: null
},
user_level: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
user_score: {
type: String,
trim: true,
minLength: 1,
maxLength: 512,
default: null
},
last_post: {
type: Object,
default: null
},
// user_ratings: [{
// project_id: {
// type: mongoose.Schema.Types.ObjectId,
// ref: 'Project',
// required: true
// },
// creator_id: {
// type: mongoose.Schema.Types.ObjectId,
// ref: 'User',
// required: true
// },
// rating: {
// type: Number,
// required: true
// },
// comment: {
// type: String,
// trim: true
// }
// }],
daily_free_request: {
type: Number,
default: 1
},
last_free_request_date: {
type: Date,
default: null
},
blocked_by: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
blocked_users: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
block_status: {
type: Boolean,
required: false,
default: null
},
advertising_profiles: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'AdvertisingProfile'
}],
services: [{
title: {
type: String,
required: true,
trim: true
},
originalPrice: {
type: String,
required: true
},
discountPrice: {
type: String,
required: false
},
discountPercentage: {
type: Number,
required: false
},
status: {
type: String,
default: "UnderReview",
required: false
},
image: {
type: String,
required: false,
trim: true
}
}],
monthly_free_offer: {
type: Number,
default: 1
},
last_free_offer_date: {
type: Date,
default: null
}
// contactInfo: {
// phone: String,
// mobile: String,
// telegramLink: String,
// whatsappNumber: String,
// instagramLink: String,
// saveInfoForNextAds: { type: Boolean, default: false }
// },
// adTotalRatings: {
// type: Number,
// default: 0
// },
// adRatingsCount: {
// type: Number,
// default: 0
// },
// adAverageRating: {
// type: Number,
// default: 0
// }
})
userSchema.plugin(timestamp)
userSchema.plugin(mongoosePaginate)
const UserModel = mongoose.model('User', userSchema)
module.exports = UserModel

View File

@@ -0,0 +1,12 @@
// models/VersionModel.js
const mongoose = require('mongoose')
const versionSchema = new mongoose.Schema({
version: { type: String, required: true },
mandatory: { type: Boolean, required: true },
releaseNotes: { type: String },
updateUrls: { type: [String], required: true } // لینک‌های آپدیت
})
const VersionModel = mongoose.model('Version', versionSchema)
module.exports = VersionModel

View File

@@ -0,0 +1,14 @@
const mongoose = require('mongoose')
const mongoosePaginate = require('mongoose-paginate-v2')
const timestamp = require('mongoose-timestamp')
const license = new mongoose.Schema({
userId : String,
Confirmation : { type : Boolean, default : false},
licenseImg : { type : String },
})
license.plugin(timestamp)
license.plugin(mongoosePaginate)
const LicenseMoldel = mongoose.model('license', license)
module.exports = LicenseMoldel

View File

@@ -1,242 +0,0 @@
const mongoose = require('mongoose')
const bcrypt = require('bcryptjs')
const UserModel = require('../../../models/UserModel')
const DeviceLinkSessionModel = require('../../../models/DeviceLinkSessionModel')
const DEVICE_LINK_TTL_MS = 5 * 60 * 1000
function findOwnSession (linkId, userId) {
if (!mongoose.Types.ObjectId.isValid(linkId)) return null
return DeviceLinkSessionModel.findOne({ _id: linkId, user: userId })
}
const putMyPublicKey = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { publicKey } = req.body
if (!publicKey || typeof publicKey !== 'string' || publicKey.length < 32 || publicKey.length > 4096) {
return res.status(422).json({ error: 'publicKey is required' })
}
const trimmed = String(publicKey).trim()
const existing = await UserModel.findById(userId).select('e2ee_public_key').lean()
// کلید عمومی همیشه یکسان می‌مونه مگر کاربر واقعاً هویتِ E2EE‌ش رو عوض کنه (دستگاه
// جدید بدون لینک‌کردن، پاک‌شدن دیتای اپ و...). اگه از قبل یه کلید ثبت‌شده و مقدار
// تازه باهاش فرق داره، یعنی یا کلاینت واقعاً هویتش رو عوض کرده یا یه باگ کلاینتی
// (مثلاً race توی ساختن کلید) داره جایگزینش می‌کنه — فقط لاگ می‌کنیم تا قابل‌ردیابی
// باشه، بدون این‌که خودِ درخواست رو رد کنیم (چون سرور نمی‌تونه تشخیص بده کدوم درسته)
if (existing?.e2ee_public_key && existing.e2ee_public_key !== trimmed) {
console.warn(`[e2ee] public key rotated for user ${userId} (${existing.e2ee_public_key.length} -> ${trimmed.length} chars)`)
}
if (existing?.e2ee_public_key !== trimmed) {
await UserModel.findByIdAndUpdate(userId, { e2ee_public_key: trimmed })
}
res.json({ ok: true })
} catch (error) {
next(error)
}
}
const getPublicKeyByUserId = async (req, res, next) => {
try {
const { userId } = req.params
if (!userId) {
return res.status(400).json({ error: 'userId is required' })
}
const user = await UserModel.findById(userId).select('e2ee_public_key').lean()
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
res.json({
userId: String(user._id),
publicKey: user.e2ee_public_key || null
})
} catch (error) {
next(error)
}
}
/**
* New device (no E2EE identity yet) starts a link session with its own ephemeral
* ECDH public key. Returns a linkId to encode as a QR — the already-linked device
* scans it, wraps its identity private key against this ephemeral key, and posts
* it back via `approveDeviceLink`. Server only ever stores/relays ciphertext.
*/
const startDeviceLink = async (req, res, next) => {
try {
const { ephemeralPublicKey } = req.body
if (
!ephemeralPublicKey ||
typeof ephemeralPublicKey !== 'string' ||
ephemeralPublicKey.length < 32 ||
ephemeralPublicKey.length > 2048
) {
return res.status(422).json({ error: 'ephemeralPublicKey is required' })
}
const session = await DeviceLinkSessionModel.create({
user: req.user._id,
ephemeralPublicKeyB64: ephemeralPublicKey.trim(),
expiresAt: new Date(Date.now() + DEVICE_LINK_TTL_MS)
})
res.json({ linkId: String(session._id), expiresAt: session.expiresAt })
} catch (error) {
next(error)
}
}
/** Already-linked device fetches the new device's ephemeral public key to wrap the identity key against. */
const getDeviceLinkSession = async (req, res, next) => {
try {
const session = await findOwnSession(req.params.linkId, req.user._id)
if (!session) {
return res.status(404).json({ error: 'Link not found or expired' })
}
res.json({
ephemeralPublicKey: session.ephemeralPublicKeyB64,
status: session.status
})
} catch (error) {
next(error)
}
}
/** Already-linked device submits the identity private key wrapped for the new device's ephemeral key. */
const approveDeviceLink = async (req, res, next) => {
try {
const { wrappedIdentityKey } = req.body
if (!wrappedIdentityKey || typeof wrappedIdentityKey !== 'string') {
return res.status(422).json({ error: 'wrappedIdentityKey is required' })
}
const session = await findOwnSession(req.params.linkId, req.user._id)
if (!session) {
return res.status(404).json({ error: 'Link not found or expired' })
}
session.wrappedIdentityKey = wrappedIdentityKey
session.status = 'fulfilled'
await session.save()
res.json({ ok: true })
} catch (error) {
next(error)
}
}
/** New device polls until the wrapped key arrives. Single-use: the session is deleted once consumed. */
const pollDeviceLink = async (req, res, next) => {
try {
const session = await findOwnSession(req.params.linkId, req.user._id)
if (!session) {
return res.status(404).json({ error: 'Link not found or expired' })
}
if (session.status !== 'fulfilled' || !session.wrappedIdentityKey) {
return res.json({ status: 'pending' })
}
const wrappedIdentityKey = session.wrappedIdentityKey
await DeviceLinkSessionModel.deleteOne({ _id: session._id })
res.json({ status: 'fulfilled', wrappedIdentityKey })
} catch (error) {
next(error)
}
}
/**
* کلیدِ E2EE حالا از رمزِ عبورِ خودِ کاربر مشتق می‌شه (نه یه رمزِ خودکارِ ساخته‌شده‌ی
* سرور) — این اندپوینت فقط رمزِ واردشده رو با bcrypt در برابرِ هشِ ذخیره‌شده تایید
* می‌کنه (عینِ loginWithUserName.js)، تا کلاینت قبل از اجرای PBKDF2ِ ۲۵۰هزار-تکراریِ
* محلی (برای decryptPrivateKeyFromBackup) یه فیدبکِ سریع برای «رمزِ اشتباه» داشته
* باشه؛ خودِ مشتق‌سازیِ کلید کاملاً سمتِ کلاینت انجام می‌شه، سرور هیچ‌وقت کلید رو نمی‌بینه
*/
const verifyPassword = async (req, res, next) => {
try {
const { password } = req.body
if (!password || typeof password !== 'string') {
return res.status(422).json({ error: 'password is required' })
}
const user = await UserModel.findById(req.user._id).select('password').lean()
if (!user?.password) {
return res.json({ valid: false })
}
const valid = await bcrypt.compare(password, user.password)
res.json({ valid })
} catch (error) {
next(error)
}
}
/**
* ذخیره‌ی بک‌آپِ کلیدِ خصوصیِ رمزنگاری‌شده با PIN/رمزِ انتخابیِ کاربر. سرور فقط
* ciphertext/salt/iv رو می‌بینه — نه خودِ PIN و نه کلیدِ خام. کاربر می‌تونه صدا بزنه
* تا بک‌آپ رو بسازه یا (با یه ciphertext جدید) رمزش رو عوض کنه.
*/
const putKeyBackup = async (req, res, next) => {
try {
const { salt, iv, ciphertext, iterations } = req.body
if (
!salt || typeof salt !== 'string' ||
!iv || typeof iv !== 'string' ||
!ciphertext || typeof ciphertext !== 'string' ||
typeof iterations !== 'number' || iterations < 100000
) {
return res.status(422).json({ error: 'invalid backup payload' })
}
if (ciphertext.length > 8192 || salt.length > 512 || iv.length > 512) {
return res.status(422).json({ error: 'backup payload too large' })
}
await UserModel.findByIdAndUpdate(req.user._id, {
e2ee_key_backup: { salt, iv, ciphertext, iterations }
})
res.json({ ok: true })
} catch (error) {
next(error)
}
}
/** فقط خودِ کاربر می‌تونه بک‌آپِ خودش رو بخونه — برای بازیابی روی یه دستگاه جدید. */
const getKeyBackup = async (req, res, next) => {
try {
const user = await UserModel.findById(req.user._id).select('e2ee_key_backup').lean()
res.json({ backup: user?.e2ee_key_backup || null })
} catch (error) {
next(error)
}
}
const deleteKeyBackup = async (req, res, next) => {
try {
await UserModel.findByIdAndUpdate(req.user._id, { e2ee_key_backup: null })
res.json({ ok: true })
} catch (error) {
next(error)
}
}
module.exports = {
putMyPublicKey,
getPublicKeyByUserId,
startDeviceLink,
getDeviceLinkSession,
approveDeviceLink,
pollDeviceLink,
verifyPassword,
putKeyBackup,
getKeyBackup,
deleteKeyBackup
}

View File

@@ -1,129 +0,0 @@
const UserModel = require('../../../models/UserModel')
const { OTP_VALID_MS, isOtpExpired } = require('../../../utils/otpExpiry')
const { sendSmsIrVerify, getSmsIrTemplateId } = require('../../../utils/smsIr')
function normalizeMobile (value) {
return String(value || '').replace(/\D/g, '')
}
function generateOTP () {
return Math.floor(100000 + Math.random() * 900000)
}
// کاربرانی که با گوگل/اینستاگرام ثبت‌نام کردن و شماره موبایل ندارن، از همین‌جا
// (نه فلوی لاگین) می‌تونن یه بار برای همیشه شماره‌شون رو ثبت کنن. بعد از ثبت،
// دیگه هیچ مسیرِ ویرایشی براش نیست — طبق خواسته‌ی کاربر.
const requestMobileOtp = async (req, res, next) => {
try {
if (req.user.mobile) {
return res.status(422).json({
error: true,
message: 'شماره موبایل شما قبلاً ثبت شده و قابل تغییر نیست'
})
}
const mobile = normalizeMobile(req.body.mobile)
if (!mobile || !/^09[0-9]{9}$/.test(mobile)) {
return res.status(422).json({
error: true,
message: 'فرمت شماره موبایل صحیح نیست'
})
}
const existing = await UserModel.findOne({ mobile })
if (existing) {
return res.status(422).json({
error: true,
message: 'این شماره موبایل قبلاً توسط کاربر دیگری ثبت شده است'
})
}
const otp = generateOTP()
const otpSentAt = new Date()
await UserModel.updateOne(
{ _id: req.user._id },
{ $set: { pending_mobile: mobile, otp: String(otp), otpSentAt } }
)
setTimeout(() => {
UserModel.updateOne(
{ _id: req.user._id, otp: String(otp) },
{ $set: { otp: null, otpSentAt: null, pending_mobile: null } }
).catch((error) => console.error('[requestMobileOtp] clear OTP failed:', error))
}, OTP_VALID_MS)
const smsResult = await sendSmsIrVerify({
mobile,
otp,
templateId: getSmsIrTemplateId('930719')
})
if (!smsResult.ok) {
return res.status(502).json({
error: true,
message: 'ارسال پیامک ناموفق بود. لطفاً کمی بعد دوباره تلاش کنید.'
})
}
return res.json({ success: true, message: 'کد تایید ارسال شد' })
} catch (error) {
next(error)
}
}
const verifyMobileOtp = async (req, res, next) => {
try {
const { otp } = req.body
if (!otp) {
return res.status(422).json({ error: true, message: 'کد تایید الزامی است' })
}
const user = await UserModel.findById(req.user._id)
if (!user.pending_mobile || !user.otp) {
return res.status(422).json({
error: true,
message: 'درخواستی برای ثبت شماره موبایل یافت نشد. دوباره تلاش کنید'
})
}
if (isOtpExpired(user)) {
return res.status(422).json({
error: true,
message: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید'
})
}
if (String(user.otp) !== String(otp)) {
return res.status(422).json({ error: true, message: 'کد را اشتباه وارد کردید' })
}
// بینِ درخواستِ کد و تاییدش، ممکنه یه کاربرِ دیگه دقیقاً همین شماره رو گرفته باشه
const conflict = await UserModel.findOne({ mobile: user.pending_mobile })
if (conflict) {
return res.status(422).json({
error: true,
message: 'این شماره موبایل قبلاً توسط کاربر دیگری ثبت شده است'
})
}
user.mobile = user.pending_mobile
user.pending_mobile = null
user.otp = null
user.otpSentAt = null
await user.save()
return res.json({
success: true,
message: 'شماره موبایل با موفقیت ثبت شد',
mobile: user.mobile
})
} catch (error) {
next(error)
}
}
module.exports = {
requestMobileOtp,
verifyMobileOtp
}

View File

@@ -1,389 +0,0 @@
const axios = require('axios')
const mongoose = require('mongoose')
const AdModel = require('../../../models/AdModel')
const PostModel = require('../../../models/PostModel')
const StoryModel = require('../../../models/StoryModel')
const UserModel = require('../../../models/UserModel')
const { resolveAdPrice } = require('../../../utils/adPricing')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const { deliverNotificationAd, trackAdEvent, findActiveAd, buildAdLinkedContentItem } = require('../../../utils/adDelivery')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const SPONSORED_TYPES = ['project', 'product', 'shop', 'billboard']
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
const AD_CAMPAIGN_DURATION_DAYS = 7
const AD_TYPES = AdModel.AD_TYPES
function toRial (toman) {
return Math.round(Number(toman) * 10)
}
function normalizeTargeting (body) {
// برای POST (createAd) یه آرایه‌ی واقعیه (JSON body)؛ برای GET (getAdPricing) کوئری‌استرینگ
// که فرانت با کاما جدا می‌فرسته — هر دو شکل رو قبول می‌کنیم
let subExpertise = []
if (Array.isArray(body.target_sub_expertise)) {
subExpertise = body.target_sub_expertise.filter(Boolean)
} else if (typeof body.target_sub_expertise === 'string' && body.target_sub_expertise) {
subExpertise = body.target_sub_expertise.split(',').map((s) => s.trim()).filter(Boolean)
}
return {
target_country_id: body.target_country_id != null && body.target_country_id !== '' ? Number(body.target_country_id) : null,
target_province_id: body.target_province_id != null && body.target_province_id !== '' ? Number(body.target_province_id) : null,
target_city_id: body.target_city_id != null && body.target_city_id !== '' ? Number(body.target_city_id) : null,
target_expertise: body.target_expertise ? String(body.target_expertise).trim() : null,
target_sub_expertise: subExpertise,
target_gender: ['female', 'male'].includes(body.target_gender) ? body.target_gender : 'any'
}
}
/** GET /ads/pricing — کاربر با هر انتخابی توی ویزارد، قیمتِ همون سناریو رو زنده می‌بینه */
const getAdPricing = async (req, res, next) => {
try {
const { ad_type: adType } = req.query
if (!AD_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
const targeting = normalizeTargeting(req.query)
const price = await resolveAdPrice(adType, {
provinceId: targeting.target_province_id,
gender: targeting.target_gender === 'any' ? null : targeting.target_gender,
expertise: targeting.target_expertise,
subExpertise: targeting.target_sub_expertise
})
return res.json({ price })
} catch (error) {
next(error)
}
}
async function activateAd (ad) {
ad.status = 'active'
ad.started_at = new Date()
ad.expires_at = new Date(Date.now() + AD_CAMPAIGN_DURATION_DAYS * 24 * 60 * 60 * 1000)
await ad.save()
if (ad.ad_type === 'notification') {
deliverNotificationAd(ad).catch((error) => console.error('deliverNotificationAd error:', error.message))
}
}
/** POST /ads — ساخت تبلیغ؛ عین سفارش فروشگاه/دوره، اول کیف پول رو چک می‌کنه */
const createAd = async (req, res, next) => {
try {
const { ad_type: adType, useWallet } = req.body
if (!AD_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
let contentPostId = null
let contentStoryId = null
let contentMediaPath = null
let contentMediaType = null
let contentOverlays = null
let contentProjectId = null
let contentShopProductId = null
let contentShopId = null
let contentBillboardId = null
let notificationTitle = null
let notificationBody = null
// نگاشتِ ad_type → {کلیدِ ورودی، تابعِ resolveLinkedEntity، پیامِ خطا} برای چهار
// نوعِ «تبلیغِ محتوای موجود» (پروژه/کالا/فروشگاه/بیلبورد) — همون منطقِ مالکیت+وضعیتی
// که ثبتِ پست (resolveLinkedEntity.js) از قبل داره، دوباره‌نویسی نشده
const LINKED_CONTENT_TYPES = {
project: { bodyKey: 'content_project_id', kind: 'project', message: 'پروژه‌ای برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این پروژه نیستید' },
product: { bodyKey: 'content_shop_product_id', kind: 'shopproduct', message: 'کالایی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این کالا نیستید' },
shop: { bodyKey: 'content_shop_id', kind: 'shop', message: 'فروشگاهی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این فروشگاه نیستید' },
billboard: { bodyKey: 'content_billboard_id', kind: 'billboard', message: 'بیلبوردی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این بیلبورد نیستید' }
}
if (adType === 'first_post') {
const postId = req.body.content_post_id
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(422).json({ error: true, message: 'پستی برای تبلیغ انتخاب نشده است' })
}
const post = await PostModel.findById(postId).select('user_id').lean()
if (!post || String(post.user_id) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این پست نیستید' })
}
contentPostId = postId
} else if (adType === 'first_story') {
const storyId = req.body.content_story_id
if (!storyId || !mongoose.Types.ObjectId.isValid(storyId)) {
return res.status(422).json({ error: true, message: 'استوری‌ای برای تبلیغ انتخاب نشده است' })
}
// مدیا/اورلی‌ها همینجا اسنپ‌شات می‌شن (نه فقط رفرنس) — استوریِ اصلی معمولاً ۲۴ساعته
// منقضی می‌شه، ولی کمپینِ تبلیغاتی باید کاملِ مدتش (activateAd) زنده بمونه
const story = await StoryModel.findById(storyId).select('user_id media_path media_type overlays').lean()
if (!story || String(story.user_id) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این استوری نیستید' })
}
contentStoryId = storyId
contentMediaPath = story.media_path
contentMediaType = story.media_type
contentOverlays = story.overlays || []
} else if (adType === 'notification') {
notificationTitle = String(req.body.notification_title || '').trim()
notificationBody = String(req.body.notification_body || '').trim()
if (!notificationTitle || !notificationBody) {
return res.status(422).json({ error: true, message: 'عنوان و متن نوتیفیکیشن الزامی است' })
}
} else if (LINKED_CONTENT_TYPES[adType]) {
const { bodyKey, kind, message, ownerMessage } = LINKED_CONTENT_TYPES[adType]
const rawId = req.body[bodyKey]
if (!rawId || !mongoose.Types.ObjectId.isValid(rawId)) {
return res.status(422).json({ error: true, message })
}
const resolvedId = await resolveLinkedEntity(kind, rawId, req.user._id)
if (!resolvedId) {
return res.status(403).json({ error: true, message: ownerMessage })
}
if (adType === 'project') contentProjectId = resolvedId
else if (adType === 'product') contentShopProductId = resolvedId
else if (adType === 'shop') contentShopId = resolvedId
else if (adType === 'billboard') contentBillboardId = resolvedId
}
const targeting = normalizeTargeting(req.body)
const price = await resolveAdPrice(adType, {
provinceId: targeting.target_province_id,
gender: targeting.target_gender === 'any' ? null : targeting.target_gender,
expertise: targeting.target_expertise,
subExpertise: targeting.target_sub_expertise
})
if (!price || price <= 0) {
return res.status(422).json({ error: true, message: 'قیمتی برای این سناریوی تبلیغ تعریف نشده است' })
}
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(
req.user._id,
price,
useWallet
)
const adPayload = {
user_id: req.user._id,
ad_type: adType,
content_post_id: contentPostId,
content_story_id: contentStoryId,
content_project_id: contentProjectId,
content_shop_product_id: contentShopProductId,
content_shop_id: contentShopId,
content_billboard_id: contentBillboardId,
content_media_path: contentMediaPath,
content_media_type: contentMediaType,
content_overlays: contentOverlays,
notification_title: notificationTitle,
notification_body: notificationBody,
...targeting,
price,
walletAmountUsed: walletAmount
}
if (fullyCovered) {
const ad = await AdModel.create(adPayload)
await debitWalletForPurchase(req.user._id, walletAmount, {
referenceType: 'payment',
referenceId: ad._id,
description: 'ثبت تبلیغ از کیف پول'
})
await activateAd(ad)
return res.status(201).json({ success: true, paidFromWallet: true, adId: ad._id })
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const ad = await AdModel.create({ ...adPayload, status: 'pending_payment' })
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(gatewayAmount),
description: `ثبت تبلیغ (${adType})`,
callback_url: `${callbackBase}/payment/ads/callback?adId=${ad._id}`
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
ad.payment_authority = result.authority
await ad.save()
return res.status(201).json({
success: true,
adId: ad._id,
authority: result.authority,
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
await AdModel.deleteOne({ _id: ad._id })
console.error('ZarinPal initiate error:', response.data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
console.error('createAd error:', error?.response?.data || error.message)
next(error)
}
}
/** GET /ads/payment/verify — کال‌بک زرین‌پال */
const verifyAdPayment = async (req, res, next) => {
const { adId, Authority, Status } = req.query
const failRedirect = `${process.env.APP_SITE}/settings/ads?payment=failed`
const successRedirect = `${process.env.APP_SITE}/settings/ads?payment=success`
try {
const ad = await AdModel.findById(adId)
if (!ad || ad.status !== 'pending_payment') {
return res.redirect(failRedirect)
}
if (Status !== 'OK') {
ad.status = 'failed'
await ad.save()
return res.redirect(failRedirect)
}
const walletAmount = ad.walletAmountUsed || 0
const gatewayAmount = ad.price - walletAmount
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
const data = verify.data?.data
if (data?.code === 100 || data?.code === 101) {
await debitWalletForPurchase(ad.user_id, walletAmount, {
referenceType: 'payment',
referenceId: ad._id,
description: 'ثبت تبلیغ از کیف پول'
})
await activateAd(ad)
return res.redirect(successRedirect)
}
ad.status = 'failed'
await ad.save()
return res.redirect(failRedirect)
} catch (error) {
console.error('verifyAdPayment error:', error?.response?.data || error.message)
return res.redirect(failRedirect)
}
}
/** GET /ads/mine */
const getMyAds = async (req, res, next) => {
try {
const ads = await AdModel.find({ user_id: req.user._id })
.sort({ createdAt: -1 })
.lean()
const now = Date.now()
const withEffectiveStatus = ads.map((ad) => ({
...ad,
effective_status:
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
}))
return res.json({ ads: withEffectiveStatus })
} catch (error) {
next(error)
}
}
/** GET /ads/:id — جزئیات + آنالیتیکس */
const getAdDetail = async (req, res, next) => {
try {
const ad = await AdModel.findById(req.params.id).lean()
if (!ad || String(ad.user_id) !== String(req.user._id)) {
return res.status(404).json({ error: true, message: 'تبلیغ یافت نشد' })
}
const now = Date.now()
const effectiveStatus =
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
let content = null
if (ad.ad_type === 'first_post' && ad.content_post_id) {
content = await PostModel.findById(ad.content_post_id).select('files caption type').lean()
} else if (ad.ad_type === 'first_story') {
// اسنپ‌شاتِ خودِ AdModel — نه لوکاپِ زنده روی StoryModel، چون استوریِ اصلی معمولاً
// ۲۴ساعته منقضی می‌شه ولی این جزئیات باید کاملِ عمرِ تبلیغ در دسترس بمونه
content = { media_path: ad.content_media_path, media_type: ad.content_media_type }
} else if (SPONSORED_TYPES.includes(ad.ad_type)) {
content = await buildAdLinkedContentItem(ad)
}
return res.json({ ad: { ...ad, effective_status: effectiveStatus }, content })
} catch (error) {
next(error)
}
}
/** GET /ads/sponsored?ad_type=project|product|shop|billboard — لیستِ مربوطه (اسپشیالیست‌ها/
* کالاهای فروشگاه/فروشگاه‌ها/بیلبوردها) این رو صدا می‌زنه و اگه چیزی برگشت، اولِ لیستِ خودش
* نشونش می‌ده. برخلافِ first_post/first_story که مستقیم توی fید/استوری تزریق می‌شن، این چهار
* نوع endpoint جدا دارن — چون fetch لیست‌شون توی کنترلرهای پرترافیک/حساسِ جداگانه‌ست و
* دست‌نخورده موندنشون امن‌تره */
const getSponsoredAd = async (req, res, next) => {
try {
const { ad_type: adType } = req.query
if (!SPONSORED_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
const viewer = await UserModel.findById(req.user._id)
.select('country province city gender expertise sub_expertise')
.lean()
const ad = await findActiveAd(adType, { ...viewer, _id: req.user._id })
if (!ad) return res.json({ ad: null })
const item = await buildAdLinkedContentItem(ad)
if (!item) return res.json({ ad: null })
trackAdEvent(ad._id, 'view').catch(() => {})
return res.json({ ad: item })
} catch (error) {
next(error)
}
}
/** POST /ads/:id/track — {type:'view'|'click'} — بیننده صدا می‌زنه، نیازی به مالکیت نیست */
const trackAdEventEndpoint = async (req, res, next) => {
try {
const { type } = req.body
if (!['view', 'click'].includes(type)) {
return res.status(422).json({ error: true, message: 'نوع رویداد نامعتبر است' })
}
await trackAdEvent(req.params.id, type)
return res.json({ success: true })
} catch (error) {
next(error)
}
}
module.exports = {
getAdPricing,
createAd,
verifyAdPayment,
getMyAds,
getAdDetail,
getSponsoredAd,
trackAdEventEndpoint
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +0,0 @@
const jwt = require('jsonwebtoken')
const AnalyticsEvent = require('../../../models/AnalyticsEventModel')
const MAX_BATCH_SIZE = 50
const VALID_TYPES = new Set(['session_start', 'page_view', 'click'])
const VALID_PLATFORMS = new Set(['web', 'android', 'ios'])
function decodeUserId(req) {
const token = req.header('Authorization')?.split(' ')[1]
if (!token) return null
try {
const decoded = jwt.verify(token, process.env.APP_SECRET)
return decoded?.id || null
} catch {
return null
}
}
// ورودیِ خام و کوتاه (بدون توکن الزامی — کاربر مهمون هم ردیابی می‌شه)؛ از هر دو کلاینت
// وب (sendBeacon موقع بستن تب) و اپلیکیشن اندروید/iOS به‌صورت دسته‌ای ارسال می‌شه
const trackEvents = async (req, res, next) => {
try {
const userId = decodeUserId(req)
const events = Array.isArray(req.body?.events) ? req.body.events : []
if (!events.length) return res.status(204).end()
const docs = events.slice(0, MAX_BATCH_SIZE).reduce((acc, e) => {
if (!e || !VALID_TYPES.has(e.type) || !VALID_PLATFORMS.has(e.platform)) {
return acc
}
if (!e.sessionId || typeof e.sessionId !== 'string') return acc
acc.push({
userId,
sessionId: e.sessionId,
type: e.type,
path: typeof e.path === 'string' ? e.path.slice(0, 500) : null,
durationMs:
typeof e.durationMs === 'number' && e.durationMs >= 0
? Math.min(e.durationMs, 24 * 60 * 60 * 1000)
: null,
label: typeof e.label === 'string' ? e.label.slice(0, 200) : null,
platform: e.platform,
device: typeof e.device === 'string' ? e.device.slice(0, 100) : null,
browser: typeof e.browser === 'string' ? e.browser.slice(0, 100) : null,
os: typeof e.os === 'string' ? e.os.slice(0, 100) : null
})
return acc
}, [])
if (docs.length) await AnalyticsEvent.insertMany(docs, { ordered: false })
res.status(204).end()
} catch (error) {
next(error)
}
}
module.exports = { trackEvents }

View File

@@ -1,267 +0,0 @@
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { buildLoginResponseForUser } = require('../../../utils/loginResponse')
const {
expireBlockSuspensionIfNeeded,
getBlockedAccountResponse,
} = require('../../../utils/blockSuspension')
const {
findOrCreateGoogleUser,
findExistingGoogleUser,
} = require('../../../utils/googleUserAuth')
const { verifyGoogleIdToken } = require('../../../utils/googleTokenVerify')
const DEFAULT_GOOGLE_CLIENT_ID =
'91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com'
function getGoogleClientIds() {
const raw =
process.env.GOOGLE_CLIENT_ID ||
process.env.GOOGLE_CLIENT_IDS ||
DEFAULT_GOOGLE_CLIENT_ID
return [...new Set(String(raw).split(',').map((id) => id.trim()).filter(Boolean))]
}
function googleAuthError(res, status, message, extra = {}) {
return res.status(status).json({
error: true,
message,
...extra,
})
}
const googleAuth = async (req, res) => {
try {
if (!process.env.APP_SECRET) {
console.error('[googleAuth] APP_SECRET is not configured')
return googleAuthError(
res,
503,
'پیکربندی سرور برای ورود با گوگل کامل نیست (APP_SECRET)'
)
}
const { credential } = req.body
if (!credential) {
return googleAuthError(res, 422, 'توکن گوگل ارسال نشده است')
}
const clientIds = getGoogleClientIds()
if (clientIds.length === 0) {
return googleAuthError(res, 503, 'ورود با گوگل در حال حاضر فعال نیست')
}
const payload = await verifyGoogleIdToken(credential, clientIds)
const googleId = payload?.sub
const email = payload?.email?.toLowerCase()?.trim()
if (!googleId || !email) {
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
}
if (!payload.email_verified) {
return googleAuthError(res, 422, 'ایمیل حساب گوگل تایید نشده است')
}
let user
try {
user = await findOrCreateGoogleUser({
googleId,
email,
})
} catch (err) {
if (err?.code === 'EMAIL_LINKED_TO_OTHER_GOOGLE') {
return googleAuthError(
res,
409,
'این ایمیل قبلاً با حساب گوگل دیگری ثبت شده است. با همان حساب وارد شوید یا از موبایل/نام کاربری استفاده کنید.'
)
}
if (err?.code === 'MISSING_GOOGLE_IDENTITY') {
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
}
// فقط duplicate key واقعی — نه هر MongoServerError
if (err?.code === 11000) {
user = await findExistingGoogleUser({ googleId, email })
if (!user) {
console.error('[googleAuth] duplicate key unresolved', {
googleId,
email,
keyValue: err.keyValue,
message: err.message,
})
return googleAuthError(
res,
409,
'ثبت این حساب گوگل ممکن نشد. اگر قبلاً ثبت‌نام کرده‌اید با همان روش وارد شوید؛ در غیر این صورت چند لحظه بعد دوباره تلاش کنید.',
{ en_message: err.message, keyValue: err.keyValue || null }
)
}
} else {
throw err
}
}
if (!user?._id) {
return googleAuthError(res, 500, 'ورود با گوگل ناموفق بود')
}
user = await expireBlockSuspensionIfNeeded(user)
const blockResponse = getBlockedAccountResponse(user)
if (blockResponse) {
return res.status(403).json(blockResponse)
}
const token = TokenService.sign({ id: String(user._id) })
const loginPayload = await buildLoginResponseForUser(user, token)
return res.json({
...loginPayload,
id: loginPayload.id != null ? String(loginPayload.id) : String(user._id),
})
} catch (error) {
console.error('[googleAuth] failed:', error)
if (
error?.name === 'TokenExpiredError' ||
error?.message?.includes('jwt expired') ||
error?.message?.includes('Token used too late')
) {
return googleAuthError(
res,
401,
'توکن گوگل منقضی شده است. دوباره تلاش کنید.'
)
}
if (
error?.name === 'JsonWebTokenError' ||
error?.message?.includes('Invalid token') ||
error?.message?.includes('jwt audience invalid') ||
error?.message?.includes('Signing key not found')
) {
return googleAuthError(
res,
401,
'توکن گوگل نامعتبر است. دوباره تلاش کنید.'
)
}
if (error?.message?.includes('JWKS unavailable')) {
return googleAuthError(
res,
503,
'سرور به Google دسترسی ندارد. فایل googleJwksCache.json را روی سرور آپلود کنید.',
{ en_message: error.message }
)
}
if (error?.name === 'ValidationError') {
return googleAuthError(
res,
422,
'ذخیره حساب گوگل ناموفق بود. مدل کاربر روی سرور به‌روز نیست.',
{ en_message: error.message }
)
}
return googleAuthError(
res,
500,
'خطا در ورود با گوگل. لطفاً چند لحظه بعد دوباره تلاش کنید.',
{ en_message: error.message }
)
}
}
const linkGoogleAccount = async (req, res) => {
try {
const user = req.user
const { credential } = req.body
if (!credential) {
return googleAuthError(res, 422, 'توکن گوگل ارسال نشده است')
}
if (user.google_id) {
return googleAuthError(res, 400, 'حساب گوگل قبلاً به این کاربر متصل شده است')
}
const clientIds = getGoogleClientIds()
if (clientIds.length === 0) {
return googleAuthError(res, 503, 'ورود با گوگل در حال حاضر فعال نیست')
}
const payload = await verifyGoogleIdToken(credential, clientIds)
const googleId = payload?.sub
const email = payload?.email?.toLowerCase()?.trim()
if (!googleId || !email) {
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
}
if (!payload.email_verified) {
return googleAuthError(res, 422, 'ایمیل حساب گوگل تایید نشده است')
}
const conflict = await UserModel.findOne({
$or: [
{ google_id: googleId },
{ email },
{ email: new RegExp(`^${String(email).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') },
],
_id: { $ne: user._id },
})
if (conflict) {
return googleAuthError(
res,
409,
'این حساب گوگل یا ایمیل قبلاً به کاربر دیگری متصل شده است'
)
}
await UserModel.updateOne(
{ _id: user._id },
{
$set: {
google_id: googleId,
email: user.email || email,
email_verified: true,
},
},
{ strict: false }
)
return res.json({
message: 'حساب گوگل با موفقیت متصل شد',
email: user.email || email,
linked: true,
})
} catch (error) {
console.error('[linkGoogleAccount]', error)
return googleAuthError(res, 500, 'خطا در اتصال حساب گوگل')
}
}
const getGoogleLinkStatus = async (req, res) => {
try {
const user = req.user
return res.json({
linked: Boolean(user.google_id),
email: user.email || null,
auth_provider: user.auth_provider || 'mobile',
})
} catch (error) {
console.error('[getGoogleLinkStatus]', error)
return googleAuthError(res, 500, 'خطا در دریافت وضعیت حساب گوگل')
}
}
module.exports = {
googleAuth,
linkGoogleAccount,
getGoogleLinkStatus,
}

View File

@@ -1,134 +0,0 @@
const crypto = require('crypto')
const UserModel = require('../../../models/UserModel')
const PostModel = require('../../../models/PostModel')
const InstagramImportedPostModel = require('../../../models/InstagramImportedPostModel')
const { parseSignedRequest } = require('../../../utils/metaSignedRequest')
/**
* Meta calls these two endpoints server-to-server (no user session, no bearer
* token) — registered as the "Deauthorize callback URL" / "Data deletion request
* URL" in the Instagram Business Login settings. Auth is the signed_request's
* HMAC, not our normal middleware.
*/
function getAppSecret () {
return process.env.INSTAGRAM_APP_SECRET
}
async function findUserByInstagramScopedId (instagramId) {
if (!instagramId) return null
return UserModel.findOne({ instagram_id: String(instagramId) })
}
/**
* Same effect as the user-initiated "disconnect" button (instagramController.js):
* revoke the stored token, keep instagram_id so a future reconnect matches the
* same account. Shared so deauthorize/data-deletion/manual-disconnect can't drift.
*/
async function revokeInstagramConnection (userId) {
await UserModel.updateOne(
{ _id: userId },
{
$set: {
instagram_access_token_enc: null,
instagram_token_expires_at: null,
'instagram_sync_prefs.auto_update': false,
},
},
{ strict: false }
)
}
/**
* Deletes everything that was actually obtained *through* the Instagram API —
* imported posts + cached profile fields — without touching the rest of the
* Modstagram account (posts/content the user created directly in the app).
* instagram_id is $unset (not set to null): the field is a sparse unique index,
* and a sparse index still counts a literal `null` as present, so multiple
* deleted-and-relinked users would collide on a duplicate key.
*/
async function deleteInstagramSourcedData (userId) {
const importedPosts = await InstagramImportedPostModel.find({ user: userId }).select('post').lean()
const postIds = importedPosts.map((row) => row.post)
if (postIds.length) {
await PostModel.deleteMany({ _id: { $in: postIds } })
}
await InstagramImportedPostModel.deleteMany({ user: userId })
await UserModel.updateOne(
{ _id: userId },
{
$set: {
instagram_username: null,
instagram_account_type: null,
instagram_access_token_enc: null,
instagram_token_expires_at: null,
instagram_connected_at: null,
instagram_last_synced_at: null,
'instagram_sync_prefs.posts': false,
'instagram_sync_prefs.auto_update': false,
},
$unset: { instagram_id: '' },
},
{ strict: false }
)
}
const instagramDeauthorize = async (req, res) => {
const secret = getAppSecret()
if (!secret) {
console.error('[instagramDeauthorize] INSTAGRAM_APP_SECRET is not configured')
return res.status(503).end()
}
const data = parseSignedRequest(req.body?.signed_request, secret)
if (!data?.user_id) {
return res.status(400).json({ error: true, message: 'invalid signed_request' })
}
try {
const user = await findUserByInstagramScopedId(data.user_id)
if (user) {
await revokeInstagramConnection(user._id)
}
} catch (err) {
console.error('[instagramDeauthorize]', err)
}
return res.status(200).json({ success: true })
}
const instagramDataDeletion = async (req, res) => {
const secret = getAppSecret()
if (!secret) {
console.error('[instagramDataDeletion] INSTAGRAM_APP_SECRET is not configured')
return res.status(503).end()
}
const data = parseSignedRequest(req.body?.signed_request, secret)
if (!data?.user_id) {
return res.status(400).json({ error: true, message: 'invalid signed_request' })
}
const confirmationCode = crypto.randomBytes(16).toString('hex')
try {
const user = await findUserByInstagramScopedId(data.user_id)
if (user) {
await deleteInstagramSourcedData(user._id)
}
} catch (err) {
console.error('[instagramDataDeletion]', err)
}
const appSite = process.env.APP_SITE || 'https://modstagram.com'
return res.status(200).json({
url: `${appSite}/instagram-data-deletion?code=${confirmationCode}`,
confirmation_code: confirmationCode,
})
}
module.exports = {
instagramDeauthorize,
instagramDataDeletion,
}

View File

@@ -1,285 +0,0 @@
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { buildLoginResponseForUser } = require('../../../utils/loginResponse')
const {
expireBlockSuspensionIfNeeded,
getBlockedAccountResponse,
} = require('../../../utils/blockSuspension')
const {
findOrCreateInstagramUser,
} = require('../../../utils/instagramUserAuth')
const InstagramGraphService = require('../../../services/InstagramGraphService')
const { encryptSecret } = require('../../../utils/secretCipher')
const ALLOWED_ACCOUNT_TYPES = ['BUSINESS', 'MEDIA_CREATOR']
function instagramAuthError(res, status, message, extra = {}) {
return res.status(status).json({
error: true,
message,
...extra,
})
}
function isConfigured() {
return Boolean(
process.env.INSTAGRAM_CLIENT_ID &&
process.env.INSTAGRAM_APP_SECRET &&
process.env.INSTAGRAM_REDIRECT_URI
)
}
/**
* Exchange the OAuth `code` for a long-lived token + profile.
* Shared by both the login/register callback and the link-to-existing-user flow.
*/
async function resolveInstagramIdentity(code) {
const { accessToken: shortLivedToken } = await InstagramGraphService.exchangeCodeForToken(code)
if (!shortLivedToken) {
const err = new Error('NO_SHORT_LIVED_TOKEN')
err.code = 'NO_SHORT_LIVED_TOKEN'
throw err
}
const { accessToken: longLivedToken, expiresAt } =
await InstagramGraphService.exchangeForLongLivedToken(shortLivedToken)
const profile = await InstagramGraphService.getProfile(longLivedToken)
return { longLivedToken, expiresAt, profile }
}
const instagramAuth = async (req, res) => {
try {
if (!process.env.APP_SECRET) {
console.error('[instagramAuth] APP_SECRET is not configured')
return instagramAuthError(
res,
503,
'پیکربندی سرور کامل نیست (APP_SECRET)'
)
}
if (!isConfigured()) {
return instagramAuthError(res, 503, 'ورود با اینستاگرام در حال حاضر فعال نیست')
}
const { code } = req.body
if (!code) {
return instagramAuthError(res, 422, 'کد اینستاگرام ارسال نشده است')
}
let longLivedToken, expiresAt, profile
try {
;({ longLivedToken, expiresAt, profile } = await resolveInstagramIdentity(code))
} catch (err) {
console.error('[instagramAuth] token exchange failed:', err?.response?.data || err.message)
return instagramAuthError(
res,
401,
'اتصال به اینستاگرام ناموفق بود. دوباره تلاش کنید.',
{ en_message: err?.response?.data?.error_message || err.message }
)
}
if (!profile?.id) {
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
}
if (profile.accountType && !ALLOWED_ACCOUNT_TYPES.includes(profile.accountType)) {
return instagramAuthError(
res,
422,
'فقط حساب‌های Business یا Creator اینستاگرام قابل اتصال هستند. لطفاً حساب خود را در اینستاگرام به یکی از این دو نوع تغییر دهید.'
)
}
let user
try {
user = await findOrCreateInstagramUser({
instagramId: profile.id,
username: profile.username,
accountType: profile.accountType,
tokenEnc: encryptSecret(longLivedToken),
tokenExpiresAt: expiresAt,
profilePictureUrl: profile.profilePictureUrl,
})
} catch (err) {
if (err?.code === 'MISSING_INSTAGRAM_IDENTITY') {
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
}
throw err
}
if (!user?._id) {
return instagramAuthError(res, 500, 'ورود با اینستاگرام ناموفق بود')
}
user = await expireBlockSuspensionIfNeeded(user)
const blockResponse = getBlockedAccountResponse(user)
if (blockResponse) {
return res.status(403).json(blockResponse)
}
const token = TokenService.sign({ id: String(user._id) })
const loginPayload = await buildLoginResponseForUser(user, token)
return res.json({
...loginPayload,
id: loginPayload.id != null ? String(loginPayload.id) : String(user._id),
instagram_username: profile.username || null,
instagram_full_name: profile.name || null,
instagram_bio: profile.biography || null,
instagram_profile_picture_url: profile.profilePictureUrl || null,
})
} catch (error) {
console.error('[instagramAuth] failed:', error)
return instagramAuthError(
res,
500,
'خطا در ورود با اینستاگرام. لطفاً چند لحظه بعد دوباره تلاش کنید.',
{ en_message: error.message }
)
}
}
const linkInstagramAccount = async (req, res) => {
try {
const user = req.user
const { code } = req.body
if (!code) {
return instagramAuthError(res, 422, 'کد اینستاگرام ارسال نشده است')
}
if (user.instagram_id && user.instagram_access_token_enc) {
return instagramAuthError(res, 400, 'حساب اینستاگرام قبلاً به این کاربر متصل شده است')
}
if (!isConfigured()) {
return instagramAuthError(res, 503, 'اتصال اینستاگرام در حال حاضر فعال نیست')
}
let longLivedToken, expiresAt, profile
try {
;({ longLivedToken, expiresAt, profile } = await resolveInstagramIdentity(code))
} catch (err) {
console.error('[linkInstagramAccount] token exchange failed:', err?.response?.data || err.message)
return instagramAuthError(
res,
401,
'اتصال به اینستاگرام ناموفق بود. دوباره تلاش کنید.',
{ en_message: err?.response?.data?.error_message || err.message }
)
}
if (!profile?.id) {
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
}
if (profile.accountType && !ALLOWED_ACCOUNT_TYPES.includes(profile.accountType)) {
return instagramAuthError(
res,
422,
'فقط حساب‌های Business یا Creator اینستاگرام قابل اتصال هستند. لطفاً حساب خود را در اینستاگرام به یکی از این دو نوع تغییر دهید.'
)
}
const conflict = await UserModel.findOne({
instagram_id: String(profile.id),
_id: { $ne: user._id },
})
if (conflict) {
return instagramAuthError(
res,
409,
'این حساب اینستاگرام قبلاً به کاربر دیگری متصل شده است'
)
}
await UserModel.updateOne(
{ _id: user._id },
{
$set: {
instagram_id: String(profile.id),
instagram_username: profile.username || null,
instagram_account_type: profile.accountType || null,
instagram_access_token_enc: encryptSecret(longLivedToken),
instagram_token_expires_at: expiresAt,
instagram_connected_at: new Date(),
},
},
{ strict: false }
)
return res.json({
message: 'حساب اینستاگرام با موفقیت متصل شد',
instagram_username: profile.username || null,
linked: true,
})
} catch (error) {
console.error('[linkInstagramAccount]', error)
return instagramAuthError(res, 500, 'خطا در اتصال حساب اینستاگرام')
}
}
const getInstagramLinkStatus = async (req, res) => {
try {
const user = req.user
return res.json({
linked: Boolean(user.instagram_id),
connected: Boolean(user.instagram_id && user.instagram_access_token_enc),
instagram_username: user.instagram_username || null,
instagram_account_type: user.instagram_account_type || null,
connected_at: user.instagram_connected_at || null,
auth_provider: user.auth_provider || 'mobile',
instagram_sync_prefs: {
posts: Boolean(user.instagram_sync_prefs?.posts),
auto_update: Boolean(user.instagram_sync_prefs?.auto_update),
},
})
} catch (error) {
console.error('[getInstagramLinkStatus]', error)
return instagramAuthError(res, 500, 'خطا در دریافت وضعیت حساب اینستاگرام')
}
}
const disconnectInstagram = async (req, res) => {
try {
const user = req.user
if (!user.instagram_id) {
return instagramAuthError(res, 400, 'حساب اینستاگرامی متصل نیست')
}
// instagram_id عمداً حذف نمی‌شود تا محتوای قبلاً ایمپورت‌شده و امکان اتصال مجدد حفظ شود
await UserModel.updateOne(
{ _id: user._id },
{
$set: {
instagram_access_token_enc: null,
instagram_token_expires_at: null,
'instagram_sync_prefs.auto_update': false,
},
},
{ strict: false }
)
return res.json({
message: 'اتصال اینستاگرام قطع شد',
linked: true,
connected: false,
})
} catch (error) {
console.error('[disconnectInstagram]', error)
return instagramAuthError(res, 500, 'خطا در قطع اتصال اینستاگرام')
}
}
module.exports = {
instagramAuth,
linkInstagramAccount,
getInstagramLinkStatus,
disconnectInstagram,
}

View File

@@ -1,141 +0,0 @@
const UserModel = require('../../../models/UserModel')
const InstagramImportedPostModel = require('../../../models/InstagramImportedPostModel')
const InstagramGraphService = require('../../../services/InstagramGraphService')
const { decryptSecret } = require('../../../utils/secretCipher')
const { importInstagramMedia, SUPPORTED_MEDIA_TYPES } = require('../../../utils/instagramMediaImport')
function importError (res, status, message) {
return res.status(status).json({ error: true, message })
}
async function requireConnectedInstagram (user) {
if (!user.instagram_id || !user.instagram_access_token_enc) {
const err = new Error('NOT_CONNECTED')
err.code = 'NOT_CONNECTED'
throw err
}
return decryptSecret(user.instagram_access_token_enc)
}
/**
* List the user's Instagram media, flagged with whether each item was already
* imported — feeds the manual picker UI.
*/
const getInstagramMedia = async (req, res) => {
try {
const user = req.user
let accessToken
try {
accessToken = await requireConnectedInstagram(user)
} catch (err) {
if (err.code === 'NOT_CONNECTED') {
return importError(res, 400, 'ابتدا حساب اینستاگرام خود را متصل کنید')
}
throw err
}
const { items } = await InstagramGraphService.getMedia(accessToken)
const importedIds = new Set(
(
await InstagramImportedPostModel.find({ user: user._id }).select('instagram_media_id').lean()
).map((row) => row.instagram_media_id)
)
const media = items.map((item) => ({
id: item.id,
caption: item.caption || null,
media_type: item.media_type,
media_url: item.media_url,
permalink: item.permalink,
timestamp: item.timestamp,
supported: SUPPORTED_MEDIA_TYPES.includes(item.media_type),
already_imported: importedIds.has(String(item.id)),
}))
return res.json({ media })
} catch (error) {
console.error('[getInstagramMedia]', error)
return importError(res, 500, 'خطا در دریافت پست‌های اینستاگرام')
}
}
/**
* mode: 'manual' — import exactly the given mediaIds.
* mode: 'automatic' — turn on ongoing sync and import everything not yet imported.
*/
const importInstagramPosts = async (req, res) => {
try {
const user = req.user
const { mode, mediaIds } = req.body
if (mode !== 'manual' && mode !== 'automatic') {
return importError(res, 422, 'نوع انتقال نامعتبر است')
}
let accessToken
try {
accessToken = await requireConnectedInstagram(user)
} catch (err) {
if (err.code === 'NOT_CONNECTED') {
return importError(res, 400, 'ابتدا حساب اینستاگرام خود را متصل کنید')
}
throw err
}
const { items } = await InstagramGraphService.getMedia(accessToken)
let targetItems
if (mode === 'manual') {
const requestedIds = new Set((Array.isArray(mediaIds) ? mediaIds : []).map(String))
if (requestedIds.size === 0) {
return importError(res, 422, 'هیچ پستی انتخاب نشده است')
}
targetItems = items.filter((item) => requestedIds.has(String(item.id)))
} else {
targetItems = items
}
let importedCount = 0
for (const item of targetItems) {
const post = await importInstagramMedia(user, item)
if (post) importedCount += 1
}
const $set = { 'instagram_sync_prefs.posts': true }
if (mode === 'automatic') {
$set['instagram_sync_prefs.auto_update'] = true
$set.instagram_last_synced_at = new Date()
}
await UserModel.updateOne({ _id: user._id }, { $set }, { strict: false })
return res.json({
message: 'انتقال پست‌های اینستاگرام انجام شد',
imported_count: importedCount,
auto_update: mode === 'automatic',
})
} catch (error) {
console.error('[importInstagramPosts]', error)
return importError(res, 500, 'خطا در انتقال پست‌های اینستاگرام')
}
}
const disableAutoImport = async (req, res) => {
try {
const user = req.user
await UserModel.updateOne(
{ _id: user._id },
{ $set: { 'instagram_sync_prefs.auto_update': false } },
{ strict: false }
)
return res.json({ message: 'همگام‌سازی خودکار غیرفعال شد', auto_update: false })
} catch (error) {
console.error('[disableAutoImport]', error)
return importError(res, 500, 'خطا در غیرفعال‌سازی همگام‌سازی خودکار')
}
}
module.exports = {
getInstagramMedia,
importInstagramPosts,
disableAutoImport,
}

View File

@@ -1,148 +0,0 @@
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { buildAuthResponse } = require('../../../utils/authSteps')
const { verifyTemp2faToken } = require('../../../utils/loginResponse')
const {
generateSecret,
verifyTotp,
buildOtpAuthUrl,
} = require('../../../utils/totp')
const {
readTotpState,
setPendingSecret,
activateTotp,
deactivateTotp,
} = require('../../../utils/totpStore')
const ISSUER = 'Modstagram'
async function getTotpStatus (req, res) {
const user = req.user
const state = await readTotpState(user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
return res.json({
enabled: state.totp_enabled,
linked: Boolean(state.totp_secret),
})
}
async function setupTotp (req, res) {
const user = req.user
if (!user?._id) {
return res.status(401).json({ message: 'کاربر احراز هویت نشده است' })
}
const state = await readTotpState(user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (state.totp_enabled) {
return res.status(400).json({ message: 'ورود دو مرحله‌ای قبلاً فعال شده است' })
}
const secret = generateSecret()
await setPendingSecret(user._id, secret)
const accountName =
user.user_name || user.email || user.mobile || String(user._id)
return res.json({
secret,
otpauth_url: buildOtpAuthUrl({
secret,
accountName,
issuer: ISSUER,
}),
})
}
async function enableTotp (req, res) {
const { code } = req.body
if (!code) {
return res.status(422).json({ message: 'کد تایید الزامی است' })
}
const state = await readTotpState(req.user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (!state.totp_pending_secret) {
return res.status(400).json({ message: 'ابتدا راه‌اندازی ورود دو مرحله‌ای را انجام دهید' })
}
if (!verifyTotp(state.totp_pending_secret, code)) {
return res.status(422).json({ message: 'کد تایید نامعتبر است' })
}
await activateTotp(req.user._id, state.totp_pending_secret)
return res.json({ message: 'ورود دو مرحله‌ای فعال شد', enabled: true })
}
async function disableTotp (req, res) {
const { code } = req.body
const state = await readTotpState(req.user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (!state.totp_enabled || !state.totp_secret) {
return res.status(400).json({ message: 'ورود دو مرحله‌ای فعال نیست' })
}
if (!code || !verifyTotp(state.totp_secret, code)) {
return res.status(422).json({ message: 'کد تایید نامعتبر است' })
}
await deactivateTotp(req.user._id)
return res.json({ message: 'ورود دو مرحله‌ای غیرفعال شد', enabled: false })
}
async function verifyLoginTotp (req, res) {
const { temp_token: tempToken, code } = req.body
if (!tempToken || !code) {
return res.status(422).json({ message: 'اطلاعات ورود ناقص است' })
}
let decoded
try {
decoded = verifyTemp2faToken(tempToken)
} catch {
return res.status(401).json({ message: 'نشست ورود منقضی شده است. دوباره وارد شوید' })
}
const state = await readTotpState(decoded.id)
if (!state?.totp_enabled || !state.totp_secret) {
return res.status(401).json({ message: 'ورود دو مرحله‌ای برای این حساب فعال نیست' })
}
if (!verifyTotp(state.totp_secret, code)) {
return res.status(422).json({ message: 'کد دو مرحله‌ای نامعتبر است' })
}
const user = await UserModel.findById(decoded.id)
if (!user) {
return res.status(401).json({ message: 'کاربر یافت نشد' })
}
const token = TokenService.sign({ id: user._id })
return res.json(buildAuthResponse(user, token))
}
module.exports = {
getTotpStatus,
setupTotp,
enableTotp,
disableTotp,
verifyLoginTotp,
}

View File

@@ -1,91 +0,0 @@
const AutoDirectRuleModel = require('../../../models/AutoDirectRuleModel')
function ruleError (res, status, message) {
return res.status(status).json({ error: true, message })
}
async function findOwnedRule (ruleId, userId) {
const rule = await AutoDirectRuleModel.findById(ruleId)
if (!rule) return { error: 'NOT_FOUND' }
if (String(rule.user_id) !== String(userId)) return { error: 'FORBIDDEN' }
return { rule }
}
const getMyAutoDirectRules = async (req, res, next) => {
try {
const rules = await AutoDirectRuleModel.find({ user_id: req.user._id }).sort({ createdAt: -1 })
return res.json({ rules })
} catch (error) {
next(error)
}
}
const createAutoDirectRule = async (req, res, next) => {
try {
const { trigger_text, direct_message, reply_caption } = req.body
if (!trigger_text?.trim() || !direct_message?.trim() || !reply_caption?.trim()) {
return ruleError(res, 422, 'همه‌ی فیلدها الزامی است')
}
const rule = await AutoDirectRuleModel.create({
user_id: req.user._id,
trigger_text: trigger_text.trim(),
direct_message: direct_message.trim(),
reply_caption: reply_caption.trim()
})
return res.status(201).json({ message: 'دایرکت اتوماتیک ثبت شد', rule })
} catch (error) {
next(error)
}
}
const updateAutoDirectRule = async (req, res, next) => {
try {
const { rule, error } = await findOwnedRule(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return ruleError(res, 404, 'دایرکت اتوماتیک یافت نشد')
if (error === 'FORBIDDEN') return ruleError(res, 403, 'شما مالک این قانون نیستید')
const { trigger_text, direct_message, reply_caption, is_active } = req.body
if (trigger_text !== undefined) {
if (!trigger_text?.trim()) return ruleError(res, 422, 'متن کامنت الزامی است')
rule.trigger_text = trigger_text.trim()
}
if (direct_message !== undefined) {
if (!direct_message?.trim()) return ruleError(res, 422, 'متن دایرکت الزامی است')
rule.direct_message = direct_message.trim()
}
if (reply_caption !== undefined) {
if (!reply_caption?.trim()) return ruleError(res, 422, 'متن پاسخ کامنت الزامی است')
rule.reply_caption = reply_caption.trim()
}
if (is_active !== undefined) {
rule.is_active = Boolean(is_active)
}
await rule.save()
return res.json({ message: 'دایرکت اتوماتیک به‌روزرسانی شد', rule })
} catch (error) {
next(error)
}
}
const deleteAutoDirectRule = async (req, res, next) => {
try {
const { rule, error } = await findOwnedRule(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return ruleError(res, 404, 'دایرکت اتوماتیک یافت نشد')
if (error === 'FORBIDDEN') return ruleError(res, 403, 'شما مالک این قانون نیستید')
await rule.deleteOne()
return res.json({ message: 'دایرکت اتوماتیک حذف شد' })
} catch (error) {
next(error)
}
}
module.exports = {
getMyAutoDirectRules,
createAutoDirectRule,
updateAutoDirectRule,
deleteAutoDirectRule
}

View File

@@ -1,194 +0,0 @@
const BookingConfigModel = require('../../../models/BookingConfigModel')
const UserModel = require('../../../models/UserModel')
const WEEK_DAYS = ['sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri']
function bookingError (res, status, message) {
return res.status(status).json({ error: true, message })
}
async function findOwnedConfig (configId, userId) {
const config = await BookingConfigModel.findById(configId)
if (!config) return { error: 'NOT_FOUND' }
if (String(config.owner) !== String(userId)) return { error: 'FORBIDDEN' }
return { config }
}
const createDraftBookingConfig = async (req, res, next) => {
try {
const { name } = req.body
if (!name) {
return bookingError(res, 422, 'نام رزرو الزامی است')
}
const config = await BookingConfigModel.create({
owner: req.user._id,
name,
status: 'draft'
})
return res.json({ message: 'رزرو ایجاد شد', configId: config._id, config })
} catch (error) {
next(error)
}
}
const getMyBookingConfigs = async (req, res, next) => {
try {
const configs = await BookingConfigModel.find({ owner: req.user._id }).sort({ createdAt: -1 })
return res.json({ configs })
} catch (error) {
next(error)
}
}
const getBookingConfigById = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
return res.json({ config })
} catch (error) {
next(error)
}
}
const updateBookingConfigServices = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
const { serviceIds } = req.body
if (!Array.isArray(serviceIds) || serviceIds.length === 0) {
return bookingError(res, 422, 'حداقل یک خدمت انتخاب کنید')
}
const user = await UserModel.findById(req.user._id).select('services')
const selected = (user.services || []).filter((service) =>
serviceIds.includes(String(service._id))
)
if (selected.length === 0) {
return bookingError(res, 422, 'خدمات انتخاب‌شده یافت نشد')
}
config.services = selected.map((service) => ({
serviceId: String(service._id),
title: service.title,
price: Number(service.discountPrice || service.originalPrice || 0),
image: service.image || null
}))
await config.save()
return res.json({ message: 'خدمات ذخیره شد', config })
} catch (error) {
next(error)
}
}
const updateBookingConfigSchedule = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
const { weeklySchedule, holidayDates, depositRequired, weeklyRepeatCount } = req.body
if (weeklySchedule !== undefined) {
if (!Array.isArray(weeklySchedule)) {
return bookingError(res, 422, 'ساعات کاری نامعتبر است')
}
for (const entry of weeklySchedule) {
if (!WEEK_DAYS.includes(entry.day)) {
return bookingError(res, 422, 'روز هفته نامعتبر است')
}
}
config.weeklySchedule = weeklySchedule
}
if (holidayDates !== undefined) {
if (!Array.isArray(holidayDates)) {
return bookingError(res, 422, 'روزهای تعطیل نامعتبر است')
}
config.holidayDates = holidayDates.map((d) => new Date(d))
}
if (depositRequired !== undefined) {
config.depositRequired = Boolean(depositRequired)
}
if (weeklyRepeatCount !== undefined) {
if (weeklyRepeatCount === null || weeklyRepeatCount === '') {
config.weeklyRepeatCount = null
} else {
const count = Number(weeklyRepeatCount)
if (!Number.isInteger(count) || count < 1) {
return bookingError(res, 422, 'تعداد تکرار هفته نامعتبر است')
}
config.weeklyRepeatCount = count
}
}
await config.save()
return res.json({ message: 'ساعات کاری ذخیره شد', config })
} catch (error) {
next(error)
}
}
const updateBookingConfigStatus = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
const { status } = req.body
if (!['active', 'inactive'].includes(status)) {
return bookingError(res, 422, 'وضعیت نامعتبر است')
}
config.status = status
// فعال‌سازی/تمدید دستی: شمارش تکرار هفتگی از همین لحظه دوباره شروع میشه
if (status === 'active' && config.weeklyRepeatCount) {
config.repeatStartedAt = new Date()
}
await config.save()
return res.json({ message: 'وضعیت رزرو به‌روزرسانی شد', config })
} catch (error) {
next(error)
}
}
const submitBookingConfig = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
if (!config.name || !config.services?.length || !config.weeklySchedule?.length) {
return bookingError(res, 422, 'اطلاعات رزرو ناقص است')
}
config.status = 'active'
if (config.weeklyRepeatCount) {
config.repeatStartedAt = new Date()
}
await config.save()
return res.json({ message: 'رزرو با موفقیت ثبت شد', config })
} catch (error) {
next(error)
}
}
module.exports = {
createDraftBookingConfig,
getMyBookingConfigs,
getBookingConfigById,
updateBookingConfigServices,
updateBookingConfigSchedule,
updateBookingConfigStatus,
submitBookingConfig
}

View File

@@ -1,299 +0,0 @@
const BookingConfigModel = require('../../../models/BookingConfigModel')
const BookingModel = require('../../../models/BookingModel')
const { notifyUser } = require('../../../utils/notifyUser')
const JS_DAY_TO_KEY = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
function bookingError (res, status, message) {
return res.status(status).json({ error: true, message })
}
function toDateOnlyString (date) {
return new Date(date).toISOString().slice(0, 10)
}
/** null if the "تکرار هفته" window never ends (or isn't set); otherwise the Date it stops repeating. */
function getRepeatExpiresAt (config) {
if (!config.weeklyRepeatCount || !config.repeatStartedAt) return null
return new Date(
new Date(config.repeatStartedAt).getTime() + config.weeklyRepeatCount * 7 * 24 * 60 * 60 * 1000
)
}
/** Public: the buyer-facing view of a user's active booking config, for their profile page. */
const getPublicBookingConfig = async (req, res, next) => {
try {
const config = await BookingConfigModel.findOne({
owner: req.params.userId,
status: 'active'
})
if (!config) return res.json({ config: null })
return res.json({ config, repeatExpiresAt: getRepeatExpiresAt(config) })
} catch (error) {
next(error)
}
}
/** Free time slots for one date: this weekday's ranges, minus holidays, minus already-booked ranges. */
const getAvailability = async (req, res, next) => {
try {
const { configId, date } = req.query
if (!configId || !date) {
return bookingError(res, 422, 'اطلاعات درخواست ناقص است')
}
const config = await BookingConfigModel.findOne({ _id: configId, status: 'active' })
if (!config) return bookingError(res, 404, 'رزرو یافت نشد')
const targetDate = new Date(date)
const isHoliday = (config.holidayDates || []).some(
(d) => toDateOnlyString(d) === toDateOnlyString(targetDate)
)
if (isHoliday) return res.json({ slots: [] })
const repeatExpiresAt = getRepeatExpiresAt(config)
if (repeatExpiresAt && targetDate >= repeatExpiresAt) return res.json({ slots: [] })
const dayKey = JS_DAY_TO_KEY[targetDate.getDay()]
const dayEntry = (config.weeklySchedule || []).find((entry) => entry.day === dayKey)
if (!dayEntry || !dayEntry.ranges?.length) return res.json({ slots: [] })
const startOfDay = new Date(targetDate)
startOfDay.setHours(0, 0, 0, 0)
const endOfDay = new Date(targetDate)
endOfDay.setHours(23, 59, 59, 999)
const existingBookings = await BookingModel.find({
bookingConfig: configId,
date: { $gte: startOfDay, $lte: endOfDay },
status: { $ne: 'cancelled' }
})
const bookedRanges = new Set(
existingBookings.map((b) => `${b.startTime}-${b.endTime}`)
)
const slots = dayEntry.ranges.filter(
(range) => !bookedRanges.has(`${range.start_time}-${range.end_time}`)
)
return res.json({ slots })
} catch (error) {
next(error)
}
}
/** Creates a booking. Confirmed immediately if no deposit required, otherwise pending_payment. */
const createBooking = async (req, res, next) => {
try {
const { configId, serviceId, date, startTime, endTime } = req.body
if (!configId || !serviceId || !date || !startTime || !endTime) {
return bookingError(res, 422, 'اطلاعات رزرو ناقص است')
}
const config = await BookingConfigModel.findOne({ _id: configId, status: 'active' })
if (!config) return bookingError(res, 404, 'رزرو یافت نشد')
const service = (config.services || []).find((s) => s.serviceId === serviceId)
if (!service) return bookingError(res, 422, 'خدمت یافت نشد')
const targetDate = new Date(date)
const isHoliday = (config.holidayDates || []).some(
(d) => toDateOnlyString(d) === toDateOnlyString(targetDate)
)
if (isHoliday) return bookingError(res, 422, 'این روز تعطیل است')
const repeatExpiresAt = getRepeatExpiresAt(config)
if (repeatExpiresAt && targetDate >= repeatExpiresAt) {
return bookingError(res, 422, 'مهلت تکرار هفتگی این رزرو به پایان رسیده است')
}
const dayKey = JS_DAY_TO_KEY[targetDate.getDay()]
const dayEntry = (config.weeklySchedule || []).find((entry) => entry.day === dayKey)
const validRange = dayEntry?.ranges?.some(
(r) => r.start_time === startTime && r.end_time === endTime
)
if (!validRange) return bookingError(res, 422, 'این بازه زمانی معتبر نیست')
const startOfDay = new Date(targetDate)
startOfDay.setHours(0, 0, 0, 0)
const endOfDay = new Date(targetDate)
endOfDay.setHours(23, 59, 59, 999)
const alreadyBooked = await BookingModel.findOne({
bookingConfig: configId,
date: { $gte: startOfDay, $lte: endOfDay },
startTime,
endTime,
status: { $ne: 'cancelled' }
})
if (alreadyBooked) return bookingError(res, 422, 'این بازه زمانی قبلاً رزرو شده است')
const depositRequired = Boolean(config.depositRequired)
const depositAmount = depositRequired
? Math.round(service.price * 0.3)
: 0
const booking = await BookingModel.create({
bookingConfig: config._id,
provider: config.owner,
buyer: req.user._id,
serviceId,
serviceTitle: service.title,
servicePrice: service.price,
date: targetDate,
startTime,
endTime,
depositRequired,
depositAmount,
status: depositRequired ? 'pending_payment' : 'confirmed'
})
if (!depositRequired) {
await notifyUser(req.app.get('io'), config.owner, {
type: 'booking',
title: 'رزرو جدید',
description: `خدمت «${service.title}» در تاریخ ${toDateOnlyString(targetDate)} ساعت ${startTime} رزرو شد`,
project_post_id: booking._id
})
}
return res.json({ message: 'رزرو ثبت شد', booking })
} catch (error) {
next(error)
}
}
/** Buyer's own bookings, or (role=provider) bookings received against the caller's own configs. */
const getMyBookings = async (req, res, next) => {
try {
const role = req.query.role === 'provider' ? 'provider' : 'buyer'
const filter = role === 'provider'
? { provider: req.user._id }
: { buyer: req.user._id }
const bookings = await BookingModel.find(filter)
.populate('buyer', 'first_name last_name user_name profile_image user_level is_verified verify_badge')
.populate('provider', 'first_name last_name user_name profile_image user_level is_verified verify_badge')
.sort({ date: -1, createdAt: -1 })
return res.json({ bookings })
} catch (error) {
next(error)
}
}
const BOOKING_ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null }
/** Revenue-over-time + top services + status breakdown for the provider's bookings — backs the "Booking" analytics tab. */
const getBookingAnalytics = async (req, res, next) => {
try {
const userId = req.user._id
const range = BOOKING_ANALYTICS_RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '30d'
const days = BOOKING_ANALYTICS_RANGE_DAYS[range]
const since = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : new Date(0)
const confirmedMatch = { provider: userId, status: 'confirmed', date: { $gte: since } }
const [series, totalsAgg, topServicesAgg, statusCounts] = await Promise.all([
BookingModel.aggregate([
{ $match: confirmedMatch },
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$date' } },
revenue: { $sum: '$servicePrice' },
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
]),
BookingModel.aggregate([
{ $match: confirmedMatch },
{ $group: { _id: null, totalRevenue: { $sum: '$servicePrice' }, totalCount: { $sum: 1 } } }
]),
BookingModel.aggregate([
{ $match: confirmedMatch },
{ $group: { _id: '$serviceTitle', revenue: { $sum: '$servicePrice' }, count: { $sum: 1 } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 }
]),
BookingModel.aggregate([
{ $match: { provider: userId, date: { $gte: since } } },
{ $group: { _id: '$status', count: { $sum: 1 } } }
])
])
const topItems = topServicesAgg
.filter((row) => row._id)
.map((row) => ({ id: row._id, title: row._id, image: null, metric: row.revenue }))
const statusBreakdown = { pending_payment: 0, confirmed: 0, cancelled: 0 }
for (const row of statusCounts) {
if (row._id in statusBreakdown) statusBreakdown[row._id] = row.count
}
return res.json({
range,
series,
totals: {
totalRevenue: totalsAgg[0]?.totalRevenue || 0,
totalCount: totalsAgg[0]?.totalCount || 0
},
topItems,
rating: null,
statusBreakdown
})
} catch (error) {
next(error)
}
}
/** Provider cancels a reservation made against one of their booking configs. */
const updateBookingStatus = async (req, res, next) => {
try {
const { id } = req.params
const { action } = req.body
if (action !== 'cancel') {
return bookingError(res, 400, 'نوع عملیات نامعتبر است')
}
const booking = await BookingModel.findById(id)
if (!booking) return bookingError(res, 404, 'رزرو یافت نشد')
if (booking.provider.toString() !== req.user._id.toString()) {
return bookingError(res, 403, 'اجازه‌ی تغییر این رزرو را ندارید')
}
if (booking.status === 'cancelled') {
return bookingError(res, 400, 'این رزرو قبلاً لغو شده است')
}
booking.status = 'cancelled'
await booking.save()
await notifyUser(req.app.get('io'), booking.buyer, {
type: 'booking',
title: 'رزرو لغو شد',
description: `رزرو خدمت «${booking.serviceTitle}» در تاریخ ${toDateOnlyString(booking.date)} ساعت ${booking.startTime} توسط ارائه‌دهنده لغو شد`,
project_post_id: booking._id
})
return res.json({
message: 'رزرو لغو شد',
bookingId: String(booking._id),
status: booking.status
})
} catch (error) {
next(error)
}
}
module.exports = {
getPublicBookingConfig,
getAvailability,
createBooking,
getMyBookings,
updateBookingStatus,
getBookingAnalytics
}

View File

@@ -1,146 +0,0 @@
const { default: axios } = require('axios')
const BookingModel = require('../../../models/BookingModel')
const PaymentModel = require('../../../models/PaymentModel')
const { notifyUser } = require('../../../utils/notifyUser')
const { BOOKING_COMMISSION_RATE, creditBookingShare } = require('../../../utils/bookingCommission')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const apiKey =
process.env.ZARINPAL_MERCHANT_ID ||
process.env.MERCHENT_CODE ||
'c7c41e8a-918f-4741-bcd5-58f3bc51db73'
function toDateOnlyString (date) {
return new Date(date).toISOString().slice(0, 10)
}
// درگاه قدیمیِ REST زرین‌پال (/pg/rest/WebGate) منسوخ شده — مبلغ به API نسخه‌ی v4
// باید به ریال ارسال بشه، نه تومان (برخلاف API قدیمی)
function toRial (tomanAmount) {
return Number(tomanAmount) * 10
}
/** Initiates the 30% deposit payment for a pending booking. */
const initiateBookingPayment = async (req, res) => {
try {
const { bookingId } = req.body
const booking = await BookingModel.findById(bookingId)
if (!booking) {
return res.status(404).json({ error: true, message: 'رزرو یافت نشد' })
}
if (String(booking.buyer) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این رزرو نیستید' })
}
if (booking.status !== 'pending_payment') {
return res.status(400).json({ error: true, message: 'این رزرو در انتظار پرداخت نیست' })
}
const callbackBase =
process.env.APP_SITE_CallBack ||
process.env.APP_URL ||
''
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: apiKey,
amount: toRial(booking.depositAmount),
description: `بیعانه رزرو خدمت ${booking.serviceTitle}`,
callback_url: `${callbackBase}/bookings/payment-callback/web?bookingId=${booking._id}`
},
{ headers: { 'Content-Type': 'application/json' }, timeout: 20000 }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
booking.paymentAuthority = result.authority
await booking.save()
return res.status(200).json({
authority: result.authority,
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
console.error('initiateBookingPayment ZarinPal error:', response.data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
console.error('initiateBookingPayment error:', error?.response?.data || error?.message || error)
return res.status(500).json({ error: 'Internal server error' })
}
}
const handleBookingPaymentCallback = async (req, res) => {
const { Authority, Status, bookingId } = req.query
const failRedirect = `${process.env.APP_SITE}/settings/booking/payment/failed?bookingId=${bookingId || ''}`
const successRedirect = `${process.env.APP_SITE}/settings/booking/payment/success?bookingId=${bookingId || ''}`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
const booking = await BookingModel.findById(bookingId)
if (!booking) return res.redirect(failRedirect)
if (booking.status === 'confirmed') {
return res.redirect(successRedirect)
}
const verificationResponse = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{ merchant_id: apiKey, authority: Authority, amount: toRial(booking.depositAmount) },
{ headers: { 'Content-Type': 'application/json' } }
)
const verificationData = verificationResponse.data?.data
if (verificationData?.code !== 100 && verificationData?.code !== 101) {
await PaymentModel.create({
amount: booking.depositAmount,
status: 'failed',
authority: Authority,
user_id: booking.buyer,
type: 'booking_deposit'
})
return res.redirect(failRedirect)
}
const payment = await PaymentModel.create({
amount: booking.depositAmount,
status: 'successful',
authority: Authority,
user_id: booking.buyer,
type: 'booking_deposit'
})
booking.depositPaid = true
booking.status = 'confirmed'
await booking.save()
const netAmount = Math.round(booking.depositAmount * (1 - BOOKING_COMMISSION_RATE))
await creditBookingShare(
req.app.get('io'),
booking.provider,
netAmount,
payment._id,
`بیعانه رزرو خدمت «${booking.serviceTitle}» (پس از کسر ۵٪ کارمزد)`
)
await notifyUser(req.app.get('io'), booking.provider, {
type: 'booking',
title: 'رزرو جدید',
description: `خدمت «${booking.serviceTitle}» در تاریخ ${toDateOnlyString(booking.date)} ساعت ${booking.startTime} رزرو شد`,
project_post_id: booking._id
})
return res.redirect(successRedirect)
} catch (error) {
console.error('handleBookingPaymentCallback error:', error?.response?.data || error?.message || error)
return res.redirect(failRedirect)
}
}
module.exports = {
initiateBookingPayment,
handleBookingPaymentCallback
}

View File

@@ -1,745 +0,0 @@
const mongoose = require('mongoose')
const path = require('path')
const fs = require('fs-extra')
const crypto = require('crypto')
const moment = require('moment-jalaali')
const ChatRoomModel = require('../../../models/ChatRoomModel')
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
const UserModel = require('../../../models/UserModel')
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
({ optimizeVideoFaststart } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[chatRoomController] postVideoProcessing unavailable:', error.message)
}
const {
slugifyTitle,
userCanSeeRoom,
resolveRoomOnlineCount,
resolveIdleTimeoutMs
} = require('./chatRoomHelpers')
const { opaquePreview } = require('../../../utils/e2ee')
const { needsUsernameAndPassword } = require('../../../utils/authSteps')
const DEFAULT_ROOM = {
title: 'چت روم مدستاگرام',
slug: 'modstagram-community',
description: 'گفتگوی گروهی کاربران مدستاگرام'
}
function formatTimeOnly(date) {
return moment(date).locale('fa').format('HH:mm:ss')
}
async function ensureDefaultRoom() {
let room = await ChatRoomModel.findOne({ slug: DEFAULT_ROOM.slug })
if (!room) {
room = await ChatRoomModel.create({
...DEFAULT_ROOM,
isSystem: true,
lastActivityAt: new Date()
})
} else if (!room.isSystem) {
room.isSystem = true
if (!room.lastActivityAt) room.lastActivityAt = new Date()
await room.save()
}
return room
}
async function enrichRoomMessage(message) {
const doc = message._doc ? { ...message._doc } : { ...message }
const sender = await UserModel.findById(doc.senderId)
.select('first_name last_name user_name profile_image is_verified verify_badge')
.lean()
let replyTo = null
if (doc.replyToId) {
const parent = await ChatRoomMessageModel.findById(doc.replyToId).lean()
if (parent) {
const parentUser = await UserModel.findById(parent.senderId)
.select('first_name last_name user_name')
.lean()
replyTo = {
_id: parent._id,
content: opaquePreview(parent.content, 200),
senderName: parentUser
? `${parentUser.first_name || ''} ${parentUser.last_name || ''}`.trim() ||
parentUser.user_name
: 'کاربر'
}
}
}
return {
...doc,
_id: String(doc._id),
roomId: String(doc.roomId),
senderId: String(doc.senderId),
createdAt: formatTimeOnly(doc.createdAt),
createdAtIso: doc.createdAt ? new Date(doc.createdAt).toISOString() : undefined,
reactions: (doc.reactions || []).map((r) => ({
userId: String(r.userId),
emoji: r.emoji,
})),
sender: sender
? {
_id: String(sender._id),
first_name: sender.first_name,
last_name: sender.last_name,
user_name: sender.user_name,
profile_image: sender.profile_image,
is_verified: sender.is_verified,
verify_badge: sender.verify_badge
}
: undefined,
replyTo
}
}
function emitGroupMessage(io, roomId, message, memberIds = []) {
if (!io) return
const payload = { ...message, roomId: String(message.roomId ?? roomId) }
io.to(`group:${roomId}`).emit('newGroupMessage', payload)
const members = [...new Set((memberIds || []).map((id) => String(id)).filter(Boolean))]
for (const memberId of members) {
io.to(`user:${memberId}`).emit('newGroupMessage', payload)
}
}
async function serializeRoom(room, io, userId) {
const memberCount = room.members?.length || 0
const isMember = room.members?.some((m) => String(m) === String(userId))
const onlineCount = await resolveRoomOnlineCount(io, room)
return {
_id: String(room._id),
title: room.title,
description: room.description,
image: room.image,
province: room.province || null,
city: room.city || null,
expertise: room.expertise || null,
visibility: room.visibility || 'public',
memberCount,
isMember,
onlineCount,
isSystem: Boolean(room.isSystem),
createdBy: room.createdBy ? String(room.createdBy) : null,
lastActivityAt: room.lastActivityAt,
idleTimeoutMs: room.idleTimeoutMs || null
}
}
const listRooms = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
await ensureDefaultRoom()
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const rooms = await ChatRoomModel.find({}).sort({ lastActivityAt: -1 }).lean()
const enriched = await Promise.all(
rooms
.filter((room) => userCanSeeRoom(user, room))
.map(async (room) => {
const lastMessage = await ChatRoomMessageModel.findOne({ roomId: room._id })
.sort({ createdAt: -1 })
.lean()
return {
...(await serializeRoom(room, io, userId)),
lastMessage: lastMessage
? {
content: opaquePreview(lastMessage.content, 80),
createdAt: formatTimeOnly(lastMessage.createdAt)
}
: null
}
})
)
res.json({ rooms: enriched })
} catch (error) {
next(error)
}
}
const createRoom = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { title, province_id, city_id, expertise, visibility, member_ids, idle_timeout_days } = req.body
if (!title || !String(title).trim()) {
return res.status(422).json({ error: 'نام اتاق الزامی است' })
}
const roomVisibility =
String(visibility || 'public').trim() === 'private' ? 'private' : 'public'
let invitedMemberIds = []
if (member_ids) {
try {
const parsed =
typeof member_ids === 'string' ? JSON.parse(member_ids) : member_ids
if (Array.isArray(parsed)) {
invitedMemberIds = parsed
.map((id) => String(id).trim())
.filter((id) => mongoose.Types.ObjectId.isValid(id))
}
} catch {
invitedMemberIds = []
}
}
if (roomVisibility === 'private' && invitedMemberIds.length === 0) {
return res.status(422).json({
error: 'برای اتاق خصوصی، افزودن حداقل یک کاربر الزامی است',
})
}
let province = null
let city = null
if (province_id) {
province = await ProvinceModel.findOne({ id: Number(province_id) }).lean()
if (!province) {
return res.status(404).json({ error: 'استان یافت نشد' })
}
}
if (city_id) {
city = await CityModel.findOne({ id: Number(city_id) }).lean()
if (!city) {
return res.status(404).json({ error: 'شهر یافت نشد' })
}
if (!province && city.province_id) {
province = await ProvinceModel.findOne({ id: city.province_id }).lean()
}
}
let imagePath = null
const imageFile = req.file
if (imageFile) {
const uploadDir = path.join(__dirname, '../../../storage/chat-rooms')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
// اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (imageFile.originalname) گرفته نمی‌شه — فقط پسوندش
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(imageFile.originalname || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(imageFile.path, filePath)
await optimizeImageFile(filePath, { maxDimension: 800 })
imagePath = `/chat-rooms/${uniqueFileName}`
}
const expertiseValue =
expertise && String(expertise).trim() ? String(expertise).trim() : undefined
const members = [userId, ...invitedMemberIds.filter((id) => id !== userId)]
const room = await ChatRoomModel.create({
title: String(title).trim(),
slug: `${slugifyTitle(title)}-${Date.now()}`,
image: imagePath,
province:
roomVisibility === 'public' && province
? { id: province.id, name: province.name }
: undefined,
city:
roomVisibility === 'public' && city
? { id: city.id, name: city.name }
: undefined,
expertise: roomVisibility === 'public' ? expertiseValue : undefined,
visibility: roomVisibility,
members,
createdBy: userId,
lastActivityAt: new Date(),
idleTimeoutMs: resolveIdleTimeoutMs(
idle_timeout_days ? Number(idle_timeout_days) * 24 * 60 * 60 * 1000 : undefined
),
isSystem: false
})
res.status(201).json({
room: await serializeRoom(room.toObject(), io, userId)
})
} catch (error) {
next(error)
}
}
const joinRoom = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
if (!room.members?.some((m) => String(m) === userId)) {
room.members = room.members || []
room.members.push(userId)
await room.save()
}
res.json({
room: {
_id: String(room._id),
title: room.title,
description: room.description,
image: room.image,
isMember: true
}
})
} catch (error) {
next(error)
}
}
const getRoomMessages = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
const page = parseInt(req.query.page, 10) || 1
const limit = parseInt(req.query.limit, 10) || 50
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
const isMember = room.members?.some((m) => String(m) === userId)
if (!isMember) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
const filter = { roomId: room._id }
const total = await ChatRoomMessageModel.countDocuments(filter)
const totalPages = Math.ceil(total / limit) || 1
const skip = (page - 1) * limit
const messages = await ChatRoomMessageModel.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
const enriched = await Promise.all(messages.map((m) => enrichRoomMessage(m)))
res.json({ messages: enriched.reverse(), totalPages })
} catch (error) {
next(error)
}
}
const sendRoomMessage = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { roomId } = req.params
const { content, replyToId, fileType } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const textContent = content != null ? String(content).trim() : ''
const uploaded = req.file
const resolvedFileType = fileType || undefined
if (!textContent && !uploaded) {
return res.status(422).json({ error: 'content or file is required' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
const isMember = room.members?.some((m) => String(m) === userId)
if (!isMember) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
// عینِ گیتِ /api/v1/chat (index.js) — بدونِ رمزِ عبور، کلیدِ E2EE نمی‌تونه
// بک‌آپ/بازیابی بشه، پس ارسالِ پیام (حتی توی روم) باید قبلش کاربرِ گوگل/
// اینستاگرام‌ـی که هنوز رمز نساخته رو مسدود کنه
if (needsUsernameAndPassword(req.user)) {
return res.status(403).json({
error: true,
type: 'needs_username_password',
message: 'برای ارسال پیام باید نام‌کاربری و رمز عبور خود را تکمیل کنید'
})
}
let fileUrl = null
if (uploaded) {
// multer (roomMessageUpload توی routes) از قبل فایل رو مستقیماً روی پوشه‌ی
// نهایی نوشته — دیگه نیازی به fs.move جداگانه نیست
if (resolvedFileType === 'image' || (uploaded.mimetype || '').startsWith('image/')) {
await optimizeImageFile(uploaded.path)
} else if (resolvedFileType === 'video' || (uploaded.mimetype || '').startsWith('video/')) {
optimizeVideoFaststart(uploaded.path).catch((err) => {
console.error('[chatRoomController] video optimize background error:', err.message)
})
}
fileUrl = `/chat-rooms/messages/${uploaded.filename}`
}
room.lastActivityAt = new Date()
await room.save()
const message = await ChatRoomMessageModel.create({
roomId: room._id,
senderId: userId,
content: textContent,
file: fileUrl || undefined,
fileType: fileUrl ? resolvedFileType : undefined,
replyToId: replyToId || undefined
})
const formatted = await enrichRoomMessage(message)
emitGroupMessage(io, String(room._id), formatted, room.members)
res.status(201).json({ message: formatted })
} catch (error) {
next(error)
}
}
const getRoomDetail = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { roomId } = req.params
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId).lean()
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
const members = await UserModel.find({ _id: { $in: room.members || [] } })
.select('first_name last_name user_name profile_image is_verified verify_badge')
.lean()
res.json({
room: {
...(await serializeRoom(room, io, userId)),
members: members.map((m) => ({
_id: String(m._id),
first_name: m.first_name,
last_name: m.last_name,
user_name: m.user_name,
profile_image: m.profile_image,
is_verified: m.is_verified,
verify_badge: m.verify_badge
}))
}
})
} catch (error) {
next(error)
}
}
const addRoomMembers = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
const { member_ids: memberIdsRaw } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (room.visibility !== 'private') {
return res.status(422).json({ error: 'فقط اتاق‌های خصوصی قابل دعوت هستند' })
}
if (!room.createdBy || String(room.createdBy) !== userId) {
return res.status(403).json({ error: 'فقط سازنده اتاق می‌تواند کاربر اضافه کند' })
}
let memberIds = []
if (Array.isArray(memberIdsRaw)) {
memberIds = memberIdsRaw
} else if (typeof memberIdsRaw === 'string') {
try {
memberIds = JSON.parse(memberIdsRaw)
} catch {
memberIds = []
}
}
const validIds = [...new Set(
(memberIds || [])
.map((id) => String(id).trim())
.filter((id) => mongoose.Types.ObjectId.isValid(id) && id !== userId)
)]
if (!validIds.length) {
return res.status(422).json({ error: 'کاربر معتبری برای افزودن انتخاب نشده است' })
}
const existing = new Set((room.members || []).map((id) => String(id)))
for (const id of validIds) {
if (!existing.has(id)) {
room.members.push(id)
existing.add(id)
}
}
await room.save()
const io = req.app.get('io')
res.json({ room: await serializeRoom(room.toObject(), io, userId) })
} catch (error) {
next(error)
}
}
const reactToRoomMessage = async (req, res, next) => {
try {
const userId = String(req.user._id)
const io = req.app.get('io')
const { roomId } = req.params
const { messageId, emoji } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId) || !mongoose.Types.ObjectId.isValid(messageId)) {
return res.status(400).json({ error: 'Invalid id' })
}
if (!emoji) {
return res.status(400).json({ error: 'emoji is required' })
}
const user = await UserModel.findById(userId)
.select('province city expertise')
.lean()
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!userCanSeeRoom(user, room)) {
return res.status(403).json({ error: 'Access denied' })
}
if (!room.members?.some((m) => String(m) === userId)) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
const msg = await ChatRoomMessageModel.findOne({ _id: messageId, roomId: room._id })
if (!msg) return res.status(404).json({ error: 'Message not found' })
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 enrichRoomMessage(msg)
io.to(`group:${roomId}`).emit('groupMessageReaction', {
roomId: String(roomId),
messageId: String(msg._id),
reactions: formatted.reactions,
})
res.json({ message: formatted })
} catch (error) {
next(error)
}
}
const getRoomE2eeKeys = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!room.members?.some((m) => String(m) === userId)) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
const memberIds = (room.members || []).map((m) => String(m))
const users = await UserModel.find({ _id: { $in: memberIds } })
.select('e2ee_public_key')
.lean()
const wraps = Array.isArray(room.e2ee_wrapped_keys) ? room.e2ee_wrapped_keys : []
const myWrap = wraps.find((w) => String(w.userId) === userId)
const wrapUserIds = new Set(wraps.map((w) => String(w.userId)))
const missingWrapUserIds = memberIds.filter((id) => !wrapUserIds.has(id))
res.json({
e2ee_enabled: Boolean(room.e2ee_enabled),
myWrappedKey: myWrap?.wrappedKey || null,
memberPublicKeys: users.map((u) => ({
userId: String(u._id),
publicKey: u.e2ee_public_key || null
})),
missingWrapUserIds
})
} catch (error) {
next(error)
}
}
const putRoomE2eeKeys = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { roomId } = req.params
const { e2ee_enabled, wrapped_keys } = req.body
if (!mongoose.Types.ObjectId.isValid(roomId)) {
return res.status(400).json({ error: 'Invalid room id' })
}
const room = await ChatRoomModel.findById(roomId)
if (!room) return res.status(404).json({ error: 'Room not found' })
if (!room.members?.some((m) => String(m) === userId)) {
return res.status(403).json({
error: 'ابتدا به اتاق بپیوندید',
code: 'NOT_A_MEMBER'
})
}
// فعال‌سازیِ اولیه‌ی E2EE روم باید atomic باشه — بدون این گارد، اگه دو عضو تقریباً
// هم‌زمان (هرکدوم با یه کلیدِ تصادفیِ متفاوت) اولین‌بار بخوان روم رو فعال کنن، هر دو
// findById/save رو با موفقیت رد می‌کردن و ذخیره‌ی دومی wrap های اولی رو override
// می‌کرد — نتیجه یه ناهم‌خوانیِ دائمیِ کلید بین اعضا بود؛ دقیقاً همون باگی که باعث
// شد E2EE روم‌ها موقتاً به‌طور کامل غیرفعال بشه به‌جای رفعِ ریشه‌ای
if (e2ee_enabled === true && !room.e2ee_enabled) {
const claimed = await ChatRoomModel.findOneAndUpdate(
{ _id: roomId, e2ee_enabled: { $ne: true } },
{ $set: { e2ee_enabled: true } },
{ new: true }
)
if (!claimed) {
// یکی دیگه هم‌زمان برنده شد — این درخواست نباید wrapped_keys خودش (که با
// کلیدِ بازنده ساخته شده) رو ثبت کنه؛ کلاینت باید myWrappedKey تازه (متعلق به
// کلیدِ برنده) رو بخونه و کلیدِ محلیِ خودش رو دور بریزه
const fresh = await ChatRoomModel.findById(roomId)
const freshWraps = Array.isArray(fresh?.e2ee_wrapped_keys) ? fresh.e2ee_wrapped_keys : []
const myFreshWrap = freshWraps.find((w) => String(w.userId) === userId)
return res.status(409).json({
error: 'room e2ee already enabled by another member',
code: 'E2EE_ALREADY_ENABLED',
e2ee_enabled: true,
myWrappedKey: myFreshWrap?.wrappedKey || null
})
}
room.e2ee_enabled = true
} else if (typeof e2ee_enabled === 'boolean') {
room.e2ee_enabled = e2ee_enabled
}
if (Array.isArray(wrapped_keys)) {
const memberSet = new Set((room.members || []).map((m) => String(m)))
const existing = Array.isArray(room.e2ee_wrapped_keys)
? [...room.e2ee_wrapped_keys]
: []
const byUser = new Map(existing.map((w) => [String(w.userId), w.wrappedKey]))
for (const entry of wrapped_keys) {
const uid = String(entry?.userId || '').trim()
const wrappedKey = String(entry?.wrappedKey || '').trim()
if (!uid || !wrappedKey || !memberSet.has(uid)) continue
if (wrappedKey.length > 8192) continue
byUser.set(uid, wrappedKey)
}
room.e2ee_wrapped_keys = [...byUser.entries()].map(([uid, key]) => ({
userId: uid,
wrappedKey: key
}))
}
await room.save()
res.json({
ok: true,
e2ee_enabled: Boolean(room.e2ee_enabled),
wrappedCount: room.e2ee_wrapped_keys?.length || 0
})
} catch (error) {
next(error)
}
}
module.exports = {
listRooms,
createRoom,
joinRoom,
getRoomMessages,
sendRoomMessage,
getRoomDetail,
addRoomMembers,
reactToRoomMessage,
getRoomE2eeKeys,
putRoomE2eeKeys,
enrichRoomMessage,
emitGroupMessage
}

View File

@@ -1,156 +0,0 @@
const mongoose = require('mongoose')
const path = require('path')
const fs = require('fs-extra')
const ChatRoomModel = require('../../../models/ChatRoomModel')
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
const UserModel = require('../../../models/UserModel')
const DEFAULT_IDLE_MS = 30 * 24 * 60 * 60 * 1000 // 1 month
const MIN_IDLE_MS = 60 * 60 * 1000 // 1 hour
const MAX_IDLE_MS = 365 * 24 * 60 * 60 * 1000 // 1 year
const STORAGE_ROOT = path.join(__dirname, '../../../storage')
const ONLINE_WINDOW_MS = 60 * 1000
function resolveIdleTimeoutMs (value) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IDLE_MS
return Math.min(MAX_IDLE_MS, Math.max(MIN_IDLE_MS, parsed))
}
function slugifyTitle(title) {
return String(title)
.trim()
.replace(/\s+/g, '-')
.replace(/[^\u0600-\u06FFa-zA-Z0-9-]/g, '')
.slice(0, 48) || 'room'
}
function userCanSeeRoom(user, room) {
if (!room) return false
if (room.isSystem) return true
const userId = user?._id != null ? String(user._id) : ''
if (room.visibility === 'private') {
if (!userId) return false
if (room.createdBy && String(room.createdBy) === userId) return true
return Boolean(room.members?.some((memberId) => String(memberId) === userId))
}
if (userId && room.createdBy && String(room.createdBy) === userId) {
return true
}
const hasProvince = room.province?.id != null
const hasCity = room.city?.id != null
const hasExpertise = Boolean(room.expertise && String(room.expertise).trim())
if (!hasProvince && !hasCity && !hasExpertise) return true
if (hasExpertise) {
const userExp = String(user?.expertise || '').trim()
if (userExp !== String(room.expertise).trim()) return false
}
if (hasCity) {
const userCityId = user?.city?.id
return userCityId != null && String(userCityId) === String(room.city.id)
}
if (hasProvince) {
const userProvId = user?.province?.id
return userProvId != null && String(userProvId) === String(room.province.id)
}
return true
}
async function countRoomOnlineMembers(memberIds = []) {
const ids = [...new Set((memberIds || []).map((id) => String(id)).filter(Boolean))]
if (!ids.length) return 0
const objectIds = ids
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.map((id) => new mongoose.Types.ObjectId(id))
if (!objectIds.length) return 0
const cutoff = new Date(Date.now() - ONLINE_WINDOW_MS)
return UserModel.countDocuments({
_id: { $in: objectIds },
ghost_mode: { $ne: true },
last_online: { $gte: cutoff },
})
}
function getRoomOnlineCount(io, roomId) {
if (!io || !roomId) return 0
const key = `group:${String(roomId)}`
const adapterRoom = io.sockets?.adapter?.rooms?.get(key)
return adapterRoom ? adapterRoom.size : 0
}
async function resolveRoomOnlineCount(io, room) {
const memberOnline = await countRoomOnlineMembers(room?.members)
const socketOnline = getRoomOnlineCount(io, room?._id)
return Math.max(memberOnline, socketOnline)
}
async function removeRoomImage(imagePath) {
if (!imagePath) return
const relative = String(imagePath).replace(/^\//, '')
const filePath = path.join(STORAGE_ROOT, relative)
if (fs.existsSync(filePath)) {
await fs.remove(filePath).catch(() => {})
}
}
async function deleteRoomFully(io, room) {
if (!room?._id) return
const messages = await ChatRoomMessageModel.find({ roomId: room._id }).lean()
for (const msg of messages) {
if (msg.file) await removeRoomImage(msg.file)
}
await ChatRoomMessageModel.deleteMany({ roomId: room._id })
await removeRoomImage(room.image)
await ChatRoomModel.deleteOne({ _id: room._id })
if (io) {
io.to(`group:${room._id}`).emit('chatRoomDeleted', { roomId: String(room._id) })
}
}
async function purgeInactiveChatRooms(io) {
const now = new Date()
const stale = await ChatRoomModel.find({
isSystem: { $ne: true },
$expr: {
$lt: [
{ $add: ['$lastActivityAt', { $ifNull: ['$idleTimeoutMs', DEFAULT_IDLE_MS] }] },
now
]
}
})
for (const room of stale) {
await deleteRoomFully(io, room)
}
return stale.length
}
module.exports = {
DEFAULT_IDLE_MS,
MIN_IDLE_MS,
MAX_IDLE_MS,
resolveIdleTimeoutMs,
slugifyTitle,
userCanSeeRoom,
countRoomOnlineMembers,
getRoomOnlineCount,
resolveRoomOnlineCount,
deleteRoomFully,
purgeInactiveChatRooms,
removeRoomImage
}

View File

@@ -1,16 +1,16 @@
const { CityModel } = require('../../models/StateCity')
const getCities = async (req, res, next) => {
try {
const { id } = req.params
const cities = await CityModel.find({ province_id: id })
res.status(200).json({
cities
})
} catch (err) {
res.status(500).send('Internal Server Error')
}
}
module.exports = {
getCities
}
const { CityModel } = require('../../models/StateCity')
const getCities = async (req, res, next) => {
try {
const { id } = req.params
const cities = await CityModel.find({ province_id: id })
res.status(200).json({
cities
})
} catch (err) {
res.status(500).send('Internal Server Error')
}
}
module.exports = {
getCities
}

View File

@@ -1,16 +0,0 @@
const { CountryModel } = require('../../models/StateCity')
const getCountries = async (req, res, next) => {
try {
const countries = await CountryModel.find({}).sort({ name_fa: 1 })
res.status(200).json({
countries
})
} catch (err) {
res.status(500).send('Internal Server Error')
}
}
module.exports = {
getCountries
}

View File

@@ -1,13 +1,13 @@
/* eslint-disable camelcase */
const ExpertiseModel = require('../../../models/ExpertiseModel')
const getExpertise = async (req, res, next) => {
try {
const expertises = await ExpertiseModel.find().select('expertise sub_expertise')
res.json({ expertises })
} catch (error) {
next(error)
}
}
module.exports = {
getExpertise
}
/* eslint-disable camelcase */
const ExpertiseModel = require('../../../models/ExpertiseModel')
const getExpertise = async (req, res, next) => {
try {
const expertises = await ExpertiseModel.find().select('expertise sub_expertise')
res.json({ expertises })
} catch (error) {
next(error)
}
}
module.exports = {
getExpertise
}

View File

@@ -1,53 +1,65 @@
const jwt = require('jsonwebtoken')
const PaymentModel = require('../../../models/PaymentModel')
const ProjectModel = require('../../../models/ProjectModel')
const getFinancial = async (req, res) => {
try {
// Extract user ID from JWT token
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const options = {
page: req.query.page || 1, // صفحه پیش‌فرض ۱
limit: req.query.limit || 10, // حداکثر تعداد موارد برای هر صفحه
sort: { createdAt: -1 }
}
// همه‌ی پرداخت‌های کاربر (پروژه، تبلیغات، هدیه، فروشگاه، رزرو، دوره و ...)، نه فقط
// پرداخت‌های مرتبط با پروژه — project_title فقط وقتی project_id واقعاً ست شده باشه پر می‌شه
const financial = await PaymentModel.paginate(
{ user_id: userId }, // فیلتر
options // گزینه‌های پیجینیشن
)
if (financial.docs.length) {
const projectIds = financial.docs
.map((payment) => payment.project_id)
.filter(Boolean)
const projects = projectIds.length
? await ProjectModel.find({ _id: { $in: projectIds } }).select('title').lean()
: []
const titleById = new Map(projects.map((p) => [String(p._id), p.title]))
financial.docs = financial.docs.map((payment) => {
const doc = typeof payment.toObject === 'function' ? payment.toObject() : payment
return {
...doc,
project_title: doc.project_id ? titleById.get(String(doc.project_id)) : undefined
}
})
}
// Send the payments back to the client
res.status(200).json({
financial: financial,
totalPages: financial.totalPages, // ارسال تعداد کل صفحات
totalItems: financial.totalDocs // ارسال تعداد کل آیتم‌ها
})
} catch (error) {
console.error('Error occurred:', error)
res.status(500).json({ error: 'Internal server error' })
}
}
module.exports = { getFinancial }
const jwt = require('jsonwebtoken')
const PaymentModel = require('../../../models/PaymentModel')
const ProjectModel = require('../../../models/ProjectModel')
const moment = require('moment-jalaali')
const getFinancial = async (req, res) => {
try {
// Extract user ID from JWT token
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const options = {
page: req.query.page || 1, // صفحه پیش‌فرض ۱
limit: req.query.limit || 10, // حداکثر تعداد موارد برای هر صفحه
sort: { createdAt: -1 }
}
// Find all payments associated with the user
const financial = await PaymentModel.paginate(
{ user_id: userId }, // فیلتر
options // گزینه‌های پیجینیشن
)
// Prepare the data to be sent back
const userPayments = await Promise.all(financial.docs.map(async (payment) => {
// Find the project associated with the payment
const project = await ProjectModel.findById(payment.project_id)
if (!project) {
return null
}
// استفاده از کتابخانه moment-jalaali برای تبدیل تاریخ و زمان به شمسی
const shamsiDate = moment(payment.createdAt).format('jYYYY-jMM-jDD')
const shamsiTime = moment(payment.createdAt).format('HH:mm')
// Extract the required information
const userPayment = {
_id: payment._id,
project_title: project.title,
project_id: project._id,
payment_date: shamsiDate, // Format date as YYYY-MM-DD
payment_time: shamsiTime,
iban: project.creator_id ? project.creator_id.iban : null, // Check if creator_id exists before accessing iban
amount: payment.amount,
payment_status: payment.status
}
return userPayment
}))
// Filter out null values
const validUserPayments = userPayments.filter(payment => payment !== null)
// Send the payments back to the client
res.status(200).json({
financial: financial,
totalPages: financial.totalPages, // ارسال تعداد کل صفحات
totalItems: financial.totalDocs // ارسال تعداد کل آیتم‌ها
})
} catch (error) {
console.error('Error occurred:', error)
res.status(500).json({ error: 'Internal server error' })
}
}
module.exports = { getFinancial }

View File

@@ -1,14 +0,0 @@
const { checkClientRegion } = require('../../../utils/vpnCheck')
/** GET /geo/check — طبق خواسته‌ی کاربر: هر بار اپ (وب/اندروید) باز می‌شه صدا زده می‌شه
* تا اگه کاربر با آی‌پیِ غیرایران یا VPN/پراکسی وارد شده، بهش هشدار نشون داده بشه */
const checkGeo = async (req, res, next) => {
try {
const result = await checkClientRegion(req.ip)
return res.status(200).json(result)
} catch (error) {
next(error)
}
}
module.exports = { checkGeo }

View File

@@ -1,451 +0,0 @@
/* eslint-disable camelcase */
const axios = require('axios')
const jMoment = require('moment-jalaali')
const GiftModel = require('../../../models/GiftModel')
const UserModel = require('../../../models/UserModel')
const PaymentModel = require('../../../models/PaymentModel')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
const GIFT_MIN_AMOUNT = 2000
const GIFT_MAX_AMOUNT = 100000000
const GIFT_COMMISSION_RATE = 0.1
function toRial (tomanAmount) {
return Number(tomanAmount) * 10
}
async function validateGiftTarget (senderId, receiverId, amount) {
if (!receiverId) {
return { error: { status: 400, message: 'کاربر گیرنده مشخص نشده است' } }
}
if (String(receiverId) === String(senderId)) {
return { error: { status: 400, message: 'نمی‌توانید به خودتان هدیه بدهید' } }
}
const giftAmount = Number(amount)
if (!Number.isFinite(giftAmount) || giftAmount < GIFT_MIN_AMOUNT || giftAmount > GIFT_MAX_AMOUNT) {
return { error: { status: 422, message: 'مبلغ هدیه نامعتبر است' } }
}
const receiver = await UserModel.findById(receiverId)
if (!receiver) {
return { error: { status: 404, message: 'کاربر گیرنده یافت نشد' } }
}
if (receiver.gift_enabled === false) {
return { error: { status: 403, message: 'این کاربر دریافت هدیه را غیرفعال کرده است' } }
}
return { receiver, giftAmount }
}
const GIFT_USER_SELECT = 'profile_image user_level first_name last_name user_name is_verified verify_badge'
async function creditGiftReceiver (receiverId, giftId, giftAmount) {
await WalletTransactionModel.create({
user: receiverId,
bucket: 'gift',
type: 'credit',
amount: Math.round(giftAmount * (1 - GIFT_COMMISSION_RATE)),
referenceType: 'payment',
referenceId: giftId,
description: 'هدیه دریافتی از کاربر دیگر (پس از کسر ۱۰٪ کارمزد)'
})
}
const initiateGiftPayment = async (req, res, next) => {
try {
const senderId = req.user._id
const { receiverId, amount, message, platform, useWallet } = req.body
if (!receiverId) {
return res.status(400).json({ error: true, message: 'کاربر گیرنده مشخص نشده است' })
}
if (String(receiverId) === String(senderId)) {
return res.status(400).json({ error: true, message: 'نمی‌توانید به خودتان هدیه بدهید' })
}
const giftAmount = Number(amount)
if (!Number.isFinite(giftAmount) || giftAmount < GIFT_MIN_AMOUNT || giftAmount > GIFT_MAX_AMOUNT) {
return res.status(422).json({ error: true, message: 'مبلغ هدیه نامعتبر است' })
}
const receiver = await UserModel.findById(receiverId)
if (!receiver) {
return res.status(404).json({ error: true, message: 'کاربر گیرنده یافت نشد' })
}
if (receiver.gift_enabled === false) {
return res.status(403).json({ error: true, message: 'این کاربر دریافت هدیه را غیرفعال کرده است' })
}
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(senderId, giftAmount, useWallet)
if (fullyCovered) {
await debitWalletForPurchase(senderId, walletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
const gift = await GiftModel.create({
sender: senderId,
receiver: receiverId,
amount: giftAmount,
message: message || null,
paymentMethod: 'wallet'
})
await creditGiftReceiver(receiverId, gift._id, giftAmount)
return res.json({
paidFromWallet: true,
gift: { _id: gift._id, amount: gift.amount, message: gift.message }
})
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const callbackUrl = `${callbackBase}/gifts/payment-callback?senderId=${senderId}&receiverId=${receiverId}&amount=${giftAmount}&walletAmount=${walletAmount}&message=${encodeURIComponent(message || '')}${platformSuffix}`
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(gatewayAmount),
description: `هدیه ویژه به ${receiver.first_name || receiver.user_name}`,
callback_url: callbackUrl
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
return res.json({
authority: result.authority,
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
console.error('ZarinPal gift initiate error:', response.data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
next(error)
}
}
const handleGiftPaymentCallback = async (req, res) => {
const { Authority, Status, senderId, receiverId, amount, walletAmount, message, platform } = req.query
const isAndroid = platform === 'android'
const failRedirect = isAndroid
? `modstagram://FailedPay?type=gift${receiverId ? `&receiverId=${receiverId}` : ''}`
: `${process.env.APP_SITE}/gift/payment/failed${receiverId ? `?receiverId=${receiverId}` : ''}`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
const giftAmount = Number(amount)
const usedWalletAmount = Number(walletAmount) || 0
const gatewayAmount = giftAmount - usedWalletAmount
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
const data = verify.data?.data
if (data?.code !== 100 && data?.code !== 101) {
return res.redirect(failRedirect)
}
// پرداخت درگاه قطعاً موفق شد — همین حالا سهمِ کیف پول هم کسر می‌شه (نگاه کن به
// utils/walletSplit.js)
await debitWalletForPurchase(senderId, usedWalletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
const payment = await PaymentModel.create({
amount: gatewayAmount,
status: 'successful',
authority: Authority,
user_id: senderId,
type: 'gift'
})
const gift = await GiftModel.create({
sender: senderId,
receiver: receiverId,
amount: giftAmount,
message: message ? decodeURIComponent(message) : null,
transaction_id: payment._id,
paymentMethod: usedWalletAmount > 0 ? 'mixed' : 'online'
})
await creditGiftReceiver(receiverId, gift._id, giftAmount)
const receiverUser = await UserModel.findById(receiverId).select('user_name').lean()
if (isAndroid) {
const usernameParam = receiverUser?.user_name ? `&username=${encodeURIComponent(receiverUser.user_name)}` : ''
return res.redirect(`modstagram://SuccessPay?type=gift&giftId=${gift._id}&receiverId=${receiverId}${usernameParam}`)
}
const usernameParam = receiverUser?.user_name ? `&receiverUsername=${encodeURIComponent(receiverUser.user_name)}` : ''
return res.redirect(`${process.env.APP_SITE}/gift/payment/success?giftId=${gift._id}&receiverId=${receiverId}${usernameParam}`)
} catch (error) {
console.error('handleGiftPaymentCallback error:', error?.response?.data || error.message)
return res.redirect(failRedirect)
}
}
const getMyGifts = async (req, res, next) => {
try {
const userId = req.user._id
const { page = 1, limit = 10, filter } = req.query
const query = filter === 'received' ? { receiver: userId } : { sender: userId }
const options = {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
sort: { createdAt: -1 },
populate: [
{ path: 'sender', select: GIFT_USER_SELECT },
{ path: 'receiver', select: GIFT_USER_SELECT }
]
}
const result = await GiftModel.paginate(query, options)
const gifts = result.docs.map((gift) => ({
...gift._doc,
createdAt: jMoment(gift.createdAt).format('jYYYY-jMM-jDD HH:mm')
}))
return res.json({
gifts,
totalPages: result.totalPages,
totalItems: result.totalDocs
})
} catch (error) {
next(error)
}
}
const replyToGift = async (req, res, next) => {
try {
const userId = req.user._id
const { giftId, reply } = req.body
if (!giftId || !reply || !reply.trim()) {
return res.status(400).json({ error: true, message: 'متن پاسخ الزامی است' })
}
const gift = await GiftModel.findById(giftId)
if (!gift || String(gift.receiver) !== String(userId)) {
return res.status(404).json({ error: true, message: 'هدیه یافت نشد' })
}
gift.reply = reply.trim()
gift.repliedAt = new Date()
await gift.save()
return res.json({ message: 'پاسخ شما ثبت شد', gift })
} catch (error) {
next(error)
}
}
const initiateChatGift = async (req, res, next) => {
try {
const senderId = req.user._id
const { receiverId, amount, message, return_path: returnPath, platform, useWallet } = req.body
const { error, receiver, giftAmount } = await validateGiftTarget(senderId, receiverId, amount)
if (error) return res.status(error.status).json({ error: true, message: error.message })
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(senderId, giftAmount, useWallet)
if (fullyCovered) {
await debitWalletForPurchase(senderId, walletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
const gift = await GiftModel.create({
sender: senderId,
receiver: receiverId,
amount: giftAmount,
message: message || null,
paymentMethod: 'wallet',
viaChatMessage: true,
opened: false,
openedAt: null
})
return res.json({
paidFromWallet: true,
gift: { _id: gift._id, amount: gift.amount, message: gift.message }
})
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const safeReturnPath = typeof returnPath === 'string' && returnPath.startsWith('/') ? returnPath : '/'
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const callbackUrl = `${callbackBase}/gifts/chat/payment-callback?senderId=${senderId}&receiverId=${receiverId}&amount=${giftAmount}&walletAmount=${walletAmount}&message=${encodeURIComponent(message || '')}&returnPath=${encodeURIComponent(safeReturnPath)}${platformSuffix}`
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(gatewayAmount),
description: `هدیه در چت به ${receiver.first_name || receiver.user_name}`,
callback_url: callbackUrl
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
return res.json({
paidFromWallet: false,
authority: result.authority,
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
console.error('ZarinPal chat gift initiate error:', response.data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
next(error)
}
}
const handleChatGiftPaymentCallback = async (req, res) => {
const { Authority, Status, senderId, receiverId, amount, walletAmount, message, returnPath, platform } = req.query
const isAndroid = platform === 'android'
const safeReturnPath = typeof returnPath === 'string' && returnPath.startsWith('/') ? decodeURIComponent(returnPath) : '/'
const failRedirect = isAndroid
? `modstagram://FailedPay?type=gift${receiverId ? `&receiverId=${receiverId}` : ''}`
: `${process.env.APP_SITE}${safeReturnPath}?giftPaymentFailed=1`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
const giftAmount = Number(amount)
const usedWalletAmount = Number(walletAmount) || 0
const gatewayAmount = giftAmount - usedWalletAmount
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
const data = verify.data?.data
if (data?.code !== 100 && data?.code !== 101) {
return res.redirect(failRedirect)
}
await debitWalletForPurchase(senderId, usedWalletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
const payment = await PaymentModel.create({
amount: gatewayAmount,
status: 'successful',
authority: Authority,
user_id: senderId,
type: 'gift'
})
const decodedMessage = message ? decodeURIComponent(message) : null
const gift = await GiftModel.create({
sender: senderId,
receiver: receiverId,
amount: giftAmount,
message: decodedMessage,
transaction_id: payment._id,
paymentMethod: usedWalletAmount > 0 ? 'mixed' : 'online',
viaChatMessage: true,
opened: false,
openedAt: null
})
if (isAndroid) {
const receiverUser = await UserModel.findById(receiverId).select('user_name').lean()
const usernameParam = receiverUser?.user_name ? `&username=${encodeURIComponent(receiverUser.user_name)}` : ''
return res.redirect(
`modstagram://SuccessPay?type=gift&giftId=${gift._id}&receiverId=${receiverId}${usernameParam}`
)
}
return res.redirect(
`${process.env.APP_SITE}${safeReturnPath}?giftId=${gift._id}&giftAmount=${giftAmount}&giftMessage=${encodeURIComponent(decodedMessage || '')}`
)
} catch (error) {
console.error('handleChatGiftPaymentCallback error:', error?.response?.data || error.message)
return res.redirect(failRedirect)
}
}
const openGift = async (req, res, next) => {
try {
const userId = req.user._id
const { id } = req.params
const gift = await GiftModel.findById(id)
if (!gift || String(gift.receiver) !== String(userId)) {
return res.status(404).json({ error: true, message: 'هدیه یافت نشد' })
}
if (gift.opened) {
return res.json({ gift, alreadyOpened: true })
}
await WalletTransactionModel.create({
user: gift.receiver,
bucket: 'gift',
type: 'credit',
amount: Math.round(gift.amount * (1 - GIFT_COMMISSION_RATE)),
referenceType: 'payment',
referenceId: gift._id,
description: 'هدیه دریافتی از کاربر دیگر (پس از کسر ۱۰٪ کارمزد)'
})
gift.opened = true
gift.openedAt = new Date()
await gift.save()
return res.json({ gift, alreadyOpened: false })
} catch (error) {
next(error)
}
}
module.exports = {
initiateGiftPayment,
handleGiftPaymentCallback,
getMyGifts,
replyToGift,
initiateChatGift,
handleChatGiftPaymentCallback,
openGift
}

View File

@@ -1,142 +1,77 @@
/* eslint-disable camelcase */
const bcrypt = require('bcryptjs')
const { check, validationResult } = require('express-validator')
const jwt = require('jsonwebtoken')
const UserModel = require('../../../models/UserModel')
const { isOtpExpired } = require('../../../utils/otpExpiry')
const { isGoogleUser } = require('../../../utils/authSteps')
const changePasswordValidationRules = () => {
return [
check('new_password')
.notEmpty().withMessage('رمز عبور نمی‌تواند خالی باشد')
.isLength({ min: 8, max: 25 }).withMessage('رمز عبور نباید کوتاه تر از 8 کاراکتر باشد')
.matches(/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/u).withMessage('رمز عبور باید شامل حروف و اعداد باشد')
]
}
async function resolveUserFromRequest (req) {
const authHeader = req.header('Authorization')
if (authHeader) {
const token = authHeader.split(' ')[1]
if (token) {
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
return UserModel.findById(decodedToken.id)
}
}
// بدون Authorization (مسیر فراموشی رمز)، otp همیشه اجباریه — قبلاً اگه otp اصلاً
// توی body نمی‌اومد، این بلاک کامل رد می‌شد و کاربر بدون هیچ احرازی برگردونده
// می‌شد (باگ امنیتیِ حیاتی: تغییر رمز هرکسی فقط با دونستنِ mobile/email)
const { mobile, email, otp } = req.body
if (mobile) {
if (!otp) {
return { error: 'کد تایید الزامی است' }
}
const user = await UserModel.findOne({ mobile })
if (!user) return null
if (!user.otp || isOtpExpired(user)) {
return { error: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید' }
}
if (String(user.otp) !== String(otp)) {
return { error: 'کد تایید اشتباه است' }
}
user.otp = null
user.otpSentAt = null
await user.save()
return user
}
if (email) {
if (!otp) {
return { error: 'کد تایید الزامی است' }
}
const user = await UserModel.findOne({ email: email.trim().toLowerCase() })
if (!user) return null
if (!user.otp || isOtpExpired(user)) {
return { error: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید' }
}
if (String(user.otp) !== String(otp)) {
return { error: 'کد تایید اشتباه است' }
}
user.otp = null
user.otpSentAt = null
await user.save()
return user
}
return null
}
const changePasswordUser = async (req, res, next) => {
try {
const { new_password } = req.body
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
const resolved = await resolveUserFromRequest(req)
if (resolved?.error) {
return res.status(422).json({ error: true, message: resolved.error })
}
const user = resolved
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربر یافت نشد'
})
}
const hashedPassword = await bcrypt.hash(new_password, 10)
user.password = hashedPassword
await user.save()
return res.json({
message: 'کلمه عبور با موفقیت ذخیره شد'
})
} catch (error) {
next(error)
}
}
const editChangePasswordUser = async (req, res, next) => {
try {
const { new_password } = req.body
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
const resolved = await resolveUserFromRequest(req)
if (resolved?.error) {
return res.status(422).json({ error: true, message: resolved.error })
}
const user = resolved
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربر یافت نشد'
})
}
const hashedPassword = await bcrypt.hash(new_password, 10)
user.password = hashedPassword
await user.save()
const message = isGoogleUser(user) && !req.body.mobile
? 'کلمه عبور با موفقیت تنظیم شد. از این پس می‌توانید با نام کاربری وارد شوید.'
: 'کلمه عبور با موفقیت تغییر یافت'
return res.json({ message })
} catch (error) {
next(error)
}
}
module.exports = {
changePasswordUser, changePasswordValidationRules, editChangePasswordUser
}
/* eslint-disable camelcase */
const bcrypt = require('bcryptjs')
const { check, validationResult } = require('express-validator')
const jwt = require('jsonwebtoken')
const UserModel = require('../../../models/UserModel')
const changePasswordValidationRules = () => {
return [
check('new_password')
.notEmpty().withMessage('رمز عبور نمی‌تواند خالی باشد')
.isLength({ min: 8, max: 25 }).withMessage('رمز عبور نباید کوتاه تر از 8 کاراکتر باشد')
.matches(/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/u).withMessage('رمز عبور باید شامل حروف و اعداد باشد')
]
}
const changePasswordUser = async (req, res, next) => {
try {
const { new_password } = req.body
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
// اعتبارسنجی ورودی ها
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
// یافتن کاربر با شماره موبایل
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربری با این شماره موبایل یافت نشد'
})
}
// هش کردن پسورد جدید و ذخیره در دیتابیس
const hashedPassword = await bcrypt.hash(new_password, 10)
user.password = hashedPassword
await user.save()
return res.json({
message: 'کلمه عبور با موفقیت ذخیره شد'
})
} catch (error) {
next(error)
}
}
const editChangePasswordUser = async (req, res, next) => {
try {
const { mobile, new_password } = req.body
const user = await UserModel.findOne({ mobile })
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربری با این شماره موبایل یافت نشد'
})
}
// هش کردن پسورد جدید و ذخیره در دیتابیس
const hashedPassword = await bcrypt.hash(new_password, 10)
user.password = hashedPassword
await user.save()
return res.json({
message: 'کلمه عبور با موفقیت تغییر یافت'
})
} catch (error) {
next(error)
}
}
module.exports = {
changePasswordUser, changePasswordValidationRules, editChangePasswordUser
}

View File

@@ -1,108 +0,0 @@
const UserModel = require('../../../models/UserModel')
const { OTP_VALID_MS } = require('../../../utils/otpExpiry')
const { isGoogleUser } = require('../../../utils/authSteps')
const { sendOtpEmail, isEmailConfigured } = require('../../../utils/emailService')
const { sendSmsIrVerify, getSmsIrTemplateId } = require('../../../utils/smsIr')
function generateOTP () {
return Math.floor(100000 + Math.random() * 900000)
}
async function sendSmsOtp (mobile, otp) {
const result = await sendSmsIrVerify({
mobile,
otp,
templateId: getSmsIrTemplateId('930719')
})
if (!result.ok) {
throw new Error('SMS_SEND_FAILED')
}
}
const forgotPassword = async (req, res, next) => {
try {
const { identifier, email, mobile } = req.body
const lookup = (identifier || email || mobile || '').trim().toLowerCase()
if (!lookup) {
return res.status(422).json({
error: true,
message: 'ایمیل یا شماره موبایل را وارد کنید'
})
}
const isMobile = /^09[0-9]{9}$/.test(lookup)
let user = null
if (isMobile) {
user = await UserModel.findOne({ mobile: lookup })
} else {
user = await UserModel.findOne({ email: lookup })
}
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربری با این مشخصات یافت نشد'
})
}
const otp = String(generateOTP())
const otpSentAt = new Date()
user.otp = otp
user.otpSentAt = otpSentAt
await user.save()
setTimeout(() => {
UserModel.findOneAndUpdate(
{ _id: user._id },
{ $set: { otp: null, otpSentAt: null } },
{ new: true }
).catch(error => console.error('Error clearing OTP:', error))
}, OTP_VALID_MS)
if (isGoogleUser(user) || (!isMobile && user.email)) {
if (!user.email) {
return res.status(422).json({
error: true,
message: 'ایمیلی برای این حساب ثبت نشده است'
})
}
if (!isEmailConfigured()) {
return res.status(503).json({
error: true,
message: 'ارسال ایمیل در حال حاضر فعال نیست'
})
}
await sendOtpEmail(user.email, otp, 'password_reset')
return res.status(200).json({
success: true,
channel: 'email',
destination: user.email.replace(/(.{2}).+(@.+)/, '$1***$2'),
message: 'کد بازیابی به ایمیل شما ارسال شد'
})
}
if (!user.mobile) {
return res.status(422).json({
error: true,
message: 'شماره موبایلی برای این حساب ثبت نشده است'
})
}
await sendSmsOtp(user.mobile, otp)
return res.status(200).json({
success: true,
channel: 'sms',
destination: user.mobile.replace(/(\d{4})\d{4}(\d{3})/, '$1****$2'),
message: 'کد بازیابی به شماره موبایل شما ارسال شد'
})
} catch (error) {
next(error)
}
}
module.exports = {
forgotPassword
}

View File

@@ -1,81 +1,92 @@
const UserModel = require('../../../models/UserModel')
const { check, validationResult } = require('express-validator')
const { OTP_VALID_MS } = require('../../../utils/otpExpiry')
const { sendSmsIrVerify, getSmsIrTemplateId } = require('../../../utils/smsIr')
function normalizeMobile (value) {
return String(value || '').replace(/\D/g, '')
}
function loginValidationRules () {
return [
check('mobile')
.customSanitizer(normalizeMobile)
.notEmpty().withMessage('شماره موبایل نمی‌تواند خالی باشد')
.isLength({ min: 11, max: 11 }).withMessage('شماره موبایل باید دقیقاً 11 رقم باشد')
.matches(/^09[0-9]{9}$/).withMessage('فرمت شماره موبایل صحیح نیست')
]
}
function generateOTP () {
return Math.floor(100000 + Math.random() * 900000)
}
const loginUser = async (req, res, next) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
const mobile = normalizeMobile(req.body.mobile)
if (!mobile) {
return res.status(422).json({
error: true,
message: 'شماره موبایل نمی‌تواند خالی باشد'
})
}
const otp = generateOTP()
const otpSentAt = new Date()
await UserModel.findOneAndUpdate(
{ mobile },
{ $set: { mobile, otp: String(otp), otpSentAt } },
{ upsert: true, new: true, lean: true }
)
setTimeout(() => {
UserModel.findOneAndUpdate(
{ mobile },
{ $set: { otp: null, otpSentAt: null } },
{ new: true }
).catch((error) => console.error('[loginUser] clear OTP failed:', error))
}, OTP_VALID_MS)
const smsResult = await sendSmsIrVerify({
mobile,
otp,
templateId: getSmsIrTemplateId('930719')
})
if (!smsResult.ok) {
return res.status(502).json({
error: true,
message: 'ارسال پیامک ناموفق بود. لطفاً کمی بعد دوباره تلاش کنید.'
})
}
return res.status(200).json({
success: true,
message: 'کد تایید ارسال شد'
})
} catch (error) {
next(error)
}
}
module.exports = {
loginValidationRules,
loginUser
}
const UserModel = require('../../../models/UserModel')
const { check, validationResult } = require('express-validator')
const { default: axios } = require('axios')
const { OTP_VALID_MS } = require('../../../utils/otpExpiry')
const loginValidationRules = () => {
return [
check('mobile')
.notEmpty().withMessage('شماره موبایل نمی‌تواند خالی باشد')
.isLength({ min: 11, max: 11 }).withMessage('شماره موبایل باید دقیقاً 11 رقم باشد')
.matches(/^09[0-9]{9}$/).withMessage('فرمت شماره موبایل صحیح نیست')
]
}
const loginUser = async (req, res, next) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
const { mobile } = req.body
if (!mobile) {
return res.status(422).json({
error: true,
message: 'شماره موبایل نمی‌تواند خالی باشد'
})
}
// const existingUser = await UserModel.findOne({ mobile })
// if (!existingUser) {
// return res.status(422).json({ error: true, message: 'کاربری با این شماره موبایل یافت نشد' })
// }
function generateOTP () {
return Math.floor(100000 + Math.random() * 900000)
}
const otp = generateOTP()
const otpSentAt = new Date()
// eslint-disable-next-line no-unused-vars
const user = await UserModel.findOneAndUpdate(
{ mobile },
{ $set: { mobile, otp: String(otp), otpSentAt } },
{ upsert: true, new: true, lean: true }
)
setTimeout(() => {
UserModel.findOneAndUpdate(
{ mobile },
{ $set: { otp: null, otpSentAt: null } },
{ new: true }
)
.then(() => {})
.catch(error => console.error('Error setting OTP to null:', error))
}, OTP_VALID_MS)
const data = JSON.stringify({
mobile,
templateId: '930719',
parameters: [
{ name: 'CODE', value: otp.toString() }
]
})
const config = {
method: 'post',
url: 'https://api.sms.ir/v1/send/verify',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
'x-api-key': '2JKxGbTzE64t0YTkjip6RYHnm9mq1haMlidF8WWwQunwubHFVW6iaMmfFQbMXEDt'
},
data
}
axios(config)
.then(function (response) {
})
.catch(function (error) {
console.log(error)
})
res.status(200).json({
success: true,
message: 'کد تایید ارسال شد'
})
} catch (error) {
next(error)
}
}
module.exports = {
loginValidationRules,
loginUser
}

View File

@@ -1,122 +1,92 @@
/* eslint-disable camelcase */
const UserModel = require('../../../models/UserModel')
const bcrypt = require('bcryptjs')
const TokenService = require('../../../services/TokenService')
const {
expireBlockSuspensionIfNeeded,
getBlockedAccountResponse
} = require('../../../utils/blockSuspension')
const { signTemp2faToken } = require('../../../utils/loginResponse')
const { readTotpState } = require('../../../utils/totpStore')
const { buildAuthResponse } = require('../../../utils/authSteps')
const INVALID_CREDENTIALS = 'نام کاربری یا کلمه عبور را اشتباه وارد کردید'
const loginWithUserName = async (req, res, next) => {
try {
// اعتبارسنجی ورودی ها
const { user_name, password } = req.body
if (!user_name || !password) {
return res.status(422).json({
error: true,
message: INVALID_CREDENTIALS
})
}
// یافتن کاربر با نام کاربری
const userLow = user_name.toLowerCase()
let user = await UserModel.findOne({ user_name: userLow })
if (!user) {
return res.status(422).json({
error: true,
message: INVALID_CREDENTIALS
})
}
user = await expireBlockSuspensionIfNeeded(user)
const blockResponse = getBlockedAccountResponse(user)
if (blockResponse) {
return res.status(403).json(blockResponse)
}
if (!user.password) {
return res.status(422).json({
error: true,
message: 'ثبت‌نام شما کامل نیست. لطفاً کلمه عبور خود را در بخش ثبت‌نام تنظیم کنید.'
})
}
const isPasswordValid = await bcrypt.compare(password, user.password)
if (!isPasswordValid) {
return res.status(422).json({
error: true,
message: INVALID_CREDENTIALS
})
}
// توکن همیشه با آیدیِ خودِ کاربرِ لاگین‌کننده ساخته می‌شه (چه مالکِ اصلی چه
// زیرمجموعه) — چون blockCheck/resolveActingUser هر درخواست رو از رویِ همین
// آیدی دوباره از دیتابیس می‌خونه و اگه زیرمجموعه بود، به اکانتِ اصلی resolve
// می‌کنه. اما خودِ ریسپانسِ لاگین (هویتی که کلاینت ذخیره می‌کنه) هم باید از
// همین‌جا به اکانتِ اصلی resolve بشه — وگرنه کلاینت فکر می‌کنه یه حسابِ کاملاً
// جدا و مجزاست (نه همون اکانتِ اصلی با دسترسیِ محدود)
let identityUser = user
let subUserFields = null
if (user.is_sub_user) {
if (user.sub_user_active === false) {
return res.status(403).json({
error: true,
message: 'این حساب کاربری غیرفعال شده است'
})
}
const parent = user.parent_account_id
? await UserModel.findById(user.parent_account_id)
: null
if (!parent) {
return res.status(422).json({
error: true,
message: INVALID_CREDENTIALS
})
}
identityUser = parent
subUserFields = {
is_sub_user: true,
sub_user_permissions: user.sub_user_permissions || []
}
}
const token = TokenService.sign({ id: user._id })
// ۲مرحله‌ای همیشه روی خودِ کاربرِ لاگین‌کننده چک می‌شه، نه روی اکانتِ اصلی —
// وگرنه اگه صاحبِ اکانت ۲مرحله‌ای رو فعال کنه، هر زیرمجموعه‌ای موقعِ لاگین با
// رمزِ خودش گیرِ کدِ ۲مرحله‌ایِ صاحبِ اکانت می‌افتاد که اصلاً بهش دسترسی نداره.
// زیرمجموعه‌ها هیچ UIای برای تنظیمِ ۲مرحله‌ایِ خودشون ندارن، پس این چک براشون
// همیشه false می‌مونه
try {
const totpState = await readTotpState(user._id)
if (totpState?.totp_enabled && totpState?.totp_secret) {
return res.json({
requires_2fa: true,
temp_token: signTemp2faToken(user._id),
message: 'کد دو مرحله‌ای را وارد کنید'
})
}
} catch (error) {
console.error('[loginWithUserName] totp check failed:', error)
}
// buildAuthResponse مستقیم (نه buildLoginResponseForUser) چون اون دوباره خودش
// TOTP رو چک می‌کرد — این‌بار روی identityUser (اکانتِ اصلی)، که برای یه
// زیرمجموعه دقیقاً همون چکِ اشتباهی‌ای بود که بالا عمداً دورش زدیم
const response = buildAuthResponse(identityUser, token)
if (subUserFields) {
response.is_sub_user = subUserFields.is_sub_user
response.sub_user_permissions = subUserFields.sub_user_permissions
}
return res.json(response)
} catch (error) {
next(error)
}
}
module.exports = {
loginWithUserName
}
/* eslint-disable camelcase */
const UserModel = require('../../../models/UserModel')
const bcrypt = require('bcryptjs')
const TokenService = require('../../../services/TokenService')
// const jwt = require('jsonwebtoken')
const loginWithUserName = async (req, res, next) => {
try {
// اعتبارسنجی ورودی ها
const { user_name, password } = req.body
if (!user_name || !password) {
return res.status(422).json({
error: true,
message: 'اطلاعات ارسالی اشتباه است'
})
}
// یافتن کاربر با نام کاربری
const userLow = user_name.toLowerCase()
const user = await UserModel.findOne({ user_name: userLow })
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربری با این نام کاربری یافت نشد'
})
}
if (!user.password) {
return res.status(422).json({
error: true,
message: 'رمز عبور برای این کاربر تنظیم نشده است'
})
}
const isPasswordValid = await bcrypt.compare(password, user.password)
if (!isPasswordValid) {
return res.status(422).json({
error: true,
message: 'نام کاربری یا رمز عبور اشتباه است'
})
}
// اگر هم نام کاربری و هم رمز عبور درست بود، ارسال پیام موفقیت آمیز
const token = TokenService.sign({ id: user._id })
// if (user.user_name === null) {
// return res.json({
// message: 'ورود با موفقیت',
// page: 'username'
// })
// }
// if (!user.first_name && !user.last_name) {
// return res.json({
// message: 'ورود با موفقیت',
// page: 'name'
// })
// }
// if (!user.user_type) {
// return res.json({
// message: 'کد تایید صحیح بود',
// page: 'usertype'
// })
// }
let step = ''
if (user.user_name === null) { step = 'user_name' } else if
(!user.password) { step = 'password' } else if
(!user.first_name) { step = 'first_name' } else if
(!user.user_type) { step = 'user_type' }
if (!user.user_type || user.user_name === null || !user.first_name || !user.last_name) {
return res.json({
message: 'کد تایید صحیح بود',
token,
step,
user_type: null,
page: 'auth-page',
id: user._id
})
}
return res.json({
message: 'کد تایید صحیح بود',
token,
page: 'home',
step,
user_type: user.user_type,
id: user._id
})
} catch (error) {
next(error)
}
}
module.exports = {
loginWithUserName
}

View File

@@ -1,58 +1,98 @@
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { isOtpExpired } = require('../../../utils/otpExpiry')
const {
expireBlockSuspensionIfNeeded,
getBlockedAccountResponse
} = require('../../../utils/blockSuspension')
const { buildLoginResponseForUser } = require('../../../utils/loginResponse')
const verifyUser = async (req, res, next) => {
try {
const { mobile, otp } = req.body
if (!mobile || !otp) {
return res.status(422).json({
error: true,
message: 'اطلاعات ارسالی اشتباه است'
})
}
let user = await UserModel.findOne({ mobile })
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربری با این شماره موبایل یافت نشد'
})
}
user = await expireBlockSuspensionIfNeeded(user)
const blockResponse = getBlockedAccountResponse(user)
if (blockResponse) {
return res.status(403).json(blockResponse)
}
const token = TokenService.sign({ id: user._id })
if (!user.otp || isOtpExpired(user)) {
return res.status(422).json({
error: true,
message: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید'
})
}
if (String(user.otp) === String(otp)) {
return res.json(await buildLoginResponseForUser(user, token))
}
return res.status(422).json({
error: true,
message: 'کد را اشتباه وارد کردید'
})
} catch (error) {
next(error)
}
}
module.exports = {
verifyUser
}
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { isOtpExpired } = require('../../../utils/otpExpiry')
// const jwt = require('jsonwebtoken')
const verifyUser = async (req, res, next) => {
try {
const { mobile, otp } = req.body
if (!mobile || !otp) {
return res.status(422).json({
error: true,
message: 'اطلاعات ارسالی اشتباه است'
})
}
const user = await UserModel.findOne({ mobile })
if (!user) {
return res.status(404).json({
error: true,
message: 'کاربری با این شماره موبایل یافت نشد'
})
}
const token = TokenService.sign({ id: user._id })
if (!user.otp || isOtpExpired(user)) {
return res.status(422).json({
error: true,
message: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید'
})
}
if (String(user.otp) === String(otp)) {
// if (user.user_name === null) {
// return res.json({
// message: 'کد تایید صحیح بود',
// page: 'username'
// })
// }
// if (!user.first_name && !user.last_name) {
// return res.json({
// message: 'کد تایید صحیح بود',
// page: 'name'
// })
// }
// if (!user.user_type) {
// return res.json({
// message: 'کد تایید صحیح بود',
// page: 'usertype'
// })
// }
// اگر همه فیلدها پر بودند
// return res.json({
// message: 'کد تایید صحیح بود',
// token,
// user_type: user.user_type,
// page: 'home',
// id: user._id
// })
let step = ''
if (user.user_name === null) { step = 'user_name' } else if
(!user.password) { step = 'password' } else if
(!user.first_name) { step = 'first_name' } else if
(!user.user_type) { step = 'user_type' } else { step = 'profile_image' }
if (!user.user_type || user.user_name === null || !user.first_name || !user.last_name) {
return res.json({
message: 'کد تایید صحیح بود',
token,
step,
user_type: null,
page: 'auth-page',
id: user._id
})
}
return res.json({
message: 'کد تایید صحیح بود',
token,
user_type: user.user_type,
step,
page: 'home',
id: user._id
})
}
return res.status(422).json({
error: true,
message: 'کد تایید اشتباه است'
})
} catch (error) {
next(error)
}
}
module.exports = {
verifyUser
}

View File

@@ -1,172 +1,127 @@
const MessageModel = require('../../../models/MessageModel')
const UserModel = require('../../../models/UserModel')
const StoryModel = require('../../../models/StoryModel')
const jwt = require('jsonwebtoken')
const moment = require('moment-jalaali')
const {
viewerBlockedUser,
userBlockedViewer,
} = require('../../../utils/blockVisibility')
const { formatChatUserLabel } = require('../../../utils/accountLifecycle')
const { formatLastOnlineStatus } = require('../../../utils/onlineStatus')
const { getShopChatsForUser } = require('../shop-chat/shopChatController')
const { getOrSetCache } = require('../../../utils/cache')
const { unreadMessagesCacheKey } = require('../../../utils/unreadMessagesCache')
// TTLِ کوتاه (نه یه شمارنده‌ی incremental) عمداً — چون خوانده‌شدن/رسیدنِ پیام از
// چندین مسیرِ مختلف اتفاق می‌افته (index.js، سوکت)، نگه‌داشتنِ یه شمارنده‌ی دقیق
// نیاز به ردیابیِ همه‌ی اون نقاط داره و راحت drift پیدا می‌کنه؛ یه کشِ کوتاه‌مدت با
// invalidate صریح توی نقاطِ اصلی (پایین) هم سریعه هم امن
const UNREAD_COUNT_CACHE_TTL = 20
const getMessages = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { search } = req.query
// پیدا کردن تمام پیام‌هایی که کاربر مورد نظر آنها فرستنده یا گیرنده بوده است
const messageFilter = {
$or: [
{ senderId: userId },
{ receiverId: userId }
]
}
// پیدا کردن تمام پیام‌ها با فیلتر و سورت براساس جدیدترین پیام
const messages = await MessageModel.find(messageFilter).sort({ createdAt: -1 })
// استخراج شناسه‌های تمام کاربران مرتبط با این پیام‌ها (بدون تکرار)
const userIdToLastMessageDate = {}
messages.forEach(message => {
if (!userIdToLastMessageDate[message.senderId]) {
userIdToLastMessageDate[message.senderId] = message.createdAt
}
if (!userIdToLastMessageDate[message.receiverId]) {
userIdToLastMessageDate[message.receiverId] = message.createdAt
}
})
// تبدیل مجموعه شناسه‌های کاربر به آرایه
const userIdsArray = Object.keys(userIdToLastMessageDate)
// ساخت فیلتر جستجو برای کاربران مرتبط
const userFilter = { _id: { $in: userIdsArray } }
if (search) {
userFilter.$and = [
{ _id: { $in: userIdsArray } }, // فقط کاربران مرتبط
{
$or: [
{ first_name: { $regex: search, $options: 'i' } },
{ last_name: { $regex: search, $options: 'i' } },
{ user_name: { $regex: search, $options: 'i' } }
]
}
]
}
// استوری فعال طرف‌های گفتگو — برای نمایش حلقه‌ی استوری دور آواتار توی لیست چت
const activeStories = await StoryModel.find({
user_id: { $in: userIdsArray },
expires_at: { $gt: new Date() }
})
.select('user_id viewers')
.lean()
const storiesByAuthor = {}
activeStories.forEach((s) => {
const key = String(s.user_id)
if (!storiesByAuthor[key]) storiesByAuthor[key] = []
storiesByAuthor[key].push(s)
})
const hasStorySet = new Set(Object.keys(storiesByAuthor))
const unviewedStorySet = new Set(
Object.keys(storiesByAuthor).filter((authorId) =>
storiesByAuthor[authorId].some(
(s) => !s.viewers?.some((v) => String(v.user_id) === String(userId))
)
)
)
// پیدا کردن اطلاعات کاربران مرتبط بر اساس فیلتر جستجو و شمارش تعداد پیام‌های خوانده نشده برای هر کاربر
const usersData = await UserModel.find(userFilter)
const usersWithUnreadCount = await Promise.all(usersData.map(async (user) => {
const unreadMessagesCount = await MessageModel.countDocuments({ receiverId: userId, senderId: user._id, readStatus: 0 })
let lastOnlineStatus = formatLastOnlineStatus(user.last_online, user.ghost_mode)
// بررسی آیا کاربر فعلی در لیست بلاک‌شده‌های این کاربر است یا خیر
const blockedByCurrentUser = viewerBlockedUser(user, userId)
const blockedYou = userBlockedViewer(user, userId)
return {
first_name: user.first_name,
last_name: user.last_name,
user_name: user.user_name,
display_name: formatChatUserLabel(user),
account_deactivated: user.account_status === 'deactivated',
is_verified: user.is_verified,
verify_badge: user.verify_badge,
profile_image: user.profile_image,
last_online: lastOnlineStatus,
has_story: hasStorySet.has(String(user._id)),
has_unviewed_story: unviewedStorySet.has(String(user._id)),
_id: user._id,
unread_messages_count: unreadMessagesCount,
is_blocked: blockedByCurrentUser,
blocked_you: blockedYou,
last_message_date: userIdToLastMessageDate[user._id]
}
}))
// حذف کاربر فعلی از آرایه
const directChats = usersWithUnreadCount
.filter(user => user._id.toString() !== userId)
.map(user => ({ ...user, chat_type: 'user' }))
// ادغام گفتگوهای فروشگاهی (ساختار کاملاً جدا، رمزنگاری‌نشده) در همان لیست پیام‌ها
let shopChats = await getShopChatsForUser(userId)
if (search) {
const regex = new RegExp(search, 'i')
shopChats = shopChats.filter(chat => regex.test(chat.display_name || ''))
}
const filteredUsersData = [...directChats, ...shopChats].sort(
(a, b) => new Date(b.last_message_date) - new Date(a.last_message_date)
)
res.status(200).json({
filteredUsersData,
totalMessages: messages.length
})
} catch (error) {
next(error)
}
}
const getUnreadMessages = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
// پیدا کردن تمام پیام‌های کاربر که هنوز خوانده نشده‌اند
const unreadMessagesCount = await getOrSetCache(
unreadMessagesCacheKey(userId),
UNREAD_COUNT_CACHE_TTL,
() => MessageModel.countDocuments({ receiverId: userId, readStatus: 0 })
)
res.status(200).json({ unread_messages_count: unreadMessagesCount })
} catch (error) {
next(error)
}
}
module.exports = {
getMessages, getUnreadMessages
}
const MessageModel = require('../../../models/MessageModel')
const UserModel = require('../../../models/UserModel')
const jwt = require('jsonwebtoken')
const moment = require('moment-jalaali')
const getMessages = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { search } = req.query
// پیدا کردن تمام پیام‌هایی که کاربر مورد نظر آنها فرستنده یا گیرنده بوده است
const messageFilter = {
$or: [
{ senderId: userId },
{ receiverId: userId }
]
}
// پیدا کردن تمام پیام‌ها با فیلتر و سورت براساس جدیدترین پیام
const messages = await MessageModel.find(messageFilter).sort({ createdAt: -1 })
// استخراج شناسه‌های تمام کاربران مرتبط با این پیام‌ها (بدون تکرار)
const userIdToLastMessageDate = {}
messages.forEach(message => {
if (!userIdToLastMessageDate[message.senderId]) {
userIdToLastMessageDate[message.senderId] = message.createdAt
}
if (!userIdToLastMessageDate[message.receiverId]) {
userIdToLastMessageDate[message.receiverId] = message.createdAt
}
})
// تبدیل مجموعه شناسه‌های کاربر به آرایه
const userIdsArray = Object.keys(userIdToLastMessageDate)
// ساخت فیلتر جستجو برای کاربران مرتبط
const userFilter = { _id: { $in: userIdsArray } }
if (search) {
userFilter.$and = [
{ _id: { $in: userIdsArray } }, // فقط کاربران مرتبط
{
$or: [
{ first_name: { $regex: search, $options: 'i' } },
{ last_name: { $regex: search, $options: 'i' } },
{ user_name: { $regex: search, $options: 'i' } }
]
}
]
}
// پیدا کردن اطلاعات کاربران مرتبط بر اساس فیلتر جستجو و شمارش تعداد پیام‌های خوانده نشده برای هر کاربر
const usersData = await UserModel.find(userFilter)
const usersWithUnreadCount = await Promise.all(usersData.map(async (user) => {
const unreadMessagesCount = await MessageModel.countDocuments({ receiverId: userId, senderId: user._id, readStatus: 0 })
let lastOnlineStatus
const now = moment()
const lastOnline = moment(user.last_online)
const diffMinutes = now.diff(lastOnline, 'minutes')
if (diffMinutes < 1) {
lastOnlineStatus = 'آنلاین'
} else if (diffMinutes < 60) {
lastOnlineStatus = `${diffMinutes} دقیقه پیش`
} else if (diffMinutes < 1440) {
lastOnlineStatus = `${Math.floor(diffMinutes / 60)} ساعت پیش`
} else {
lastOnlineStatus = `${Math.floor(diffMinutes / 1440)} روز پیش`
}
// بررسی آیا کاربر فعلی در لیست بلاک‌شده‌های این کاربر است یا خیر
const blockedByCurrentUser = user.blocked_by.includes(userId)
const blockedYou = user.blocked_users.includes(userId)
return {
first_name: user.first_name,
last_name: user.last_name,
user_name: user.user_name,
is_verified: user.is_verified,
profile_image: user.profile_image,
last_online: lastOnlineStatus,
_id: user._id,
unread_messages_count: unreadMessagesCount,
is_blocked: blockedByCurrentUser,
blocked_you: blockedYou,
last_message_date: userIdToLastMessageDate[user._id]
}
}))
// حذف کاربر فعلی از آرایه و سورت کردن کاربران براساس آخرین پیام
const filteredUsersData = usersWithUnreadCount
.filter(user => user._id.toString() !== userId)
.sort((a, b) => new Date(b.last_message_date) - new Date(a.last_message_date))
res.status(200).json({
filteredUsersData,
totalMessages: messages.length
})
} catch (error) {
next(error)
}
}
const getUnreadMessages = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
// پیدا کردن تمام پیام‌های کاربر که هنوز خوانده نشده‌اند
const unreadMessagesCount = await MessageModel.countDocuments({ receiverId: userId, readStatus: 0 })
res.status(200).json({ unread_messages_count: unreadMessagesCount })
} catch (error) {
next(error)
}
}
module.exports = {
getMessages, getUnreadMessages
}

View File

@@ -1,182 +1,68 @@
const UserModel = require('../../../models/UserModel')
const jwt = require('jsonwebtoken')
const NotificationModel = require('../../../models/NotificationModel')
const jMoment = require('moment-jalaali')
const { getOrSetCache, invalidateCache } = require('../../../utils/cache')
function unreadNotificationsCacheKey (userId) {
return `unread:notifications:${userId}`
}
// شمارنده‌ی نوتیف‌های نخونده خیلی پرتکرار خونده می‌شه (بج روی آیکون زنگوله، تقریباً
// هر بار باز شدنِ اپ) — TTLِ کوتاه به‌جای شمارنده‌ی incremental، چون نوتیف‌های جدید از
// ده‌ها نقطه‌ی مختلفِ کد (لایک/کامنت/فالو/...) ساخته می‌شن و ردیابیِ دقیقِ همه‌شون
// برای invalidate کردن عملی نیست؛ فقط نقطه‌ی "خونده‌شدنِ همه" (پایین) صریحاً invalidate می‌شه
const UNREAD_NOTIFICATIONS_CACHE_TTL = 20
const getNotifications = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { page = 1, limit = 10, search } = req.query // افزودن پارامترهای صفحه‌بندی به درخواست
const filter = { user_id: userId }
if (search) {
filter.$or = [
{ title: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
]
}
// استفاده از مدل پیجینیت شده برای دریافت نتایج صفحه‌بندی شده
const options = {
page: parseInt(page), // تبدیل صفحه به عدد صحیح
limit: parseInt(limit), // تبدیل محدودیت به عدد صحیح
sort: { createdAt: -1 } // بر اساس زمان ایجاد (createdAt) مرتب کنید (نزولی)
}
// دریافت اعلان‌های مربوط به کاربر با استفاده از userId
let notifications = await NotificationModel.paginate(filter, options)
const totalPages = notifications.totalPages
const totalItems = notifications.totalDocs
const actorIds = [
...new Set(
notifications.docs
.map((n) => n.actor_id)
.filter(Boolean)
.map(String)
)
]
const actors = actorIds.length
? await UserModel.find({ _id: { $in: actorIds } })
.select('user_name profile_image first_name last_name')
.lean()
: []
const actorMap = new Map(actors.map((a) => [String(a._id), a]))
notifications = notifications?.docs.map(notification => {
const jDate = jMoment(notification.createdAt).format('jYYYY-jMM-jDD HH:mm')
const actorDoc = notification.actor_id
? actorMap.get(String(notification.actor_id))
: null
return {
...notification._doc,
createdAt: jDate,
actor: actorDoc
? {
_id: String(actorDoc._id),
user_name: actorDoc.user_name,
profile_image: actorDoc.profile_image,
first_name: actorDoc.first_name,
last_name: actorDoc.last_name
}
: null
}
})
// تغییر وضعیت "read" اعلان‌ها به "true"
await NotificationModel.updateMany({ user_id: userId }, { read: true })
invalidateCache(unreadNotificationsCacheKey(userId)).catch(() => {})
res.json({
notifications,
totalPages, // ارسال تعداد کل صفحات
totalItems // ارسال تعداد کل آیتم‌ها
})
} catch (error) {
next(error)
}
}
const unreadNotifications = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
// دریافت تعداد اعلان‌های جدید برای کاربر
const newNotificationsCount = await getOrSetCache(
unreadNotificationsCacheKey(userId),
UNREAD_NOTIFICATIONS_CACHE_TTL,
() => NotificationModel.countDocuments({ user_id: userId, read: false }).exec()
)
res.json({ newNotificationsCount })
} catch (error) {
next(error)
}
}
const registerPushToken = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { fcmToken, platform } = req.body
if (!fcmToken) return res.status(400).json({ message: 'fcmToken الزامی است' })
// توکن‌های وب (مرورگر) جدا از توکن‌های اندروید ذخیره می‌شن
const field = platform === 'web' ? 'fcm_web_tokens' : 'fcm_tokens'
// یه توکنِ دستگاه (fcmToken) مختص یه اکانتِ خاص نیست — وقتی کاربر از یه اکانت خارج
// و با اکانت دیگه‌ای وارد می‌شه، همون توکن قبلی دوباره ثبت می‌شه، ولی تا الان از
// داکیومنت اکانت قبلی هیچ‌وقت پاک نمی‌شد؛ نتیجه‌ش این بود که اعلان‌های اکانت قبلی
// هم همچنان روی همین دستگاه می‌رسید. اول از همه‌ی کاربرهای دیگه پاک می‌شه
await UserModel.updateMany(
{ _id: { $ne: userId } },
{ $pull: { fcm_tokens: fcmToken, fcm_web_tokens: fcmToken } }
)
// اضافه‌کردن توکن جدید و نگه‌داشتن حداکثر ۵ توکن آخر (چند دستگاه، ولی بدون رشد بی‌نهایت)
await UserModel.updateOne({ _id: userId }, { $pull: { [field]: fcmToken } })
await UserModel.updateOne(
{ _id: userId },
{ $push: { [field]: { $each: [fcmToken], $slice: -5 } } }
)
res.json({ ok: true })
} catch (error) {
next(error)
}
}
const markBroadcastSeen = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { id } = req.params
await NotificationModel.updateOne(
{ user_id: userId, broadcast_id: id },
{ push_delivered_seen: true }
)
res.json({ ok: true })
} catch (error) {
next(error)
}
}
const markBroadcastClicked = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { id } = req.params
await NotificationModel.updateOne(
{ user_id: userId, broadcast_id: id },
{ push_clicked: true }
)
res.json({ ok: true })
} catch (error) {
next(error)
}
}
module.exports = {
getNotifications, unreadNotifications, registerPushToken, markBroadcastSeen, markBroadcastClicked
}
const jwt = require('jsonwebtoken')
const NotificationModel = require('../../../models/NotificationModel')
const jMoment = require('moment-jalaali')
const getNotifications = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { page = 1, limit = 10, search } = req.query // افزودن پارامترهای صفحه‌بندی به درخواست
const filter = { user_id: userId }
if (search) {
filter.$or = [
{ title: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
]
}
// استفاده از مدل پیجینیت شده برای دریافت نتایج صفحه‌بندی شده
const options = {
page: parseInt(page), // تبدیل صفحه به عدد صحیح
limit: parseInt(limit), // تبدیل محدودیت به عدد صحیح
sort: { createdAt: -1 } // بر اساس زمان ایجاد (createdAt) مرتب کنید (نزولی)
}
// دریافت اعلان‌های مربوط به کاربر با استفاده از userId
let notifications = await NotificationModel.paginate(filter, options)
const totalPages = notifications.totalPages
const totalItems = notifications.totalDocs
notifications = notifications?.docs.map(notification => {
const jDate = jMoment(notification.createdAt).format('jYYYY-jMM-jDD HH:mm')
return {
...notification._doc,
createdAt: jDate
}
})
// تغییر وضعیت "read" اعلان‌ها به "true"
await NotificationModel.updateMany({ user_id: userId }, { read: true })
res.json({
notifications,
totalPages, // ارسال تعداد کل صفحات
totalItems // ارسال تعداد کل آیتم‌ها
})
} catch (error) {
next(error)
}
}
const unreadNotifications = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
// دریافت تعداد اعلان‌های جدید برای کاربر
const newNotificationsCount = await NotificationModel.countDocuments({ user_id: userId, read: false }).exec()
res.json({ newNotificationsCount })
} catch (error) {
next(error)
}
}
module.exports = {
getNotifications, unreadNotifications
}

View File

@@ -6,17 +6,9 @@ const UserModel = require('../../../models/UserModel')
const OfferTypeModel = require('../../../models/OfferTypeModel')
const PaymentModel = require('../../../models/PaymentModel')
const NotificationModel = require('../../../models/NotificationModel')
const { default: axios } = require('axios')
const CommentModel = require('../../../models/CommentModel')
const {
resolveRatingForComment,
recalculateUserRating
} = require('../../../utils/commentRating')
const { resolveCommentModerationStatus } = require('../../../utils/contentModeration')
const {
canUseFreeCreation,
consumeFreeCreation,
freeCreationLimitResponse
} = require('../../../utils/freeCreationLimit')
const getOfferPriceFromDatabase = async (itemName) => {
const item = await OfferTypeModel.findOne({ name: itemName })
if (!item) {
@@ -27,12 +19,8 @@ const getOfferPriceFromDatabase = async (itemName) => {
const getOfferTypes = async (req, res, next) => {
try {
const authHeader = req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Access Denied' })
}
const token = authHeader.split(' ')[1]
if (!token) return res.status(401).json({ message: 'Access Denied' })
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
@@ -43,47 +31,22 @@ const getOfferTypes = async (req, res, next) => {
message: 'کاربر یافت نشد'
})
}
let offerTypes = await OfferTypeModel.find({}, 'name price').lean()
let offerTypes = await OfferTypeModel.find({}, 'name price')
// اگر در دیتابیس نوع‌ها خالی باشد، پیش‌فرض رایگان/پولی
if (!offerTypes || offerTypes.length === 0) {
offerTypes = [
{ name: 'free', price: 0 },
{ name: 'normal', price: 50000 }
]
// بررسی برای مواردی مانند پیدا نشدن انواع پروژه
if (!offerTypes) {
return res.status(404).json({ message: 'انواع پروژه یافت نشد.' })
}
// رایگان بودن درخواست به «گیرنده» بستگی دارد: اگر گیرنده «دریافت هدیه» را غیرفعال
// کرده باشه، فرستنده (با سهمیه‌ی رایگانِ خودش) می‌تونه درخواست رایگان بده
const receiverForFree = req.query.userId
? await UserModel.findById(req.query.userId).select('gift_enabled offer_payment_enabled').lean()
: null
// رایگان وقتی فعاله که گیرنده «دریافت هدیه» یا «دریافت پرداختی از چت» رو غیرفعال کرده باشه
const freeEligible = receiverForFree?.gift_enabled === false || receiverForFree?.offer_payment_enabled === false
const canFree = freeEligible && canUseFreeCreation(user)
// رایگان را حذف نکن — اگر مجاز نیست، با فلگ disabled برگردان تا UI نشان دهد
offerTypes = offerTypes.map((t) => {
const plain = t.toObject ? t.toObject() : { ...t }
if (plain.name === 'free') {
plain.disabled = !canFree
plain.unavailableReason = canFree
? null
: !freeEligible
? 'برای درخواست رایگان، دریافت هدیه از چت را در تنظیمات حساب غیرفعال کنید'
: 'سقف ثبت رایگان شما به پایان رسیده است'
}
return plain
})
res.status(200).json({
offerTypes,
canUseFree: canFree,
monthly_free_offer: user.monthly_free_offer || 0
})
// اگر کاربر درخواست ماهانه نداشته باشد، نوع پروژه ماهانه را حذف کنید
if (user.monthly_free_offer <= 0) {
offerTypes = offerTypes.filter(projectType => projectType.name !== 'free')
}
// ارسال انواع پروژه به کاربر
res.status(200).json({ offerTypes })
} catch (error) {
// در صورت بروز خطا، ارسال پیام خطا به کاربر
console.error('Error in getOfferTypes:', error)
res.status(500).json({ message: 'خطا در دریافت انواع درخواست.' })
res.status(500).json({ message: 'خطا در دریافت انواع پروژه.' })
}
}
const getUserOffers = async (req, res, next) => {
@@ -95,8 +58,8 @@ const getUserOffers = async (req, res, next) => {
const userId = decodedToken.id
const { page = 1, limit = 10, status_filter, id } = req.query // افزودن پارامترهای صفحه‌بندی به درخواست
let filter = {}
const filterbyid = { receiver: id }
const filterbyidsender = { sender: id }
let filterbyid = {receiver: id}
let filterbyidsender = {sender: id}
if (status_filter === 'درخواست') {
filter = { sender: userId }
} else {
@@ -122,9 +85,9 @@ const getUserOffers = async (req, res, next) => {
// دریافت آفرهای صفحه‌بندی شده
let offer = await OfferModel.paginate(filter, options)
const offerbyid = await OfferModel.paginate(filterbyid, options)
const offerbyidsender = await OfferModel.paginate(filterbyidsender, options)
let offerbyid = await OfferModel.paginate(filterbyid, options)
let offerbyidsender = await OfferModel.paginate(filterbyidsender, options)
const totalPages = offer.totalPages
const totalItems = offer.totalDocs
@@ -155,6 +118,7 @@ const updateOfferStatus = async (req, res, next) => {
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id // ID کاربری که درخواست را به‌روزرسانی می‌کند
const reciverUser = await UserModel.findById(userId)
const { offerId, action } = req.body // آیدی آفر و اکشن (accept یا reject)
@@ -165,6 +129,7 @@ const updateOfferStatus = async (req, res, next) => {
// یافتن آفر موردنظر
const offer = await OfferModel.findById(offerId)
const senderUser = await UserModel.findById(offer?.sender)
if (!offer) {
return res.status(404).json({ error: 'Offer not found' })
@@ -184,6 +149,59 @@ const updateOfferStatus = async (req, res, next) => {
offer.status = action === 'accept' ? 'accepted' : 'rejected'
await offer.save()
if (action === 'accept') {
const data = JSON.stringify({
mobile: senderUser?.mobile,
templateId: '633448',
parameters: [
{ name: 'USER', value: reciverUser?.first_name + ' ' + reciverUser?.last_name },
{ name: 'EMPLOYER', value: senderUser?.first_name + ' ' + senderUser?.last_name }
]
})
const config = {
method: 'post',
url: 'https://api.sms.ir/v1/send/verify',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
'x-api-key': '2JKxGbTzE64t0YTkjip6RYHnm9mq1haMlidF8WWwQunwubHFVW6iaMmfFQbMXEDt'
},
data
}
axios(config)
.then(function (response) {
})
.catch(function (error) {
console.log(error)
})
} else {
const data = JSON.stringify({
mobile: senderUser?.mobile,
templateId: '682678',
parameters: [
{ name: 'USER', value: reciverUser?.first_name + ' ' + reciverUser?.last_name },
{ name: 'EMPLOYER', value: senderUser?.first_name + ' ' + senderUser?.last_name }
]
})
const config = {
method: 'post',
url: 'https://api.sms.ir/v1/send/verify',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
'x-api-key': '2JKxGbTzE64t0YTkjip6RYHnm9mq1haMlidF8WWwQunwubHFVW6iaMmfFQbMXEDt'
},
data
}
axios(config)
.then(function (response) {
})
.catch(function (error) {
console.log(error)
})
}
res.status(200).json({
message: `Offer has been ${offer.status} successfully`,
offerId: offer._id,
@@ -210,24 +228,12 @@ const handleSuccessfulOfferPayment = async (req, res) => {
const reciverUser = await UserModel.findById(reciverId)
if (!user) return res.status(404).json({ error: 'User not found' })
// اگر گیرنده «دریافت پرداختی از چت» رو غیرفعال کرده باشه، درخواست همکاری براش
// همیشه رایگانه — مستقل از سهمیه‌ی رایگان ماهانه‌ی فرستنده
const receiverFreeEligible = reciverUser?.gift_enabled === false || reciverUser?.offer_payment_enabled === false
let price
if (offerType === 'free' && receiverFreeEligible && canUseFreeCreation(user)) {
const consumed = await consumeFreeCreation(user)
if (!consumed.ok) {
return res.status(403).json(freeCreationLimitResponse())
}
if (user.monthly_free_offer > 0 && offerType === 'free') {
// Use free offer and decrement counter
price = 0
} else if (offerType === 'free') {
return res.status(403).json({
error: true,
message: canUseFreeCreation(user)
? 'سهمیه درخواست رایگان ماهانه شما تمام شده است.'
: freeCreationLimitResponse().message
})
user.monthly_free_offer -= 1
await user.save()
} else {
// Retrieve offer price based on type
price = await getOfferPriceFromDatabase(offerType)
@@ -273,6 +279,34 @@ const handleSuccessfulOfferPayment = async (req, res) => {
})
await notification.save()
// Send Sms
const data = JSON.stringify({
mobile: reciverUser?.mobile,
templateId: '569006',
parameters: [
{ name: 'FIRSTNAME', value: reciverUser?.first_name },
{ name: 'LASTNAME', value: reciverUser?.last_name }
]
})
const config = {
method: 'post',
url: 'https://api.sms.ir/v1/send/verify',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
'x-api-key': '2JKxGbTzE64t0YTkjip6RYHnm9mq1haMlidF8WWwQunwubHFVW6iaMmfFQbMXEDt'
},
data
}
axios(config)
.then(function (response) {
})
.catch(function (error) {
console.log(error)
})
// Send event based on payment status
// Emit a success event
@@ -340,38 +374,55 @@ const createOfferComment = async (req, res, next) => {
const userId = decodedToken.id
const { offerId, comment, rate, user_id } = req.body
if (!offerId || !comment || !user_id) {
if (!offerId || !comment || !rate || !user_id) {
return res.status(400).send({ message: 'All fields are required' })
}
const { ratingValue, isNewRating, requiresRating } = await resolveRatingForComment({
creatorId: userId,
targetUserId: user_id,
rate
// بررسی اینکه آیا کاربر قبلاً برای این پیشنهاد نظر داده است
const existingComment = await CommentModel.findOne({
offer: offerId,
creator: userId
})
if (requiresRating) {
return res.status(400).send({ message: 'Rating must be between 1 and 5' })
if (existingComment) {
return res.status(400).send({ message: 'شما قبلاً نظر خود را برای این درخواست ثبت کرده‌اید' })
}
const creatorUser = await UserModel.findById(userId).select('verify_badge').lean()
const commentStatus = resolveCommentModerationStatus(comment, creatorUser?.verify_badge)
const newComment = new CommentModel({
user: user_id,
offer: offerId,
creator: userId,
rating: ratingValue,
rating: rate,
comment,
comment_for: 'user',
status: commentStatus
status: 'pending'
})
await newComment.save()
if (isNewRating) {
await recalculateUserRating(user_id)
const user = await UserModel.findById(user_id)
// پیدا کردن کامنت‌ها و محاسبه‌ی امتیاز کل
const comments = await CommentModel.find({ user: user_id, comment_for: 'user' })
const totalRating = Number(comments.reduce((acc, comment) => acc + comment.rating, 0))
let userLevel
if (user.expertise === 'مدل') {
if (totalRating <= 50) userLevel = 'تازه وارد'
else if (totalRating <= 100) userLevel = 'استاندارد'
else if (totalRating <= 300) userLevel = 'حرفه‌ای'
else userLevel = 'استاد'
} else if (['زیبایی', 'عکاس'].includes(user.expertise)) {
if (totalRating <= 50) userLevel = 'تازه وارد'
else if (totalRating <= 100) userLevel = 'استاندارد'
else if (totalRating <= 300) userLevel = 'حرفه‌ای'
else userLevel = 'استاد'
}
user.user_score = totalRating
user.user_level = userLevel
const totalComments = await CommentModel.countDocuments({ user: user_id, comment_for: 'user' })
const averageRating = totalRating / totalComments
user.rate = averageRating.toFixed(1)
await user.save()
res.status(200).json({ message: 'نظر شما با موفقیت ثبت شد' })
} catch (error) {
next(error)

View File

@@ -1,545 +0,0 @@
/* eslint-disable camelcase */
const moment = require('moment-jalaali')
const ShopModel = require('../../../models/ShopModel')
const ShopProductListingModel = require('../../../models/ShopProductListingModel')
const OrderModel = require('../../../models/OrderModel')
const UserModel = require('../../../models/UserModel')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const ShopRatingModel = require('../../../models/ShopRatingModel')
const OrderReportModel = require('../../../models/OrderReportModel')
const ReturnRequestModel = require('../../../models/ReturnRequestModel')
const { notifyUser } = require('../../../utils/notifyUser')
const COMMISSION_RATE = 0.05
const PAYMENT_TIMEOUT_MINUTES = 60
const RETURN_WINDOW_DAYS = 3
function orderError (res, status, message) {
return res.status(status).json({ error: true, message })
}
function hasCompleteAddress (user) {
return Boolean(user.address && user.province && user.city && user.lat && user.lng)
}
function serializeOrder (order) {
const obj = typeof order.toObject === 'function' ? order.toObject() : { ...order }
const rawCreatedAt = obj.createdAt
const paymentExpiresAt = rawCreatedAt
? new Date(new Date(rawCreatedAt).getTime() + PAYMENT_TIMEOUT_MINUTES * 60 * 1000)
: null
return {
...obj,
order_number: obj.orderNumber || String(obj._id).slice(-8).toUpperCase(),
createdAt: rawCreatedAt ? moment(rawCreatedAt).format('jYYYY/jMM/jDD HH:mm') : rawCreatedAt,
paymentExpiresAt,
isPaymentExpired:
obj.status === 'pending_payment' && paymentExpiresAt ? new Date() > paymentExpiresAt : false
}
}
const createOrderDraft = async (req, res, next) => {
try {
const user = req.user
if (!hasCompleteAddress(user)) {
return res.status(422).json({
error: true,
code: 'ADDRESS_REQUIRED',
message: 'برای ثبت سفارش نیاز به ثبت آدرس و لوکیشن می‌باشید'
})
}
const { listingId, variantId, quantity, shippingMethod } = req.body
if (!listingId || !variantId || !quantity) {
return orderError(res, 422, 'اطلاعات سفارش ناقص است')
}
const listing = await ShopProductListingModel.findById(listingId)
if (!listing || listing.status !== 'active') {
return orderError(res, 404, 'کالا یافت نشد یا در دسترس نیست')
}
const shop = await ShopModel.findById(listing.shop)
if (!shop || shop.status !== 'active') {
return orderError(res, 404, 'فروشگاه در دسترس نیست')
}
const variant = listing.variants.id(variantId)
if (!variant) {
return orderError(res, 404, 'ترکیب انتخابی یافت نشد')
}
const qty = Number(quantity)
if (!Number.isFinite(qty) || qty < 1) {
return orderError(res, 422, 'تعداد نامعتبر است')
}
if (variant.stock < qty) {
return orderError(res, 422, 'موجودی کافی نیست')
}
const enabledShippingMethods = shop.shipping_methods.filter((m) => m.enabled)
if (enabledShippingMethods.length > 0 && !shippingMethod) {
return orderError(res, 422, 'انتخاب روش ارسال الزامی است')
}
let shippingCost = 0
if (shippingMethod) {
const method = enabledShippingMethods.find((m) => m.method === shippingMethod)
if (!method) {
return orderError(res, 422, 'روش ارسال انتخاب‌شده برای این فروشگاه فعال نیست')
}
shippingCost = method.cost
}
const unitPrice = variant.discount_price != null ? variant.discount_price : variant.price
const itemSubtotal = unitPrice * qty
const freeShipping =
shop.free_shipping_threshold != null && itemSubtotal >= shop.free_shipping_threshold
if (freeShipping) shippingCost = 0
// هزینه ارسال به مبلغ قابل پرداخت آنلاین اضافه می‌شود
const totalAmount = itemSubtotal + shippingCost
const commissionAmount = Math.round(itemSubtotal * COMMISSION_RATE)
// هزینه ارسال کامل به فروشنده می‌رسد؛ کمیسیون فقط روی قیمت کالا محاسبه می‌شود
const sellerNetAmount = itemSubtotal - commissionAmount + shippingCost
const order = await OrderModel.create({
buyer: user._id,
shop: shop._id,
listing: listing._id,
variantId: variant._id,
variantSnapshot: {
color: variant.color,
size: variant.size,
weight: variant.weight,
price: unitPrice
},
quantity: qty,
unit_price: unitPrice,
shipping_method: shippingMethod || null,
shipping_cost: shippingCost,
commission_amount: commissionAmount,
seller_net_amount: sellerNetAmount,
total_amount: totalAmount,
status: 'pending_payment',
buyerAddressSnapshot: {
first_name: user.first_name,
last_name: user.last_name,
mobile: user.mobile,
address: user.address,
province: user.province,
city: user.city,
lat: user.lat,
lng: user.lng
}
})
order.orderNumber = String(order._id).slice(-8).toUpperCase()
await order.save()
return res.json({ message: 'سفارش ایجاد شد', order: serializeOrder(order) })
} catch (error) {
next(error)
}
}
const getOrderById = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id).populate('listing shop')
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
const isBuyer = String(order.buyer) === String(req.user._id)
const isSeller = order.shop && String(order.shop.owner) === String(req.user._id)
if (!isBuyer && !isSeller) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
const [report, returnRequest, shopRating] = await Promise.all([
OrderReportModel.findOne({ order: order._id }).sort({ createdAt: -1 }),
ReturnRequestModel.findOne({ order: order._id }).sort({ createdAt: -1 }),
ShopRatingModel.findOne({ order: order._id })
])
return res.json({
order: serializeOrder(order),
report,
returnRequest,
shopRating,
isSeller,
isBuyer
})
} catch (error) {
next(error)
}
}
const TRACKING_REQUIRED_METHODS = ['post', 'tipax', 'chapar', 'intercity_freight']
const SELLER_SETTABLE_STATUSES = ['on_hold', 'processing', 'shipped', 'ready_for_pickup']
const getOrders = async (req, res, next) => {
try {
const { role, shopId, q, status, startDate, endDate } = req.query
const filter = {}
if (role === 'seller') {
if (shopId) {
const shop = await ShopModel.findById(shopId)
if (!shop || String(shop.owner) !== String(req.user._id)) {
return orderError(res, 403, 'شما مالک این فروشگاه نیستید')
}
filter.shop = shopId
} else {
// No specific shop requested — aggregate orders across every shop this user owns.
const ownedShopIds = await ShopModel.find({ owner: req.user._id }).distinct('_id')
if (!ownedShopIds.length) {
return res.json({
docs: [],
totalDocs: 0,
totalPages: 0,
page: Number(req.query.page) || 1,
limit: Number(req.query.limit) || 20
})
}
filter.shop = { $in: ownedShopIds }
}
} else {
filter.buyer = req.user._id
if (shopId) filter.shop = shopId
}
if (q) {
const escaped = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
filter.orderNumber = { $regex: escaped, $options: 'i' }
}
if (status) {
const statuses = String(status).split(',').map((s) => s.trim()).filter(Boolean)
if (statuses.length) filter.status = statuses.length > 1 ? { $in: statuses } : statuses[0]
}
if (startDate || endDate) {
filter.createdAt = {}
if (startDate) filter.createdAt.$gte = new Date(startDate)
if (endDate) filter.createdAt.$lte = new Date(endDate)
}
const orders = await OrderModel.paginate(filter, {
sort: { createdAt: -1 },
limit: Number(req.query.limit) || 20,
page: Number(req.query.page) || 1,
populate: ['listing', 'shop']
})
orders.docs = orders.docs.map(serializeOrder)
return res.json(orders)
} catch (error) {
next(error)
}
}
const updateOrderStatus = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id).populate('shop')
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
if (!order.shop || String(order.shop.owner) !== String(req.user._id)) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
const { status } = req.body
if (!SELLER_SETTABLE_STATUSES.includes(status)) {
return orderError(res, 422, 'وضعیت انتخاب‌شده مجاز نیست')
}
if (
status === 'shipped' &&
TRACKING_REQUIRED_METHODS.includes(order.shipping_method) &&
!order.tracking_code
) {
return orderError(res, 422, 'برای این روش ارسال، ثبت کد رهگیری الزامی است')
}
order.status = status
if (status === 'shipped' || status === 'ready_for_pickup') {
order.fulfilledAt = new Date()
order.receiptDeadlineAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
await order.save()
if (status === 'shipped' || status === 'ready_for_pickup') {
await notifyUser(req.app.get('io'), order.buyer, {
project_post_id: order._id,
type: 'shop_order',
title: status === 'shipped' ? 'سفارش ارسال شد' : 'سفارش آماده تحویل است',
description:
status === 'shipped'
? 'سفارش شما توسط فروشنده ارسال شد.'
: 'سفارش شما آماده تحویل است.'
})
}
return res.json({ message: 'وضعیت سفارش به‌روزرسانی شد', order })
} catch (error) {
next(error)
}
}
const updateTrackingCode = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id).populate('shop')
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
if (!order.shop || String(order.shop.owner) !== String(req.user._id)) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
const { tracking_code } = req.body
if (!tracking_code) {
return orderError(res, 422, 'کد رهگیری الزامی است')
}
order.tracking_code = tracking_code
await order.save()
return res.json({ message: 'کد رهگیری ثبت شد', order })
} catch (error) {
next(error)
}
}
/**
* Releases the seller's wallet funds for a fulfilled order. Called from both the
* buyer's manual "دریافت کردم" tap and the 7-day auto-release cron — whichever
* fires first wins; guarded by `walletReleased` so it only ever runs once.
*/
async function releaseOrderFunds (order, trigger, io) {
if (order.walletReleased) return order
order.status = 'completed'
order.completedAt = new Date()
if (trigger === 'buyer_confirmed') {
order.buyerConfirmedReceiptAt = new Date()
}
order.returnWindowExpiresAt = new Date(
order.completedAt.getTime() + RETURN_WINDOW_DAYS * 24 * 60 * 60 * 1000
)
order.walletReleased = true
await order.save()
const shop = await ShopModel.findById(order.shop)
if (shop) {
await WalletTransactionModel.create({
user: shop.owner,
bucket: 'shop',
shop: shop._id,
type: 'credit',
amount: order.seller_net_amount,
referenceType: 'order',
referenceId: order._id,
description: `درآمد فروش سفارش ${order._id}`
})
await notifyUser(io, shop.owner, {
project_post_id: order._id,
type: 'shop_order',
title: 'واریز وجه سفارش',
description: `مبلغ ${order.seller_net_amount.toLocaleString()} تومان بابت سفارش تکمیل‌شده به کیف پول شما واریز شد.`
})
}
return order
}
const confirmReceipt = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id)
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
if (String(order.buyer) !== String(req.user._id)) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
if (!['shipped', 'ready_for_pickup'].includes(order.status)) {
return orderError(res, 400, 'این سفارش هنوز ارسال نشده است')
}
await releaseOrderFunds(order, 'buyer_confirmed', req.app.get('io'))
return res.json({ message: 'دریافت کالا ثبت شد', order })
} catch (error) {
next(error)
}
}
/**
* لغو سفارش توسط خریدار — فقط تا وقتی وضعیت "on_hold" (در انتظار بررسی) باشه.
* پرداخت مستقیم از درگاه زرین‌پال میاد، نه از کیف‌پول (initiateOrderPayment) — و
* releaseOrderFunds فقط موقع completed شدنِ سفارش کیف‌پولِ فروشنده رو شارژ می‌کنه، پس
* توی وضعیتِ on_hold هنوز هیچ مبلغی به فروشنده واریز نشده (walletReleased=false) —
* چیزی برای «کسر از فروشنده» وجود نداره. کلِ مبلغ (سهمِ فروشنده + کارمزد، یعنی
* total_amount) مستقیم به کیف‌پولِ خریدار (نه بازگشت به کارت/درگاه) واریز می‌شه —
* دقیقاً همون چیزی که کاربر خواسته: «مبلغ به کیف پول شما منتقل خواهد شد»
*/
const cancelOrder = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id)
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
if (String(order.buyer) !== String(req.user._id)) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
if (order.status !== 'on_hold') {
return orderError(res, 400, 'این سفارش در وضعیتی نیست که قابل کنسل کردن باشد')
}
order.status = 'cancelled'
order.cancelReason = 'لغو توسط خریدار'
await order.save()
await WalletTransactionModel.create({
user: order.buyer,
bucket: 'shop',
shop: order.shop,
type: 'credit',
amount: order.total_amount,
referenceType: 'order',
referenceId: order._id,
description: `بازگشت وجه سفارش کنسل‌شده ${order._id} به کیف پول`
})
await notifyUser(req.app.get('io'), order.buyer, {
project_post_id: order._id,
type: 'shop_order',
title: 'کنسل شدن سفارش',
description: `سفارش شما کنسل شد و مبلغ ${order.total_amount.toLocaleString()} تومان به کیف پول شما واریز شد.`
})
const shop = await ShopModel.findById(order.shop)
if (shop) {
await notifyUser(req.app.get('io'), shop.owner, {
project_post_id: order._id,
type: 'shop_order',
title: 'کنسل شدن سفارش',
description: `سفارش ${order._id} توسط خریدار کنسل شد.`
})
}
return res.json({ message: 'سفارش کنسل شد', order: serializeOrder(order) })
} catch (error) {
next(error)
}
}
const rateOrder = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id)
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
if (String(order.buyer) !== String(req.user._id)) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
const { rating, comment } = req.body
const score = Number(rating)
if (!Number.isFinite(score) || score < 1 || score > 5) {
return orderError(res, 422, 'امتیاز باید بین ۱ تا ۵ باشد')
}
const shopRating = await ShopRatingModel.create({
shop: order.shop,
order: order._id,
userId: req.user._id,
rating: score,
comment: comment || null
})
return res.json({ message: 'نظر شما ثبت شد', shopRating })
} catch (error) {
next(error)
}
}
const reportOrder = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id)
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
if (String(order.buyer) !== String(req.user._id)) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
const { description } = req.body
if (!description) {
return orderError(res, 422, 'توضیح مشکل الزامی است')
}
const report = await OrderReportModel.create({
order: order._id,
buyer: req.user._id,
description
})
const shop = await ShopModel.findById(order.shop)
if (shop) {
await notifyUser(req.app.get('io'), shop.owner, {
project_post_id: order._id,
type: 'shop_order',
title: 'گزارش مشکل سفارش',
description: `خریدار برای سفارش ${order._id} مشکلی گزارش کرده است.`
})
}
return res.json({ message: 'مشکل با موفقیت ثبت شد', report })
} catch (error) {
next(error)
}
}
const createReturnRequest = async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.id)
if (!order) return orderError(res, 404, 'سفارش یافت نشد')
if (String(order.buyer) !== String(req.user._id)) {
return orderError(res, 403, 'دسترسی به این سفارش ندارید')
}
if (!order.returnWindowExpiresAt || new Date() > order.returnWindowExpiresAt) {
return orderError(res, 400, 'مهلت درخواست مرجوعی این سفارش به پایان رسیده است')
}
const { reason } = req.body
if (!reason) {
return orderError(res, 422, 'دلیل مرجوعی الزامی است')
}
const returnRequest = await ReturnRequestModel.create({
order: order._id,
buyer: req.user._id,
reason
})
const shop = await ShopModel.findById(order.shop)
if (shop) {
await notifyUser(req.app.get('io'), shop.owner, {
project_post_id: order._id,
type: 'shop_order',
title: 'درخواست مرجوعی کالا',
description: `خریدار برای سفارش ${order._id} درخواست مرجوعی ثبت کرده است.`
})
}
return res.json({ message: 'درخواست مرجوعی ثبت شد', returnRequest })
} catch (error) {
next(error)
}
}
module.exports = {
createOrderDraft,
getOrders,
updateOrderStatus,
updateTrackingCode,
getOrderById,
confirmReceipt,
cancelOrder,
rateOrder,
reportOrder,
createReturnRequest,
releaseOrderFunds,
COMMISSION_RATE,
PAYMENT_TIMEOUT_MINUTES,
RETURN_WINDOW_DAYS
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +0,0 @@
const axios = require('axios')
const OrderModel = require('../../../models/OrderModel')
const PaymentModel = require('../../../models/PaymentModel')
const ShopModel = require('../../../models/ShopModel')
const { notifyUser } = require('../../../utils/notifyUser')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
/** Toman (stored) -> Rial (ZarinPal API v4 unit) */
function toRial (tomanAmount) {
return Number(tomanAmount) * 10
}
const initiateOrderPayment = async (req, res) => {
try {
const { orderId, useWallet, platform } = req.body
if (!orderId) {
return res.status(422).json({ error: true, message: 'سفارش مشخص نشده است' })
}
const order = await OrderModel.findById(orderId)
if (!order) {
return res.status(404).json({ error: true, message: 'سفارش یافت نشد' })
}
if (String(order.buyer) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'دسترسی به این سفارش ندارید' })
}
if (order.status !== 'pending_payment') {
return res.status(400).json({ error: true, message: 'این سفارش قابل پرداخت نیست' })
}
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(
req.user._id,
order.total_amount,
useWallet
)
// کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست
if (fullyCovered) {
await debitWalletForPurchase(req.user._id, walletAmount, {
referenceType: 'order',
referenceId: order._id,
description: `پرداخت سفارش شماره ${order._id} از کیف پول`
})
order.walletAmountUsed = walletAmount
order.status = 'processing'
await order.save()
await PaymentModel.create({
amount: order.total_amount,
status: 'successful',
authority: null,
user_id: order.buyer,
order_id: order._id,
type: 'shop_order'
})
const shop = await ShopModel.findById(order.shop)
if (shop) {
await notifyUser(req.app.get('io'), shop.owner, {
project_post_id: order._id,
type: 'shop_order',
title: 'سفارش جدید',
description: `یک سفارش جدید برای فروشگاه ${shop.name} ثبت و پرداخت شد.`
})
}
return res.json({ paidFromWallet: true })
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(gatewayAmount),
description: `پرداخت سفارش شماره ${order._id}`,
callback_url: `${callbackBase}/payment/shop-order/callback?orderId=${order._id}${platformSuffix}`
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
order.paymentAuthority = result.authority
// فقط «قصدِ» استفاده از این مقدار کیف پول رو یادداشت می‌کنه — کسرِ واقعی توی
// کال‌بکِ موفقیت انجام می‌شه (نگاه کن به بالای فایل: utils/walletSplit.js)
order.walletAmountUsed = walletAmount
await order.save()
return res.json({ authority: result.authority })
}
console.error('ZarinPal initiate error:', response.data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
console.error('initiateOrderPayment error:', error?.response?.data || error.message)
return res.status(500).json({ error: true, message: 'خطا در شروع پرداخت' })
}
}
const handleOrderPaymentCallback = async (req, res) => {
const { orderId, Authority, Status, platform } = req.query
// اندروید: نتیجه با دیپ‌لینکِ اپ (modstagram://) برگردونده می‌شه، نه یه صفحه‌ی وب —
// عینِ همه‌ی مسیرهای پرداختِ دیگه (آفر/بیلبورد/پروژه). قبلاً این مسیر فقط به وب
// ریدایرکت می‌کرد، یعنی اپ هیچ‌وقت نمی‌فهمید پرداخت واقعاً موفق شده یا نه
const isAndroid = platform === 'android'
const failRedirect = isAndroid
? `modstagram://FailedPay?type=shop&orderId=${orderId || ''}`
: `${process.env.APP_SITE}/shops/payment/failed?orderId=${orderId || ''}`
const successRedirect = (id) =>
isAndroid
? `modstagram://SuccessPay?type=shop&orderId=${id}`
: `${process.env.APP_SITE}/shops/payment/success?orderId=${id}`
const io = req.app.get('io')
try {
const order = await OrderModel.findById(orderId)
if (!order) return res.redirect(failRedirect)
// سفارش ممکن است در فاصله رفتن به درگاه و بازگشت، توسط کرون لغو شده باشد
// (مثلاً عبور از مهلت ۶۰ دقیقه‌ای) — از فعال کردن دوباره‌ی یک سفارش لغوشده جلوگیری می‌شود
if (order.status !== 'pending_payment') {
return res.redirect(failRedirect)
}
// این مقدار توی initiateOrderPayment فقط «یادداشت» شده بود (نگاه کن به utils/
// walletSplit.js) — کیف پول تا همین لحظه دست‌نخورده مونده، پس اگه پرداختِ درگاه
// ناموفق/رهاشده باشه، چیزی برای برگردوندن وجود نداره
const walletAmount = order.walletAmountUsed || 0
const gatewayAmount = order.total_amount - walletAmount
if (Status !== 'OK') {
order.status = 'failed'
order.walletAmountUsed = 0
await order.save()
await PaymentModel.create({
amount: order.total_amount,
status: 'failed',
authority: Authority,
user_id: order.buyer,
order_id: order._id,
type: 'shop_order'
})
await notifyUser(io, order.buyer, {
project_post_id: order._id,
type: 'shop_order',
title: 'پرداخت ناموفق',
description: 'پرداخت سفارش شما ناموفق بود. می‌توانید دوباره تلاش کنید.'
})
return res.redirect(failRedirect)
}
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
const data = verify.data?.data
if (data?.code === 100 || data?.code === 101) {
// درگاه فقط بخشِ باقی‌مونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ
// کیف پول هم واقعاً کسر می‌شه
await debitWalletForPurchase(order.buyer, walletAmount, {
referenceType: 'order',
referenceId: order._id,
description: `پرداخت سفارش شماره ${order._id} از کیف پول`
})
await PaymentModel.create({
amount: gatewayAmount,
status: 'successful',
authority: Authority,
user_id: order.buyer,
order_id: order._id,
type: 'shop_order'
})
order.status = 'processing'
await order.save()
const shop = await ShopModel.findById(order.shop)
if (shop) {
await notifyUser(io, shop.owner, {
project_post_id: order._id,
type: 'shop_order',
title: 'سفارش جدید',
description: `یک سفارش جدید برای فروشگاه ${shop.name} ثبت و پرداخت شد.`
})
}
return res.redirect(successRedirect(order._id))
}
order.status = 'failed'
order.walletAmountUsed = 0
await order.save()
await PaymentModel.create({
amount: order.total_amount,
status: 'failed',
authority: Authority,
user_id: order.buyer,
order_id: order._id,
type: 'shop_order'
})
await notifyUser(io, order.buyer, {
project_post_id: order._id,
type: 'shop_order',
title: 'پرداخت ناموفق',
description: 'پرداخت سفارش شما ناموفق بود. می‌توانید دوباره تلاش کنید.'
})
return res.redirect(failRedirect)
} catch (error) {
console.error('handleOrderPaymentCallback error:', error?.response?.data || error.message)
return res.redirect(failRedirect)
}
}
module.exports = {
initiateOrderPayment,
handleOrderPaymentCallback
}

View File

@@ -1,494 +0,0 @@
/* eslint-disable camelcase */
const jwt = require('jsonwebtoken')
const mongoose = require('mongoose')
const PostModel = require('../../../models/PostModel')
const UserModel = require('../../../models/UserModel')
const PostBookmarkModel = require('../../../models/PostBookmarkModel')
const PostSendModel = require('../../../models/PostSendModel')
const PostShareModel = require('../../../models/PostShareModel')
const UserFollowModel = require('../../../models/UserFollowModel')
const MessageModel = require('../../../models/MessageModel')
const LikeModel = require('../../../models/LikeModel')
const CommentModel = require('../../../models/CommentModel')
const { postStatusFilter, commentStatusFilter } = require('../../../utils/moderationStatus')
const { isPostPubliclyVisible } = require('../../../utils/postModeration')
const {
attachPostEngagementFields,
} = require('../../../utils/postEngagement')
const {
enrichMessage,
emitChatEvents,
} = require('../../../helpers/chatHelpers')
const { viewerIsBlockedBy } = require('../../../utils/blockVisibility')
const { isHiddenFromPublic } = require('../../../utils/accountLifecycle')
function getUserIdFromRequest(req) {
const token = req.header('Authorization')?.split(' ')[1]
if (!token) return null
try {
const decoded = jwt.verify(token, process.env.APP_SECRET)
return decoded.id
} catch {
return null
}
}
async function mapBookmarkPosts(posts, viewerId) {
// باید همیشه آرایه برگردونه (نه یک آبجکت‌ pagination-shape) — وگرنه getBookmarks که
// بدون چک posts.length صداش می‌زنه، این آبجکت رو مستقیم توی فیلد posts پاسخ می‌ذاره و
// .map() سمت اپ روی یک object نه array کرش می‌کنه (دقیقاً باگ کرش تب ذخیره‌شده‌ها)
if (!posts.length) {
return []
}
const userIds = [...new Set(posts.map((p) => String(p.user_id)))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image blocked_users verify_badge'
)
.lean()
const decodedToken = viewerId ? { id: viewerId } : null
let mapped = posts.map((post) => {
const user =
users.find((u) => u._id.toString() === post.user_id.toString()) || {}
return {
...post,
user_name: user.user_name || '',
first_name: user.first_name || '',
last_name: user.last_name || '',
expertise: user.expertise || '',
sub_expertise: user.sub_expertise || [],
province: user.show_location ? user.province || {} : {},
city: user.show_location ? user.city || {} : {},
show_location: user.show_location,
user_level: user.user_level || '',
profile_image: user.profile_image || '',
verify_badge: user.verify_badge || 'none',
likesCount: post.likes ? post.likes.length : 0,
is_liked: false,
commentsCount: 0,
}
})
if (viewerId) {
const likes = await LikeModel.find({
userId: viewerId,
postId: { $in: posts.map((p) => p._id) },
}).lean()
const likesMap = {}
likes.forEach((l) => {
likesMap[l.postId.toString()] = true
})
const comments = await CommentModel.aggregate([
{
$match: {
post: { $in: posts.map((p) => p._id) },
status: commentStatusFilter(),
},
},
{ $group: { _id: '$post', count: { $sum: 1 } } },
])
const commentsMap = {}
comments.forEach((c) => {
commentsMap[c._id.toString()] = c.count
})
mapped = mapped.map((post) => ({
...post,
is_liked: !!likesMap[post._id.toString()],
commentsCount: commentsMap[post._id.toString()] || 0,
}))
}
mapped = await attachPostEngagementFields(mapped, viewerId)
return mapped
}
const getMyLikedPosts = async (req, res, next) => {
try {
const userId = getUserIdFromRequest(req)
if (!userId) {
return res.status(401).json({ message: 'توکن ارائه نشده است' })
}
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 15))
const skip = (page - 1) * limit
const likes = await LikeModel.find({ userId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean()
const totalItems = await LikeModel.countDocuments({ userId })
const postIds = likes.map((l) => l.postId)
const posts = postIds.length
? await PostModel.find({
_id: { $in: postIds },
status: postStatusFilter(),
is_archived: { $ne: true },
}).lean()
: []
const orderMap = new Map(postIds.map((id, idx) => [String(id), idx]))
posts.sort(
(a, b) =>
(orderMap.get(String(a._id)) ?? 0) - (orderMap.get(String(b._id)) ?? 0)
)
const enriched = posts.length ? await mapBookmarkPosts(posts, userId) : []
return res.status(200).json({
posts: enriched,
postsCount: enriched.length,
totalPages: Math.ceil(totalItems / limit) || 0,
totalItems,
})
} catch (error) {
next(error)
}
}
const getMyCommentedPosts = async (req, res, next) => {
try {
const userId = getUserIdFromRequest(req)
if (!userId) {
return res.status(401).json({ message: 'توکن ارائه نشده است' })
}
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 15))
const skip = (page - 1) * limit
const matchStage = {
creator: new mongoose.Types.ObjectId(userId),
comment_for: 'post',
post: { $ne: null },
status: commentStatusFilter(),
}
const commentAgg = await CommentModel.aggregate([
{ $match: matchStage },
{ $sort: { createdAt: -1 } },
{ $group: { _id: '$post', lastCommentedAt: { $first: '$createdAt' } } },
{ $sort: { lastCommentedAt: -1 } },
{ $skip: skip },
{ $limit: limit },
])
const totalItemsAgg = await CommentModel.aggregate([
{ $match: matchStage },
{ $group: { _id: '$post' } },
{ $count: 'total' },
])
const totalItems = totalItemsAgg[0]?.total || 0
const postIds = commentAgg.map((c) => c._id)
const posts = postIds.length
? await PostModel.find({
_id: { $in: postIds },
status: postStatusFilter(),
is_archived: { $ne: true },
}).lean()
: []
const orderMap = new Map(postIds.map((id, idx) => [String(id), idx]))
posts.sort(
(a, b) =>
(orderMap.get(String(a._id)) ?? 0) - (orderMap.get(String(b._id)) ?? 0)
)
const enriched = posts.length ? await mapBookmarkPosts(posts, userId) : []
return res.status(200).json({
posts: enriched,
postsCount: enriched.length,
totalPages: Math.ceil(totalItems / limit) || 0,
totalItems,
})
} catch (error) {
next(error)
}
}
const toggleBookmark = async (req, res, next) => {
try {
const userId = getUserIdFromRequest(req)
if (!userId) {
return res.status(401).json({ message: 'توکن ارائه نشده است' })
}
const { postId } = req.body
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ message: 'شناسه پست الزامی است' })
}
const post = await PostModel.findById(postId)
if (!post || !isPostPubliclyVisible(post)) {
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
}
const author = await UserModel.findById(post.user_id)
.select('allow_save_posts account_status')
.lean()
if (author && isHiddenFromPublic(author)) {
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
}
if (author && author.allow_save_posts === false) {
return res.status(403).json({ message: 'صاحب پست اجازه ذخیره نداده است' })
}
const existing = await PostBookmarkModel.findOne({ postId, userId })
if (existing) {
await existing.deleteOne()
const savesCount = await PostBookmarkModel.countDocuments({ postId })
return res.status(200).json({
message: 'پست از علاقمندی‌ها حذف شد',
is_saved: false,
savesCount,
})
}
await PostBookmarkModel.create({ postId, userId })
const savesCount = await PostBookmarkModel.countDocuments({ postId })
return res.status(200).json({
message: 'پست ذخیره شد',
is_saved: true,
savesCount,
})
} catch (error) {
console.error('toggleBookmark:', error)
next(error)
}
}
const getBookmarks = async (req, res, next) => {
try {
const userId = getUserIdFromRequest(req)
if (!userId) {
return res.status(401).json({ message: 'توکن ارائه نشده است' })
}
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 15))
const skip = (page - 1) * limit
const bookmarks = await PostBookmarkModel.find({ userId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean()
const totalItems = await PostBookmarkModel.countDocuments({ userId })
const postIds = bookmarks.map((b) => b.postId)
const posts = postIds.length
? await PostModel.find({ _id: { $in: postIds }, status: postStatusFilter(), is_archived: { $ne: true } }).lean()
: []
const orderMap = new Map(postIds.map((id, idx) => [String(id), idx]))
posts.sort(
(a, b) =>
(orderMap.get(String(a._id)) ?? 0) - (orderMap.get(String(b._id)) ?? 0)
)
const enriched = await mapBookmarkPosts(posts, userId)
return res.status(200).json({
posts: enriched,
postsCount: enriched.length,
totalPages: Math.ceil(totalItems / limit) || 0,
totalItems,
})
} catch (error) {
console.error('getBookmarks:', error)
next(error)
}
}
const sendPostToUsers = async (req, res, next) => {
try {
const senderId = getUserIdFromRequest(req)
if (!senderId) {
return res.status(401).json({ message: 'توکن ارائه نشده است' })
}
const { postId, receiverIds } = req.body
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ message: 'شناسه پست الزامی است' })
}
if (!Array.isArray(receiverIds) || !receiverIds.length) {
return res.status(400).json({ message: 'حداقل یک گیرنده انتخاب کنید' })
}
const uniqueReceivers = [
...new Set(
receiverIds
.map(String)
.filter((id) => mongoose.Types.ObjectId.isValid(id) && id !== String(senderId))
),
].slice(0, 50)
if (!uniqueReceivers.length) {
return res.status(400).json({ message: 'گیرنده معتبر یافت نشد' })
}
const post = await PostModel.findById(postId).lean()
if (!post || !isPostPubliclyVisible(post)) {
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
}
const author = await UserModel.findById(post.user_id)
.select('user_name first_name last_name blocked_users')
.lean()
if (!author) {
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
}
const preview = post.files?.[0]?.path || ''
const sharedPost = {
postId: String(post._id),
caption: (post.caption || '').slice(0, 200),
type: post.type || 'image',
preview,
authorId: String(post.user_id),
authorUserName: author.user_name || '',
authorDisplayName:
`${author.first_name || ''} ${author.last_name || ''}`.trim() ||
author.user_name ||
'کاربر',
url: `https://modstagram.com/posts/${post._id}`,
}
const content = JSON.stringify({ sharedPost })
const io = req.app.get('io')
let sentCount = 0
for (const receiverId of uniqueReceivers) {
const receiver = await UserModel.findById(receiverId)
.select('blocked_users')
.lean()
if (!receiver) continue
if (viewerIsBlockedBy(receiver, senderId)) continue
if (viewerIsBlockedBy(author, senderId)) continue
const newMessage = new MessageModel({
senderId,
receiverId,
content,
})
await newMessage.save()
await PostSendModel.create({ postId, senderId, receiverId })
if (io) {
const formatted = await enrichMessage(newMessage)
// برخلافِ پیامِ متنیِ معمولی، این پیام رو خودِ سرور از رویِ دیتای واقعیِ پست ساخته
// (نه متنِ رمزنگاری‌شدهٔ کاربر)، پس اینجا محدودیتِ E2EE برای پوش صدق نمی‌کنه —
// می‌تونیم دقیقاً بگیم ریلز بوده یا پست
emitChatEvents(io, formatted, senderId, receiverId, {
type: post.type === 'video' ? 'reel_shared' : 'post_shared',
description: post.type === 'video' ? 'یک ریلز برای شما ارسال کرد' : 'یک پست برای شما ارسال کرد'
})
}
sentCount += 1
}
if (!sentCount) {
return res.status(400).json({ message: 'ارسال به هیچ گیرنده‌ای انجام نشد' })
}
const sendsCount = await PostSendModel.countDocuments({ postId })
return res.status(200).json({
message: `پست به ${sentCount} نفر ارسال شد`,
sendsCount,
sentCount,
})
} catch (error) {
console.error('sendPostToUsers:', error)
next(error)
}
}
const toggleFollow = async (req, res, next) => {
try {
const followerId = getUserIdFromRequest(req)
if (!followerId) {
return res.status(401).json({ message: 'توکن ارائه نشده است' })
}
const { userId: followingId } = req.body
if (!followingId || !mongoose.Types.ObjectId.isValid(followingId)) {
return res.status(400).json({ message: 'شناسه کاربر الزامی است' })
}
if (String(followerId) === String(followingId)) {
return res.status(400).json({ message: 'نمی‌توانید خودتان را فالو کنید' })
}
const target = await UserModel.findById(followingId).select('_id').lean()
if (!target) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
const existing = await UserFollowModel.findOne({ followerId, followingId })
if (existing) {
await existing.deleteOne()
return res.status(200).json({
message: 'آنفالو شد',
is_following: false,
})
}
await UserFollowModel.create({ followerId, followingId })
return res.status(200).json({
message: 'فالو شد',
is_following: true,
})
} catch (error) {
console.error('toggleFollow:', error)
next(error)
}
}
const sharePost = async (req, res, next) => {
try {
const userId = getUserIdFromRequest(req)
if (!userId) {
return res.status(401).json({ message: 'توکن ارائه نشده است' })
}
const { postId } = req.body
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ message: 'شناسه پست الزامی است' })
}
await PostShareModel.create({ postId, userId })
const sharesCount = await PostShareModel.countDocuments({ postId })
return res.status(200).json({
message: 'اشتراک‌گذاری ثبت شد',
sharesCount,
})
} catch (error) {
console.error('sharePost:', error)
next(error)
}
}
module.exports = {
toggleBookmark,
getBookmarks,
sendPostToUsers,
toggleFollow,
getMyLikedPosts,
getMyCommentedPosts,
sharePost,
}

View File

@@ -1,90 +1,64 @@
/* eslint-disable camelcase */
const PostModel = require('../../../models/PostModel');
const jwt = require('jsonwebtoken');
const UserModel = require('../../../models/UserModel');
const LikeModel = require('../../../models/LikeModel');
const { createLikeNotification } = require('../../../utils/likeNotification');
const toggleLike = async (req, res, next) => {
try {
const { postId } = req.body;
const token = req.header('Authorization')?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'توکن ارائه نشده است' });
}
let userId;
try {
const decodedToken = jwt.verify(token, process.env.APP_SECRET);
userId = decodedToken.id;
} catch (err) {
return res.status(401).json({ message: 'توکن نامعتبر است' });
}
if (!postId) {
return res.status(400).json({ message: 'شناسه پست الزامی است' });
}
const post = await PostModel.findById(postId).select('likes user_id');
if (!post) {
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' });
}
const isLiked = post.likes.some((id) => String(id) === String(userId));
if (isLiked) {
// unlike — عین $pull به‌جای read-modify-write (post.likes.pull + save)، تا زیرِ
// درخواست‌های هم‌زمان (دبل‌تپ) نه VersionError بده نه آپدیت گم بشه
const updated = await PostModel.findOneAndUpdate(
{ _id: postId, likes: userId },
{ $pull: { likes: userId } },
{ new: true },
).select('likes');
await LikeModel.deleteMany({ postId, userId });
const likesCount = updated?.likes.length ?? post.likes.length - 1;
return res.status(200).json({
message: 'لایک با موفقیت حذف شد',
is_liked: false,
likesCount,
});
} else {
// like — $addToSet خودش idempotent و atomic-ه (اگه یه درخواستِ موازیِ دیگه
// زودتر همین کاربر رو اضافه کرده باشه، اینجا فقط چیزی عوض نمی‌شه، خطا نمی‌ده)
const updated = await PostModel.findOneAndUpdate(
{ _id: postId },
{ $addToSet: { likes: userId } },
{ new: true },
).select('likes user_id');
// upsert به‌جای create — چون LikeModel روی {postId,userId} ایندکسِ unique نداره
// (دیتای فعلیِ production تکراری داره)، ولی این همچنان جلوی ایجادِ تکراریِ تازه
// زیرِ همین درخواست رو می‌گیره
await LikeModel.updateOne(
{ postId, userId },
{ $setOnInsert: { postId, userId } },
{ upsert: true },
);
await createLikeNotification({
io: req.app.get('io'),
ownerId: updated?.user_id ?? post.user_id,
likerId: userId,
entityId: post._id,
type: 'post_like',
});
const likesCount = updated?.likes.length ?? post.likes.length + 1;
return res.status(200).json({
message: 'پست با موفقیت لایک شد',
is_liked: true,
likesCount,
});
}
} catch (error) {
console.error('خطا در toggleLike:', error);
res.status(500).json({ message: 'خطا در سرور' });
next(error);
}
};
/* eslint-disable camelcase */
const PostModel = require('../../../models/PostModel');
const jwt = require('jsonwebtoken');
const UserModel = require('../../../models/UserModel');
const LikeModel = require('../../../models/LikeModel');
const toggleLike = async (req, res, next) => {
try {
const { postId } = req.body;
const token = req.header('Authorization')?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'توکن ارائه نشده است' });
}
let userId;
try {
const decodedToken = jwt.verify(token, process.env.APP_SECRET);
userId = decodedToken.id;
} catch (err) {
return res.status(401).json({ message: 'توکن نامعتبر است' });
}
if (!postId) {
return res.status(400).json({ message: 'شناسه پست الزامی است' });
}
const post = await PostModel.findById(postId);
if (!post) {
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' });
}
const isLiked = post.likes.includes(userId);
if (isLiked) {
// unlike
post.likes.pull(userId);
await post.save();
await LikeModel.findOneAndDelete({ postId, userId });
return res.status(200).json({
message: 'لایک با موفقیت حذف شد',
is_liked: false,
likesCount: post.likes.length,
});
} else {
// like
post.likes.push(userId);
await post.save();
await LikeModel.create({ postId, userId });
return res.status(200).json({
message: 'پست با موفقیت لایک شد',
is_liked: true,
likesCount: post.likes.length,
});
}
} catch (error) {
console.error('خطا در toggleLike:', error);
res.status(500).json({ message: 'خطا در سرور' });
next(error);
}
};
module.exports = { toggleLike };

View File

@@ -1,242 +0,0 @@
/* eslint-disable camelcase */
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const PostModel = require('../../../models/PostModel')
const LikeModel = require('../../../models/LikeModel')
const CommentModel = require('../../../models/CommentModel')
const PostBookmarkModel = require('../../../models/PostBookmarkModel')
const PostSendModel = require('../../../models/PostSendModel')
const UserFollowModel = require('../../../models/UserFollowModel')
const ExploreInteractionModel = require('../../../models/ExploreInteractionModel')
function bucketByName (rows, field) {
const counts = new Map()
for (const row of rows) {
const name = row?.[field]?.name
if (!name) continue
counts.set(name, (counts.get(name) || 0) + 1)
}
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
}
const getPostAnalytics = async (req, res, next) => {
try {
const authHeader = req.header('Authorization')
const token = authHeader?.split(' ')[1]
let userId = null
if (token) {
try {
userId = jwt.verify(token, process.env.APP_SECRET).id
} catch {
userId = null
}
}
if (!userId) {
return res.status(401).json({ message: 'دسترسی غیرمجاز' })
}
const { postId } = req.params
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ message: 'شناسه پست نامعتبر است' })
}
const post = await PostModel.findById(postId).lean()
if (!post || String(post.user_id) !== String(userId)) {
return res.status(403).json({ message: 'دسترسی به آمار این پست مجاز نیست' })
}
const postObjectId = post._id
const [
viewsCount,
sharesCount,
offersCount,
likesCount,
commentsCount,
savesCount,
sendsCount,
viewRecords,
dwellRecords,
] = await Promise.all([
ExploreInteractionModel.countDocuments({
targetType: 'post',
targetId: postObjectId,
action: 'view',
}),
ExploreInteractionModel.countDocuments({
targetType: 'post',
targetId: postObjectId,
action: 'share',
}),
ExploreInteractionModel.countDocuments({
targetType: 'post',
targetId: postObjectId,
action: 'offer',
}),
LikeModel.countDocuments({ postId: postObjectId }),
CommentModel.countDocuments({ post: postObjectId, status: 'accepted' }),
PostBookmarkModel.countDocuments({ postId: postObjectId }),
PostSendModel.countDocuments({ postId: postObjectId }),
ExploreInteractionModel.find({
targetType: 'post',
targetId: postObjectId,
action: 'view',
})
.select('viewerId viewerCountry viewerProvince viewerCity')
.lean(),
ExploreInteractionModel.find({
targetType: 'post',
targetId: postObjectId,
action: { $in: ['dwell', 'watch_complete'] },
dwellMs: { $ne: null },
})
.select('dwellMs')
.lean(),
])
const viewerIds = [...new Set(viewRecords.map((v) => String(v.viewerId)))]
let followerViews = 0
let nonFollowerViews = viewRecords.length
if (viewerIds.length) {
const followers = await UserFollowModel.find({
followingId: post.user_id,
followerId: { $in: viewerIds },
})
.select('followerId')
.lean()
const followerSet = new Set(followers.map((f) => String(f.followerId)))
followerViews = viewRecords.filter((v) =>
followerSet.has(String(v.viewerId))
).length
nonFollowerViews = viewRecords.length - followerViews
}
const totalWatchMs = dwellRecords.reduce(
(sum, row) => sum + (row.dwellMs || 0),
0
)
const followsFromPost = await ExploreInteractionModel.countDocuments({
targetType: 'profile',
authorId: post.user_id,
action: 'profile_visit',
createdAt: { $gte: post.createdAt },
})
const totalAudienceViews = viewRecords.length || 1
const followerPct = Math.round((followerViews / totalAudienceViews) * 100)
const nonFollowerPct = Math.round(
(nonFollowerViews / totalAudienceViews) * 100
)
// فقط برای بازدیدکننده‌هایی که موقعیت مکانی پروفایلشون رو عمومی کردن — نه موقعیت لحظه‌ای بازدید
const viewsByCountry = bucketByName(viewRecords, 'viewerCountry')
const viewsByProvince = bucketByName(viewRecords, 'viewerProvince')
const viewsByCity = bucketByName(viewRecords, 'viewerCity')
const locatedViewsCount = viewRecords.filter((v) => v.viewerProvince?.name).length
return res.status(200).json({
post: {
_id: post._id,
type: post.type,
files: post.files,
caption: post.caption,
createdAt: post.createdAt,
},
stats: {
likesCount,
commentsCount,
sendsCount,
sharesCount,
savesCount,
followsFromPost,
offersCount,
viewsCount,
totalWatchMs,
followerViews,
nonFollowerViews,
followerPct,
nonFollowerPct,
},
geography: {
locatedViewsCount,
viewsByCountry,
viewsByProvince,
viewsByCity,
},
})
} catch (error) {
console.error('getPostAnalytics:', error)
next(error)
}
}
/** Caller's own posts with a lightweight per-post stat snapshot — backs the posts-analytics hub list. */
const getMyPostsAnalyticsList = async (req, res, next) => {
try {
const userId = req.user._id
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20))
const filter = {
user_id: userId,
status: { $in: ['pending', 'accepted'] },
is_archived: { $ne: true }
}
const [posts, totalItems] = await Promise.all([
PostModel.find(filter)
.select('type files caption createdAt likes')
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean(),
PostModel.countDocuments(filter)
])
const postIds = posts.map((p) => p._id)
const [commentCounts, viewCounts] = postIds.length
? await Promise.all([
CommentModel.aggregate([
{ $match: { post: { $in: postIds }, status: 'accepted' } },
{ $group: { _id: '$post', count: { $sum: 1 } } }
]),
ExploreInteractionModel.aggregate([
{ $match: { targetType: 'post', targetId: { $in: postIds }, action: 'view' } },
{ $group: { _id: '$targetId', count: { $sum: 1 } } }
])
])
: [[], []]
const commentMap = new Map(commentCounts.map((row) => [String(row._id), row.count]))
const viewMap = new Map(viewCounts.map((row) => [String(row._id), row.count]))
const items = posts.map((post) => ({
_id: post._id,
type: post.type,
files: post.files,
caption: post.caption,
createdAt: post.createdAt,
likesCount: post.likes?.length || 0,
commentsCount: commentMap.get(String(post._id)) || 0,
viewsCount: viewMap.get(String(post._id)) || 0
}))
return res.status(200).json({
posts: items,
page,
limit,
totalItems,
hasMore: (page - 1) * limit + items.length < totalItems
})
} catch (error) {
console.error('getMyPostsAnalyticsList:', error)
next(error)
}
}
module.exports = { getPostAnalytics, getMyPostsAnalyticsList }

View File

@@ -1,395 +1,67 @@
/* eslint-disable camelcase */
const PostModel = require('../../../models/PostModel')
const UserModel = require('../../../models/UserModel')
const CourseModel = require('../../../models/CourseModel')
const ProjectModel = require('../../../models/ProjectModel')
const AdvertisingModel = require('../../../models/AdvertisingModel')
const ShopModel = require('../../../models/ShopModel')
const ShopProductListingModel = require('../../../models/ShopProductListingModel')
const LikeModel = require('../../../models/LikeModel')
const CommentModel = require('../../../models/CommentModel')
const PostReportModel = require('../../../models/PostReportModel')
const PostBookmarkModel = require('../../../models/PostBookmarkModel')
const PostShareModel = require('../../../models/PostShareModel')
const PostSendModel = require('../../../models/PostSendModel')
const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const { check, validationResult } = require('express-validator')
const jwt = require('jsonwebtoken')
const path = require('path')
const fs = require('fs-extra')
const { default: mongoose } = require('mongoose')
const { viewerIsBlockedBy } = require('../../../utils/blockVisibility')
const { attachSinglePostEngagement } = require('../../../utils/postEngagement')
const { isHiddenFromPublic } = require('../../../utils/accountLifecycle')
const { viewerCanSeeUserContent } = require('../../../utils/privateProfile')
const { isPostPubliclyVisible } = require('../../../utils/postModeration')
const { postStatusFilter, PUBLIC_POST_STATUSES, commentStatusFilter } = require('../../../utils/moderationStatus')
const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking')
const { resolvePostModerationStatus } = require('../../../utils/contentModeration')
const { savePostWithModerationStatus } = require('../../../utils/postStatusSave')
const { createTagNotifications } = require('../../../utils/tagNotification')
const { notifyFollowers } = require('../../../utils/notifyFollowers')
let processPostVideoInBackground = async () => {}
try {
({ processPostVideoInBackground } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[postController] postVideoProcessing unavailable:', error.message)
}
const { optimizeImageFile, generateImageVariants } = require("../../../utils/imageOptimize")
const PostModel = require('../../../models/PostModel');
const UserModel = require('../../../models/UserModel');
const { check, validationResult } = require('express-validator');
const jwt = require('jsonwebtoken');
const path = require('path');
const { default: mongoose } = require('mongoose');
const createPostValidationRules = () => {
console.log(4);
return [
check('caption')
.optional({ nullable: true })
.isLength({ max: 2000 })
.withMessage('کپشن حداکثر ۲۰۰۰ کاراکتر')
]
}
.notEmpty().withMessage('توضیحات نمی‌تواند خالی باشد'),
];
};
const createPost = async (req, res) => {
try {
console.log('DEBUG: createPost called', {
filesCount: Array.isArray(req.files) ? req.files.length : 0,
hasCaption: Boolean(req.body?.caption),
userId: String(req.user?._id || req.user?.id || '')
})
console.log('DEBUG: createPost called');
console.log('DEBUG: req.files:', req.files);
console.log('DEBUG: req.body:', req.body);
const { caption } = req.body
const files = req.files || []
const userId = req.user?._id || req.user?.id
if (!userId) {
return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' })
}
let taggedUserIds = []
if (req.body.tagged_user_ids) {
const raw = req.body.tagged_user_ids
let parsed = []
try {
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : [])
} catch {
parsed = []
}
taggedUserIds = parsed
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.slice(0, 50)
}
// اعتبارسنجی همه‌ی آیتم‌های قابل‌لینک (پکیج/پروژه/بیلبورد/فروشگاه/کالا/رزرو) از یک تابع
// مشترک — همون شرط مالکیت+وضعیت که قبلاً اینجا پراکنده بود، حالا با استوری هم به اشتراک
// گذاشته می‌شه (utils/resolveLinkedEntity.js)
const linkedCourseId = await resolveLinkedEntity('pkg', req.body.linked_course_id, userId)
const linkedProjectId = await resolveLinkedEntity('project', req.body.linked_project_id, userId)
const linkedBillboardId = await resolveLinkedEntity('billboard', req.body.linked_billboard_id, userId)
const linkedShopId = await resolveLinkedEntity('shop', req.body.linked_shop_id, userId)
const linkedShopProductId = await resolveLinkedEntity('shopproduct', req.body.linked_shop_product_id, userId)
const linkedBookingId = await resolveLinkedEntity('booking', req.body.linked_booking_id, userId)
const { caption } = req.body;
const files = req.files || [];
const userId = req.user.id; // از میدل‌ویر auth
if (!files || files.length === 0) {
console.log('DEBUG: No files provided')
return res.status(400).json({ error: true, message: 'هیچ فایلی آپلود نشده است' })
console.log('DEBUG: No files provided');
return res.status(400).json({ error: true, message: 'هیچ فایلی آپلود نشده است' });
}
const captionText =
caption != null && String(caption).trim()
? maskPhoneNumbers(String(caption).trim().slice(0, 2000))
: null
// کوچک/فشرده‌کردنِ عکس‌ها قبل از ذخیره — قبل از اینکه مسیرشون توی دیتابیس بره، چون
// مسیر (file.path) دست‌نخورده می‌ماند، فقط بایت‌های خودِ فایل جایگزین می‌شوند
await Promise.all(
files
.filter((file) => (file.mimetype || file.type || '').startsWith('image/'))
.map(async (file) => {
await optimizeImageFile(file.path)
await generateImageVariants(file.path)
})
)
if (!caption) {
console.log('DEBUG: No caption provided');
return res.status(400).json({ error: true, message: 'کپشن الزامی است' });
}
const filePaths = files.map((file) => ({
path: file.path,
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image'
}))
type: file.mimetype.startsWith('video/') ? 'video' : 'image',
}));
const postMediaType = filePaths.some((file) => file.type === 'video')
? 'video'
: 'image'
const author = await UserModel.findById(userId)
.select('post_location_enabled lat lng verify_badge')
.lean()
const moderation = resolvePostModerationStatus({ caption: captionText, filePaths, verifyBadge: author?.verify_badge })
if (moderation.status === 'reject') {
return res.status(422).json({
error: true,
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.'
})
}
console.log('DEBUG: Saving post to MongoDB, files:', filePaths.length)
const postPayload = {
console.log('DEBUG: Saving post to MongoDB, filePaths:', filePaths);
const post = new PostModel({
user_id: userId,
caption: captionText,
caption,
files: filePaths,
type: postMediaType,
tagged_users: taggedUserIds,
status: moderation.status
}
type: files[0].mimetype.startsWith('video/') ? 'video' : 'image', // اضافه کردن فیلد type
createdAt: new Date(),
});
if (linkedCourseId) {
postPayload.linked_course_id = linkedCourseId
}
if (linkedProjectId) {
postPayload.linked_project_id = linkedProjectId
}
if (linkedBillboardId) {
postPayload.linked_billboard_id = linkedBillboardId
}
if (linkedShopId) {
postPayload.linked_shop_id = linkedShopId
}
if (linkedShopProductId) {
postPayload.linked_shop_product_id = linkedShopProductId
}
if (linkedBookingId) {
postPayload.linked_booking_id = linkedBookingId
}
if (
author?.post_location_enabled &&
author.lat &&
author.lng &&
String(author.lat).trim() &&
String(author.lng).trim()
) {
postPayload.lat = String(author.lat)
postPayload.lng = String(author.lng)
}
// کشور/شهر/استان پست — اختیاری، جدا از موقعیت پروفایل کاربر؛ کاربر خودش موقع ثبت پست انتخاب می‌کنه
if (req.body.country_id) {
const countryFind = await CountryModel.findOne({ id: req.body.country_id }, { _id: 0 })
if (countryFind) {
postPayload.country = { id: countryFind.id, name: countryFind.name_fa, iso2: countryFind.iso2, slug: countryFind.slug }
}
}
if (req.body.province_id) {
const provinceFind = await ProvinceModel.findOne({ id: req.body.province_id }, { _id: 0 })
if (provinceFind) {
postPayload.province = provinceFind
}
}
if (req.body.city_id) {
const cityFind = await CityModel.findOne({ id: req.body.city_id })
if (cityFind) {
postPayload.city = cityFind
}
}
const post = await savePostWithModerationStatus(PostModel, postPayload)
console.log('DEBUG: Post saved:', post._id)
if (taggedUserIds.length > 0) {
await createTagNotifications({
taggerId: userId,
taggedUserIds,
postId: post._id
})
}
notifyFollowers(userId, post).catch((err) => {
console.error('[notifyFollowers] background error:', err.message)
})
const firstVideo = filePaths.find((file) => file.type === 'video')
if (firstVideo?.path) {
processPostVideoInBackground(firstVideo.path, post._id).catch((err) => {
console.error('[postVideoProcessing] background error:', err.message)
})
}
res.status(201).json({
message: 'پست ثبت شد و در حالت تایید موقت نمایش داده می‌شود',
postId: post._id,
status: post.status
})
await post.save();
console.log('DEBUG: Post saved:', post._id);
res.status(201).json({ message: 'پست با موفقیت ایجاد شد', postId: post._id });
} catch (err) {
console.error('DEBUG: createPost error:', err.message)
if (err?.name === 'ValidationError') {
return res.status(500).json({
error: true,
message: 'خطا در ثبت پست. مدل پست روی سرور به‌روز نیست.'
})
}
res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` })
console.error('DEBUG: createPost error:', err.message);
res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` });
}
}
};
function toPublicFilePath (filePath) {
if (!filePath || typeof filePath !== 'string') return filePath
const s = filePath.replace(/\\/g, '/')
const storageIdx = s.indexOf('/storage/')
if (storageIdx >= 0) {
return s.slice(storageIdx + '/storage'.length)
}
return s
}
const updatePost = async (req, res) => {
try {
const token = req.header('Authorization')?.split(' ')[1]
let userId = null
if (token) {
try {
userId = jwt.verify(token, process.env.APP_SECRET).id
} catch {
userId = null
}
}
if (!userId) {
return res.status(401).json({ error: true, message: 'دسترسی غیرمجاز' })
}
const { postId, caption } = req.body
const files = req.files || []
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ error: true, message: 'شناسه پست نامعتبر است' })
}
const post = await PostModel.findById(postId)
if (!post) {
return res.status(404).json({ error: true, message: 'پست یافت نشد' })
}
if (String(post.user_id) !== String(userId)) {
return res.status(403).json({ error: true, message: 'اجازه ویرایش این پست را ندارید' })
}
let keepFiles = []
if (req.body.keep_files) {
try {
keepFiles = Array.isArray(req.body.keep_files)
? req.body.keep_files
: JSON.parse(req.body.keep_files)
} catch {
keepFiles = []
}
}
const existingByPath = new Map(
(post.files || []).map((f) => [toPublicFilePath(f.path), f])
)
const orderedExisting = []
for (const item of keepFiles) {
const rawPath = typeof item === 'string' ? item : item?.path
if (!rawPath) continue
const key = toPublicFilePath(rawPath)
const found = existingByPath.get(key) ||
(post.files || []).find((f) => toPublicFilePath(f.path) === key || f.path === rawPath)
if (found) {
orderedExisting.push({
path: found.path,
type: found.type === 'video' ? 'video' : 'image'
})
}
}
const newFilePaths = (files || []).map((file) => ({
path: file.path,
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image'
}))
// اگر media_order ارسال شده: existing:index یا new:index
let finalFiles = [...orderedExisting, ...newFilePaths]
if (req.body.media_order) {
let order = []
try {
order = Array.isArray(req.body.media_order)
? req.body.media_order
: JSON.parse(req.body.media_order)
} catch {
order = []
}
if (Array.isArray(order) && order.length) {
const rebuilt = []
for (const token of order) {
const value = String(token || '')
if (value.startsWith('existing:')) {
const idx = Number(value.slice('existing:'.length))
if (orderedExisting[idx]) rebuilt.push(orderedExisting[idx])
} else if (value.startsWith('new:')) {
const idx = Number(value.slice('new:'.length))
if (newFilePaths[idx]) rebuilt.push(newFilePaths[idx])
}
}
if (rebuilt.length) finalFiles = rebuilt
}
}
if (!finalFiles.length) {
return res.status(400).json({ error: true, message: 'حداقل یک فایل برای پست لازم است' })
}
if (caption != null) {
const captionText = String(caption).trim()
? maskPhoneNumbers(String(caption).trim().slice(0, 2000))
: null
const moderation = resolvePostModerationStatus({
caption: captionText,
filePaths: finalFiles
})
if (moderation.status === 'reject') {
return res.status(422).json({
error: true,
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.'
})
}
post.caption = captionText
post.status = moderation.status
}
let taggedUserIds = post.tagged_users || []
if (req.body.tagged_user_ids !== undefined) {
const raw = req.body.tagged_user_ids
let parsed = []
try {
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : [])
} catch {
parsed = []
}
taggedUserIds = parsed
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.slice(0, 50)
post.tagged_users = taggedUserIds
}
post.files = finalFiles
post.type = finalFiles.some((f) => f.type === 'video') ? 'video' : 'image'
post.updatedAt = new Date()
await post.save()
const firstVideo = finalFiles.find((file) => file.type === 'video')
if (firstVideo?.path) {
processPostVideoInBackground(firstVideo.path, post._id).catch((err) => {
console.error('[postVideoProcessing] background error:', err.message)
})
}
return res.status(200).json({
message: 'پست با موفقیت ویرایش شد',
postId: post._id,
status: post.status
})
} catch (err) {
console.error('DEBUG: updatePost error:', err.message)
return res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` })
}
}
const getUserPosts = async (req, res, next) => {
try {
@@ -408,19 +80,6 @@ const getUserPosts = async (req, res, next) => {
if (!user) {
return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' })
}
if (user_id.toString() !== userReqId.toString() && isHiddenFromPublic(user)) {
return res.status(200).json({
data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0 }
})
}
if (user_id.toString() !== userReqId.toString()) {
const canView = await viewerCanSeeUserContent(userReqId, user)
if (!canView) {
return res.status(200).json({
data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0, privateLocked: true }
})
}
}
const response = {}
const options = {
page: req.query.page || 1, // صفحه پیش‌فرض ۱
@@ -430,7 +89,7 @@ const getUserPosts = async (req, res, next) => {
if (user_id.toString() === userReqId.toString()) {
// کاربر خودش است، همه پست‌هایش را بررسی می‌کند
const posts = await PostModel.paginate(
{ user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } }, // فیلتر
{ user_id: user._id, status: { $in: ['pending', 'accept'] } }, // فیلتر
options // گزینه‌های پیجینیشن
)
response.posts = posts.docs
@@ -440,7 +99,7 @@ const getUserPosts = async (req, res, next) => {
} else {
// دیگران، فقط پست‌هایی که وضعیتشان "accept" است را ببینند
const posts = await PostModel.paginate(
{ user_id: user._id, status: postStatusFilter(), is_archived: { $ne: true } }, // فیلتر
{ user_id: user._id, status: 'accept' }, // فیلتر
options // گزینه‌های پیجینیشن
)
response.posts = posts.docs
@@ -480,40 +139,6 @@ const getUserPostsWeb = async (req, res, next) => {
return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' })
}
if (userReqId && viewerIsBlockedBy(user, userReqId)) {
return res.status(200).json({
posts: [],
postsCount: 0,
totalPages: 0,
totalItems: 0
})
}
if ((!userReqId || user_id.toString() !== userReqId.toString()) && isHiddenFromPublic(user)) {
return res.status(200).json({
posts: [],
postsCount: 0,
totalPages: 0,
totalItems: 0
})
}
if (
!userReqId ||
user_id.toString() !== userReqId.toString()
) {
const canView = await viewerCanSeeUserContent(userReqId, user)
if (!canView) {
return res.status(200).json({
posts: [],
postsCount: 0,
totalPages: 0,
totalItems: 0,
privateLocked: true
})
}
}
const data = {}
const options = {
page: req.query.page || 1,
@@ -522,28 +147,19 @@ const getUserPostsWeb = async (req, res, next) => {
}
if (userReqId && user_id.toString() === userReqId.toString()) {
// اگر کاربر لاگین کرده و درخواست برای خودش است، همه پست‌هایش را ببیند
const posts = await PostModel.paginate(
{ user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } },
{ user_id: user._id, status: { $in: ['pending', 'accept'] } },
options
)
data.posts = posts.docs.filter(
(post) => post.status === 'pending' || isPostPubliclyVisible(post)
)
data.postsCount = data.posts.length
data.posts = posts.docs
data.postsCount = posts.totalDocs
data.totalPages = posts.totalPages
data.totalItems = data.posts.length
data.totalItems = posts.totalDocs
} else {
// برای بقیه، فقط پست‌هایی که وضعیتشان "accept" است نمایش داده شود
const posts = await PostModel.paginate(
{
user_id: user._id,
status: postStatusFilter(),
is_archived: { $ne: true },
$or: [
{ report_hidden: false },
{ report_hidden: { $exists: false } },
{ report_hidden: null }
]
},
{ user_id: user._id, status: 'accept' },
options
)
data.posts = posts.docs
@@ -552,175 +168,15 @@ const getUserPostsWeb = async (req, res, next) => {
data.totalItems = posts.totalDocs
}
const pinnedIds = (user.pinned_post_ids || []).map(String)
if (pinnedIds.length && data.posts?.length) {
const pinOrder = new Map(pinnedIds.map((id, idx) => [id, idx]))
data.posts.sort((a, b) => {
const aPin = pinOrder.get(String(a._id))
const bPin = pinOrder.get(String(b._id))
if (aPin !== undefined && bPin !== undefined) return aPin - bPin
if (aPin !== undefined) return -1
if (bPin !== undefined) return 1
return new Date(b.createdAt) - new Date(a.createdAt)
})
data.posts.forEach((p) => {
p.is_pinned = pinOrder.has(String(p._id))
})
}
return res.status(200).json(data)
} catch (error) {
next(error)
}
}
// حذفِ کاملِ پست — غیرقابل‌بازگشت. علاوه بر خودِ پست، هرچی که بهش ارجاع می‌ده هم پاک
// می‌شه (لایک/کامنت/گزارش/بوکمارک/اشتراک/ارسال‌شده‌درچت + پین‌شده از پروفایل مالک) تا
// دیتای یتیم تو دیتابیس نمونه؛ فایل‌های فیزیکی (عکس/ویدیو) هم از دیسک حذف می‌شن
const deletePost = async (req, res, next) => {
try {
const userId = req.user?._id || req.user?.id
if (!userId) {
return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' })
}
const { postId } = req.params
if (!mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ error: true, message: 'شناسه پست معتبر نیست' })
}
const post = await PostModel.findById(postId)
if (!post) {
return res.status(404).json({ error: true, message: 'پست یافت نشد' })
}
if (String(post.user_id) !== String(userId)) {
return res.status(403).json({ error: true, message: 'اجازه‌ی حذف این پست را ندارید' })
}
await Promise.all([
LikeModel.deleteMany({ postId }),
CommentModel.deleteMany({ post: postId }),
PostReportModel.deleteMany({ post_id: postId }),
PostBookmarkModel.deleteMany({ postId }),
PostShareModel.deleteMany({ postId }),
PostSendModel.deleteMany({ postId }),
UserModel.updateMany(
{ pinned_post_ids: postId },
{ $pull: { pinned_post_ids: postId } }
)
])
for (const file of post.files || []) {
if (file?.path && fs.existsSync(file.path)) {
await fs.remove(file.path).catch(() => {})
}
}
await PostModel.deleteOne({ _id: postId })
return res.status(200).json({ message: 'پست با موفقیت حذف شد' })
} catch (error) {
next(error)
}
}
const getPostByIdWeb = async (req, res, next) => {
try {
const { postId } = req.params
if (!mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ message: 'شناسه پست معتبر نیست' })
}
let userReqId = null
const authHeader = req.header('Authorization')
if (authHeader) {
const token = authHeader.split(' ')[1]
try {
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
userReqId = decodedToken.id
} catch (err) {
console.warn('توکن نامعتبر است، ادامه بدون احراز هویت')
}
}
const post = await PostModel.findById(postId).lean()
if (!post || !isPostPubliclyVisible(post)) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
if (post.is_archived && String(post.user_id) !== String(userReqId)) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
const user = await UserModel.findById(post.user_id)
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image blocked_users is_private account_status'
)
.lean()
if (!user) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
if (userReqId && viewerIsBlockedBy(user, userReqId)) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
const canView = await viewerCanSeeUserContent(userReqId, user)
if (!canView) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
const LikeModel = require('../../../models/LikeModel')
const CommentModel = require('../../../models/CommentModel')
let is_liked = false
if (userReqId) {
const like = await LikeModel.findOne({
userId: userReqId,
postId: post._id
}).lean()
is_liked = !!like
}
const commentsCount = await CommentModel.countDocuments({
post: post._id,
status: commentStatusFilter()
})
let enrichedPost = {
...post,
user_name: user.user_name || '',
first_name: user.first_name || '',
last_name: user.last_name || '',
expertise: user.expertise || '',
sub_expertise: user.sub_expertise || [],
province: user.show_location ? user.province || {} : {},
city: user.show_location ? user.city || {} : {},
show_location: user.show_location,
user_level: user.user_level || '',
profile_image: user.profile_image || '',
likesCount: post.likes ? post.likes.length : 0,
is_liked,
commentsCount
}
enrichedPost = await attachSinglePostEngagement(enrichedPost, userReqId)
return res.status(200).json({
post: enrichedPost
})
} catch (error) {
next(error)
}
}
module.exports = {
createPostValidationRules,
createPost,
updatePost,
deletePost,
getUserPosts,
getUserPostsWeb,
getPostByIdWeb
getUserPostsWeb
}

View File

@@ -1,50 +0,0 @@
/* eslint-disable camelcase */
const PostReportModel = require('../../../models/PostReportModel')
const PostModel = require('../../../models/PostModel')
const { default: mongoose } = require('mongoose')
const reportPost = async (req, res) => {
try {
const reporterId = req.user._id
const { postId, reason, text } = req.body
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ message: 'شناسه پست نامعتبر است' })
}
if (!reason || !String(reason).trim()) {
return res.status(400).json({ message: 'دلیل گزارش الزامی است' })
}
const post = await PostModel.findById(postId).select('_id user_id status').lean()
if (!post || post.status !== 'accept') {
return res.status(404).json({ message: 'پست یافت نشد' })
}
if (String(post.user_id) === String(reporterId)) {
return res.status(400).json({ message: 'امکان گزارش پست خودتان وجود ندارد' })
}
const existing = await PostReportModel.findOne({
post_id: postId,
reporter_id: reporterId,
}).lean()
if (existing) {
return res.status(400).json({ message: 'شما قبلاً این پست را گزارش کرده‌اید' })
}
await PostReportModel.create({
post_id: postId,
reporter_id: reporterId,
reason: String(reason).trim().slice(0, 200),
text: String(text || '').trim().slice(0, 2000),
})
return res.status(201).json({ message: 'گزارش ثبت شد' })
} catch (err) {
console.error('reportPost:', err)
return res.status(500).json({ message: 'خطا در ثبت گزارش' })
}
}
module.exports = {
reportPost,
}

View File

@@ -1,552 +0,0 @@
/* eslint-disable camelcase */
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const moment = require('moment-jalaali')
const UserModel = require('../../../models/UserModel')
const PostModel = require('../../../models/PostModel')
const ExploreInteractionModel = require('../../../models/ExploreInteractionModel')
const UserFollowModel = require('../../../models/UserFollowModel')
const LikeModel = require('../../../models/LikeModel')
const CommentModel = require('../../../models/CommentModel')
const {
DEACTIVATION_LOCK_DAYS,
DEACTIVATION_PURGE_MONTHS,
MS_DAY,
isDeactivated,
createDeletionTombstone,
purgeUserData
} = require('../../../utils/accountLifecycle')
const { PUBLIC_POST_STATUSES } = require('../../../utils/moderationStatus')
const { APP_LANGUAGE_CODES } = require('../../../utils/appLanguages')
// middlewares/auth.js فقط توکن رو معتبرسنجی می‌کنه و next() صدا می‌زنه — req.user رو ست نمی‌کنه،
// پس باید توکن رو مستقیم دیکد کنیم (همون الگویی که notificationController.js استفاده می‌کنه)
function getUserIdFromToken (req) {
const authHeader = req.header('Authorization')
const token = authHeader?.split(' ')[1]
if (!token) return null
try {
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
return decodedToken.id
} catch {
return null
}
}
const deactivateAccount = async (req, res) => {
try {
const userId = req.user._id
const { user_name } = req.body
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (String(user.user_name).toLowerCase() !== String(user_name || '').trim().toLowerCase()) {
return res.status(422).json({ message: 'نام کاربری صحیح نیست' })
}
if (isDeactivated(user)) {
return res.status(400).json({ message: 'حساب کاربری قبلاً غیرفعال شده است' })
}
const now = new Date()
user.account_status = 'deactivated'
user.deactivated_at = now
user.reactivation_locked_until = new Date(now.getTime() + DEACTIVATION_LOCK_DAYS * MS_DAY)
user.scheduled_purge_at = new Date(now.getTime() + DEACTIVATION_PURGE_MONTHS * 30 * MS_DAY)
await user.save()
return res.status(200).json({
message: 'حساب کاربری غیرفعال شد',
reactivation_locked_until: user.reactivation_locked_until,
scheduled_purge_at: user.scheduled_purge_at
})
} catch (err) {
console.error('deactivateAccount:', err)
return res.status(500).json({ message: 'خطا در غیرفعال‌سازی حساب' })
}
}
const reactivateAccount = async (req, res) => {
try {
const userId = req.user._id
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (!isDeactivated(user)) {
return res.status(400).json({ message: 'حساب کاربری فعال است' })
}
if (user.reactivation_locked_until && new Date() < new Date(user.reactivation_locked_until)) {
return res.status(403).json({
message: 'تا پایان دوره ۷ روزه امکان فعال‌سازی مجدد وجود ندارد',
reactivation_locked_until: user.reactivation_locked_until
})
}
user.account_status = 'active'
user.deactivated_at = null
user.reactivation_locked_until = null
user.scheduled_purge_at = null
await user.save()
return res.status(200).json({ message: 'حساب کاربری فعال شد' })
} catch (err) {
console.error('reactivateAccount:', err)
return res.status(500).json({ message: 'خطا در فعال‌سازی حساب' })
}
}
const deleteAccount = async (req, res) => {
try {
const userId = req.user._id
const { user_name } = req.body
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (String(user.user_name).toLowerCase() !== String(user_name || '').trim().toLowerCase()) {
return res.status(422).json({ message: 'نام کاربری صحیح نیست' })
}
const { mobile, user_name: uname } = user
await createDeletionTombstone({ mobile, userName: uname })
await purgeUserData(userId)
await UserModel.deleteOne({ _id: userId })
return res.status(200).json({ message: 'حساب کاربری حذف شد' })
} catch (err) {
console.error('deleteAccount:', err)
return res.status(500).json({ message: 'خطا در حذف حساب' })
}
}
const getAccountStatus = async (req, res) => {
try {
const user = await UserModel.findById(req.user._id)
.select('account_status deactivated_at reactivation_locked_until scheduled_purge_at allow_save_posts ghost_mode show_location post_location_enabled is_private gift_enabled offer_payment_enabled preferred_language user_name blocked_by')
.lean()
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
const { blocked_by, ...userWithoutBlockedBy } = user
return res.status(200).json({
account: {
...userWithoutBlockedBy,
allow_save_posts: user.allow_save_posts !== false,
ghost_mode: Boolean(user.ghost_mode),
show_location: user.show_location === true,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
gift_enabled: user.gift_enabled !== false,
offer_payment_enabled: user.offer_payment_enabled !== false,
preferred_language: user.preferred_language || 'fa',
// «کاربرانی که شما را مسدود کرده‌اند» — blocked_by یه فیلد denormalized روی خودِ
// کاربره (هر بار block/unblock سینک می‌شه)، پس فقط طولش کافیه، نیازی به کوئری
// جدا روی کل کالکشن کاربرها نیست
blocked_by_count: Array.isArray(blocked_by) ? blocked_by.length : 0,
},
})
} catch (err) {
return res.status(500).json({ message: 'خطا در دریافت وضعیت حساب' })
}
}
const updatePrivacySettings = async (req, res) => {
try {
// auth فقط توکن را چک می‌کند؛ blockCheck باید req.user را ست کرده باشد
const userId = req.user?._id || getUserIdFromToken(req)
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' })
}
const $set = {}
if (typeof req.body.allow_save_posts === 'boolean') {
$set.allow_save_posts = req.body.allow_save_posts
}
if (typeof req.body.ghost_mode === 'boolean') {
$set.ghost_mode = req.body.ghost_mode
}
if (typeof req.body.show_location === 'boolean') {
$set.show_location = req.body.show_location
}
if (typeof req.body.post_location_enabled === 'boolean') {
$set.post_location_enabled = req.body.post_location_enabled
}
if (typeof req.body.is_private === 'boolean') {
$set.is_private = req.body.is_private
}
if (typeof req.body.gift_enabled === 'boolean') {
$set.gift_enabled = req.body.gift_enabled
}
if (typeof req.body.offer_payment_enabled === 'boolean') {
$set.offer_payment_enabled = req.body.offer_payment_enabled
}
if (APP_LANGUAGE_CODES.includes(req.body.preferred_language)) {
$set.preferred_language = req.body.preferred_language
}
// فقط فیلدهای privacy را با $set آپدیت می‌کنیم تا validation کل سند
// (فیلدهای قدیمی/ناسازگار) باعث 500 نشود
const user = await UserModel.findByIdAndUpdate(
userId,
Object.keys($set).length ? { $set } : {},
{
new: true,
runValidators: false,
select:
'allow_save_posts ghost_mode show_location post_location_enabled is_private gift_enabled offer_payment_enabled preferred_language',
}
).lean()
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
return res.status(200).json({
allow_save_posts: user.allow_save_posts !== false,
ghost_mode: Boolean(user.ghost_mode),
show_location: user.show_location === true,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
gift_enabled: user.gift_enabled !== false,
offer_payment_enabled: user.offer_payment_enabled !== false,
preferred_language: user.preferred_language || 'fa',
})
} catch (err) {
console.error('updatePrivacySettings:', err)
return res.status(500).json({
message: 'خطا در ذخیره تنظیمات',
detail: err?.message || String(err),
})
}
}
/**
* «ریست الگوریتم» — تمام سیگنال‌های رفتاریِ ذخیره‌شده‌ی این کاربر که فید شخصی‌سازی‌شده
* (utils/exploreAlgorithm.js) رو می‌سازن پاک می‌کنه: لاگ کامل تعامل‌هاش (بازدید/دویل/اسکیپ/
* بازدید پروفایل و...) که buildUserExploreSignals/buildUnifiedInterestScore ازش تغذیه
* می‌کنن، به‌علاوه‌ی ترتیب رتبه‌بندی‌شده‌ی کش‌شده‌ی درون‌حافظه‌ش (تا فید بعدی از صفر
* بازمحاسبه بشه، نه ادامه‌ی همون ترتیب قبلی). لایک/کامنت/دنبال‌کردن دست‌نخورده می‌مونن —
* اون‌ها محتوای صریح خودِ کاربرن، نه «حالت الگوریتم»
*/
const resetAlgorithm = async (req, res) => {
try {
const userId = req.user._id
await ExploreInteractionModel.deleteMany({ viewerId: userId })
// فقط پاک‌کردن تاریخچه‌ی بازدید کافی نبود — buildUserExploreSignals همچنان از
// روی لایک/کامنت/آفرِ واقعیِ کاربر، ترجیحات قبلی رو دوباره می‌ساخت. با ثبت این
// تاریخ، اون سیگنال‌ها هم فقط از این لحظه به بعد دوباره جمع می‌شن
await UserModel.updateOne(
{ _id: userId },
{ $set: { algorithm_reset_at: new Date() } }
)
const { resetUserFeedCache } = require('../../../utils/exploreAlgorithm')
resetUserFeedCache(String(userId))
return res.status(200).json({ message: 'الگوریتم بازنشانی شد' })
} catch (err) {
console.error('resetAlgorithm:', err)
return res.status(500).json({ message: 'خطا در بازنشانی الگوریتم' })
}
}
/** Caller's own country/province/city selection (the "Region" picker) — a denormalized
* snapshot, same pattern as the existing province/city fields, not a live FK join. */
const updateUserRegion = async (req, res) => {
try {
const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity')
const user = await UserModel.findById(req.user._id)
if (!user) return res.status(404).json({ message: 'کاربر یافت نشد' })
const { country_id, province_id, city_id } = req.body
const [country, province, city] = await Promise.all([
country_id ? CountryModel.findOne({ id: Number(country_id) }).lean() : null,
province_id ? ProvinceModel.findOne({ id: Number(province_id) }).lean() : null,
city_id ? CityModel.findOne({ id: Number(city_id) }).lean() : null
])
if (country_id && !country) return res.status(422).json({ message: 'کشور نامعتبر است' })
if (province_id && !province) return res.status(422).json({ message: 'استان نامعتبر است' })
if (city_id && !city) return res.status(422).json({ message: 'شهر نامعتبر است' })
user.country = country ? { id: country.id, name: country.name_fa, iso2: country.iso2, slug: country.slug } : null
user.province = province ? { id: province.id, name: province.name, slug: province.slug } : null
user.city = city ? { id: city.id, name: city.name, slug: city.slug } : null
await user.save()
return res.status(200).json({
country: user.country,
province: user.province,
city: user.city
})
} catch (err) {
return res.status(500).json({ message: 'خطا در ذخیره منطقه' })
}
}
const pinPost = async (req, res) => {
try {
const userId = getUserIdFromToken(req)
if (!userId) {
return res.status(401).json({ message: 'دسترسی غیرمجاز' })
}
const { postId } = req.body
if (!postId) {
return res.status(400).json({ message: 'شناسه پست الزامی است' })
}
const post = await PostModel.findById(postId).select('user_id status').lean()
if (
!post ||
String(post.user_id) !== String(userId) ||
!PUBLIC_POST_STATUSES.includes(post.status)
) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
const pinned = (user.pinned_post_ids || []).map(String)
// اگر قبلاً پین شده، آن‌پین کن (تاگل)
if (pinned.includes(String(postId))) {
user.pinned_post_ids = (user.pinned_post_ids || []).filter(
(id) => String(id) !== String(postId)
)
await user.save()
return res.status(200).json({
message: 'پین پست برداشته شد',
pinned_post_ids: user.pinned_post_ids,
unpinned: true,
})
}
if (pinned.length >= 3) {
return res.status(400).json({ message: 'حداکثر ۳ پست را می‌توانید پین کنید' })
}
user.pinned_post_ids = [postId, ...(user.pinned_post_ids || [])]
await user.save()
return res.status(200).json({
message: 'پست با موفقیت پین شد',
pinned_post_ids: user.pinned_post_ids,
unpinned: false,
})
} catch (err) {
console.error('pinPost:', err)
return res.status(500).json({ message: 'خطا در پین کردن پست' })
}
}
const toggleArchivePost = async (req, res) => {
try {
const userId = getUserIdFromToken(req)
if (!userId) {
return res.status(401).json({ message: 'دسترسی غیرمجاز' })
}
const { postId } = req.body
if (!postId) {
return res.status(400).json({ message: 'شناسه پست الزامی است' })
}
const post = await PostModel.findById(postId)
if (!post || String(post.user_id) !== String(userId)) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
post.is_archived = !post.is_archived
post.archived_at = post.is_archived ? new Date() : null
await post.save()
return res.status(200).json({
message: post.is_archived ? 'پست بایگانی شد' : 'پست دوباره منتشر شد',
is_archived: post.is_archived,
})
} catch (err) {
console.error('toggleArchivePost:', err)
return res.status(500).json({ message: 'خطا در بایگانی پست' })
}
}
const getArchivedPosts = async (req, res) => {
try {
const userId = getUserIdFromToken(req)
if (!userId) {
return res.status(401).json({ message: 'دسترسی غیرمجاز' })
}
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 12))
const skip = (page - 1) * limit
const filter = { user_id: userId, is_archived: true }
const [posts, total] = await Promise.all([
PostModel.find(filter).sort({ archived_at: -1 }).skip(skip).limit(limit).lean(),
PostModel.countDocuments(filter),
])
return res.status(200).json({
posts,
total,
page,
totalPages: Math.ceil(total / limit) || 1,
})
} catch (err) {
console.error('getArchivedPosts:', err)
return res.status(500).json({ message: 'خطا در دریافت پست‌های بایگانی‌شده' })
}
}
const ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null }
const getAccountAnalytics = async (req, res) => {
try {
const userId = req.user._id
const range = ANALYTICS_RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '30d'
const days = ANALYTICS_RANGE_DAYS[range]
const since = days ? new Date(Date.now() - days * MS_DAY) : new Date(0)
const dayFormat = '%Y-%m-%d'
const myPostIds = await PostModel.find({ user_id: userId }).distinct('_id')
const [postsSeries, followersSeries, likesSeries, commentsSeries, totalPosts, totalFollowers, totalLikes, totalComments] = await Promise.all([
PostModel.aggregate([
{ $match: { user_id: new mongoose.Types.ObjectId(userId), createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
]),
UserFollowModel.aggregate([
{ $match: { followingId: new mongoose.Types.ObjectId(userId), createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
]),
myPostIds.length
? LikeModel.aggregate([
{ $match: { postId: { $in: myPostIds }, createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
])
: [],
myPostIds.length
? CommentModel.aggregate([
{ $match: { post: { $in: myPostIds }, comment_for: 'post', createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
])
: [],
PostModel.countDocuments({ user_id: userId }),
UserFollowModel.countDocuments({ followingId: userId }),
myPostIds.length ? LikeModel.countDocuments({ postId: { $in: myPostIds } }) : 0,
myPostIds.length ? CommentModel.countDocuments({ post: { $in: myPostIds }, comment_for: 'post' }) : 0,
])
const mergeMap = new Map()
const bump = (arr, key) => {
arr.forEach((item) => {
const entry = mergeMap.get(item._id) || { date: item._id, posts: 0, followers: 0, likes: 0, comments: 0 }
entry[key] = item.count
mergeMap.set(item._id, entry)
})
}
bump(postsSeries, 'posts')
bump(followersSeries, 'followers')
bump(likesSeries, 'likes')
bump(commentsSeries, 'comments')
const series = [...mergeMap.values()].sort((a, b) => (a.date < b.date ? -1 : 1))
return res.status(200).json({
range,
series,
totals: {
posts: totalPosts,
followers: totalFollowers,
likes: totalLikes,
comments: totalComments,
},
})
} catch (err) {
console.error('getAccountAnalytics:', err)
return res.status(500).json({ message: 'خطا در دریافت آنالیتیکس' })
}
}
const getProfileVisitors = async (req, res) => {
try {
const userId = req.user._id
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20))
const skip = (page - 1) * limit
const filter = {
targetType: 'profile',
action: 'profile_visit',
targetId: userId,
viewerId: { $ne: userId },
}
const resolveVisitDate = (visit) => {
if (visit.createdAt) return new Date(visit.createdAt)
if (visit.created_at) return new Date(visit.created_at)
if (visit._id && mongoose.Types.ObjectId.isValid(visit._id)) {
return new mongoose.Types.ObjectId(String(visit._id)).getTimestamp()
}
return null
}
const visits = await ExploreInteractionModel.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean()
const viewerIds = [...new Set(visits.map((v) => String(v.viewerId)))]
const viewers = await UserModel.find({ _id: { $in: viewerIds } })
.select('_id user_name first_name last_name profile_image account_status ghost_mode')
.lean()
const byId = Object.fromEntries(viewers.map((v) => [String(v._id), v]))
const list = visits
.map((v) => {
const u = byId[String(v.viewerId)] || {}
// حالت روح: بازدیدکننده‌ای که الان ghost_mode داره نباید توی لیست بازدیدکنندگان
// صاحب پروفایل دیده بشه — قبلاً این فیلد اصلاً select نمی‌شد، پس این چک هیچ‌وقت
// اجرا نمی‌شد و بازدیدهای کاربرهای روح‌حالت هم عادی نمایش داده می‌شدن
if (!u.user_name || u.account_status === 'deleted' || u.ghost_mode) return null
const visitDate = resolveVisitDate(v)
return {
_id: String(v.viewerId),
user_name: u.user_name || '',
first_name: u.first_name || '',
last_name: u.last_name || '',
profile_image: u.profile_image || '',
visited_at: visitDate ? visitDate.toISOString() : null,
visited_at_time: visitDate ? moment(visitDate).format('HH:mm') : null,
}
})
.filter(Boolean)
const total = await ExploreInteractionModel.countDocuments(filter)
return res.status(200).json({ visitors: list, total, page, totalPages: Math.ceil(total / limit) || 1 })
} catch (err) {
console.error('getProfileVisitors:', err)
return res.status(500).json({ message: 'خطا در دریافت بازدیدکنندگان' })
}
}
module.exports = {
deactivateAccount,
reactivateAccount,
deleteAccount,
getAccountStatus,
updatePrivacySettings,
resetAlgorithm,
updateUserRegion,
getProfileVisitors,
pinPost,
toggleArchivePost,
getArchivedPosts,
getAccountAnalytics,
}

View File

@@ -1,195 +1,139 @@
const UserModel = require('../../../models/UserModel')
const PostModel = require('../../../models/PostModel')
const jwt = require('jsonwebtoken')
const { getFollowCounts } = require('../../../utils/followHelpers')
// GET user by ID from URL
const getUserById = async (req, res, next) => {
try {
const userId = req.params.id; // گرفتن آیدی از پارامتر URL
const service = req.query.services;
const user = await UserModel.findById(userId);
if (!user) {
return res.status(404).json({ message: "کاربر مورد نظر یافت نشد" });
}
let isRegister = true;
// if (
// !user.user_name ||
// !user.first_name ||
// !user.last_name ||
// !user.user_type ||
// !user.national_card_image ||
// !user.password ||
// !user.national_code
// ) {
// isRegister = false;
// }
// پروفایل عمومی دیگران — بدون داده حساس / بدون سند خام
const response = {
_id: user._id,
user_type: user.user_type,
user_level: user.user_level,
first_name: user.first_name,
last_name: user.last_name,
user_name: user.user_name,
show_location: user.show_location,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
is_verified: user.is_verified,
verify_badge: user.verify_badge,
is_Register: user.is_Register,
user_score: user.user_score,
rate: user.rate,
bio: user.bio,
height: user.height,
weight: user.weight,
size: user.size,
eye_color: user.eye_color,
hair_color: user.hair_color,
profile_image: user.profile_image,
lat: user.show_location ? user.lat : null,
lng: user.show_location ? user.lng : null,
address: user.show_location ? user.address : null,
province: user.province,
city: user.city,
cooperation_abroad: user.cooperation_abroad,
cooperation_type: user.cooperation_type,
conversation_projects: user.conversation_projects,
expertise: user.expertise,
sub_expertise: user.sub_expertise,
display_sub_expertise: user.display_sub_expertise,
auth_provider: ['google', 'instagram'].includes(user.auth_provider)
? user.auth_provider
: 'mobile',
e2ee_public_key: user.e2ee_public_key || null,
isRegister,
services: service === "1" ? user.services : null
};
return res.status(200).json({ user: response });
} catch (error) {
next(error);
}
};
const getProfile = async (req, res, next) => {
try {
const authHeader = req.header('Authorization')
if (!authHeader) return res.status(401).send('Access Denied')
const token = authHeader.split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const service = req.query.services
// eslint-disable-next-line no-unused-vars
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const user = await UserModel.findById(userId)
let isRegister = true
if (!user.user_name ||
!user.first_name ||
!user.user_type ||
!user.password ||
!user.national_card_image ||
!user.national_code
) {
isRegister = false
}
if (!user) {
return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' })
}
const postsCount = await PostModel.countDocuments({
user_id: user._id,
status: { $in: ['pending', 'accept'] }
})
const { followersCount, followingCount } = await getFollowCounts(user._id)
const blockedCount = user.blocked_users?.length || 0
const response = {
_id: user._id,
is_self: true,
user_type: user.user_type,
user_level: user.user_level,
first_name: user.first_name,
last_name: user.last_name,
user_name: user.user_name,
show_location: user.show_location,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
is_verified: user.is_verified,
verify_badge: user.verify_badge,
is_Register: user.is_Register,
user_score: user.user_score,
rate: user.rate,
bio: user.bio,
height: user.height,
weight: user.weight,
size: user.size,
eye_color: user.eye_color,
hair_color: user.hair_color,
marital_status: user.marital_status,
residence_type: user.residence_type,
residence_area: user.residence_area,
vehicle_type: user.vehicle_type,
vehicle_details: user.vehicle_details,
personal_style: user.personal_style,
job: user.job,
income_range: user.income_range,
personal_details_public: user.personal_details_public === true,
relationship_goal: user.relationship_goal,
seeking_gender: user.seeking_gender,
desired_age_range: user.desired_age_range,
personality_traits: user.personality_traits,
lifestyle: user.lifestyle,
interests: user.interests,
values: user.values,
relationship_view: user.relationship_view,
geo_preference: user.geo_preference,
children_status: user.children_status,
smoking_status: user.smoking_status,
profile_image: user.profile_image,
lat: user.lat,
lng: user.lng,
address: user.address,
country: user.country,
province: user.province,
city: user.city,
shaba: user.shaba,
national_code: user.national_code,
birthday: user.birthday,
mobile: user.mobile,
email: user.email,
auth_provider: user.auth_provider || 'mobile',
cooperation_abroad: user.cooperation_abroad,
cooperation_type: user.cooperation_type,
conversation_projects: user.conversation_projects,
expertise: user.expertise,
sub_expertise: user.sub_expertise,
display_sub_expertise: user.display_sub_expertise,
isRegister,
services: service === '1' ? user.services : null,
account_status: user.account_status || 'active',
deactivated_at: user.deactivated_at,
reactivation_locked_until: user.reactivation_locked_until,
scheduled_purge_at: user.scheduled_purge_at,
allow_save_posts: user.allow_save_posts !== false,
ghost_mode: Boolean(user.ghost_mode),
postsCount,
followersCount,
followingCount,
blockedCount
}
return res.status(200).json({ user: response })
} catch (error) {
next(error)
}
}
module.exports = {
getProfile,
getUserById
};
const UserModel = require('../../../models/UserModel')
const jwt = require('jsonwebtoken')
// GET user by ID from URL
const getUserById = async (req, res, next) => {
try {
const userId = req.params.id; // گرفتن آیدی از پارامتر URL
const service = req.query.services;
const user = await UserModel.findById(userId);
if (!user) {
return res.status(404).json({ message: "کاربر مورد نظر یافت نشد" });
}
let isRegister = true;
// if (
// !user.user_name ||
// !user.first_name ||
// !user.last_name ||
// !user.user_type ||
// !user.national_card_image ||
// !user.password ||
// !user.national_code
// ) {
// isRegister = false;
// }
const response = {
_id: user._id,
user_type: user.user_type,
user_level: user.user_level,
first_name: user.first_name,
last_name: user.last_name,
user_name: user.user_name,
show_location: user.show_location,
is_verified: user.is_verified,
user_score: user.user_score,
rate: user.rate,
bio: user.bio,
height: user.height,
weight: user.weight,
size: user.size,
eye_color: user.eye_color,
hair_color: user.hair_color,
profile_image: user.profile_image,
lat: user.lat,
lng: user.lng,
address: user.address,
province: user.province,
city: user.city,
shaba: user.shaba,
national_code: user.national_code,
mobile: user.mobile,
cooperation_abroad: user.cooperation_abroad,
cooperation_type: user.cooperation_type,
conversation_projects: user.conversation_projects,
expertise: user.expertise,
sub_expertise: user.sub_expertise,
isRegister,
user,
services: service === "1" ? user.services : null
};
return res.status(200).json({ user: response });
} catch (error) {
next(error);
}
};
const getProfile = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const service = req.query.services
// eslint-disable-next-line no-unused-vars
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const user = await UserModel.findById(userId)
let isRegister = true
if (!user.user_name ||
!user.first_name ||
!user.last_name ||
!user.user_type ||
!user.national_card_image ||
!user.password ||
!user.national_code
) {
isRegister = false
}
if (!user) {
return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' })
}
const response = {
_id: user._id,
user_type: user.user_type,
user_level: user.user_level,
first_name: user.first_name,
last_name: user.last_name,
user_name: user.user_name,
show_location: user.show_location,
is_verified: user.is_verified,
is_Register: user.is_Register,
user_score: user.user_score,
rate: user.rate,
bio: user.bio,
height: user.height,
weight: user.weight,
size: user.size,
eye_color: user.eye_color,
hair_color: user.hair_color,
profile_image: user.profile_image,
lat: user.lat,
lng: user.lng,
address: user.address,
province: user.province,
city: user.city,
shaba: user.shaba,
national_code: user.national_code,
mobile: user.mobile,
cooperation_abroad: user.cooperation_abroad,
cooperation_type: user.cooperation_type,
conversation_projects: user.conversation_projects,
expertise: user.expertise,
sub_expertise: user.sub_expertise,
isRegister,
services: service === '1' ? user.services : null
}
return res.status(200).json({ user: response })
} catch (error) {
next(error)
}
}
module.exports = {
getProfile,
getUserById
};

View File

@@ -1,307 +1,220 @@
/* eslint-disable camelcase */
const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity')
const ProjectModel = require('../../../models/ProjectModel')
const UserModel = require('../../../models/UserModel')
const { check, validationResult } = require('express-validator')
const jwt = require('jsonwebtoken')
const {
canCreateProject,
respondProjectProfileIncomplete
} = require('../../../utils/projectProfile')
const { isVerifiedBadge } = require('../../../utils/verifyBadge')
const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking')
async function activateFreeProjectIfEligible (user, project) {
if (project.project_type !== 'free') return { activated: false }
if (!user.daily_free_request || user.daily_free_request <= 0) {
return {
activated: false,
error: 'شما قبلاً از درخواست رایگان امروز استفاده کرده‌اید.'
}
}
user.daily_free_request -= 1
await user.save()
project.payment_status = 'done'
// کاربرِ بدون تیک وریفای طلایی/سبز/آبی باید از پنل ادمین تایید بشه تا پروژه‌ش
// مستقیم توی اپ/وب نمایش داده نشه
project.status = isVerifiedBadge(user.verify_badge) ? 'temp_accept' : 'pending'
await project.save()
return { activated: true }
}
function normalizeRoles (roles) {
if (!Array.isArray(roles)) return []
return roles.slice(0, 5).map((role) => ({
expertise: role.expertise,
sub_expertise: Array.isArray(role.sub_expertise)
? role.sub_expertise
: (role.subExpertise || []),
number_of_person: Number(role.number_of_person || role.numberOfPerson || 1) || 1,
height_min: role.height_min ?? role.heightMin ?? null,
height_max: role.height_max ?? role.heightMax ?? null,
weight_min: role.weight_min ?? role.weightMin ?? null,
weight_max: role.weight_max ?? role.weightMax ?? null,
size_min: role.size_min ?? role.sizeMin ?? null,
size_max: role.size_max ?? role.sizeMax ?? null,
eye_color: role.eye_color || role.eyeColor || null,
hair_color: role.hair_color || role.hairColor || null,
portfolio_required: Boolean(
role.portfolio_required ?? role.portfolioRequired ?? false
)
})).filter((role) => role.expertise)
}
function buildAgeLabel (age, ageMin, ageMax) {
if (age) return age
if (ageMin != null && ageMax != null) return `${ageMin}-${ageMax}`
if (ageMin != null) return `${ageMin}+`
if (ageMax != null) return `تا ${ageMax}`
return 'all'
}
const createProjectValidationRules = () => {
return [
check('title').notEmpty().withMessage('عنوان نمی‌تواند خالی باشد'),
check('description')
.notEmpty()
.withMessage('توضیحات نمی‌تواند خالی باشد'),
check('province').notEmpty().withMessage('استان نمی‌تواند خالی باشد'),
check('city').notEmpty().withMessage('شهر نمی‌تواند خالی باشد'),
check('offer_time')
.notEmpty()
.withMessage('زمان پروژه نمی‌تواند خالی باشد'),
check('offer_price')
.notEmpty()
.withMessage('قیمت پیشنهادی نمی‌تواند خالی باشد'),
check('project_type')
.notEmpty()
.withMessage('نوع پروژه نمی‌تواند خالی باشد')
.isIn(['free', 'normal', 'force', 'highlight'])
.withMessage('نوع پروژه باید normal یا force یا highlight باشد'),
check('gender')
.optional({ nullable: true, checkFalsy: true })
.isIn(['male', 'female', 'any'])
.withMessage('جنسیت باید male یا female یا any باشد')
]
}
const createProject = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const user = await UserModel.findById(decodedToken.id)
if (!user) {
return res.status(422).json({
error: true,
message: 'شما دسترسی به این بخش ندارید'
})
}
if (!canCreateProject(user)) {
return respondProjectProfileIncomplete(res)
}
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
const {
title,
expertise,
sub_expertise,
gender,
age,
age_min,
age_max,
conversation_projects,
country,
province,
city,
lat,
lng,
neighborhood,
address,
offer_time,
offer_price,
description,
project_type,
public_status,
created_for_user,
number_of_person,
roles,
payment_method,
start_date,
work_hours_start,
work_hours_end,
collaboration_type,
experience
} = req.body
const normalizedRoles = normalizeRoles(roles)
const primaryExpertise =
expertise || (normalizedRoles[0] && normalizedRoles[0].expertise)
const primarySubExpertise =
sub_expertise ||
(normalizedRoles[0] && normalizedRoles[0].sub_expertise) ||
[]
if (
!title ||
!primaryExpertise ||
!province ||
!city ||
!offer_time ||
!offer_price ||
!description ||
!project_type
) {
return res.status(422).json({
error: true,
message: 'اطلاعات ارسالی اشتباه است'
})
}
if (normalizedRoles.length === 0) {
return res.status(422).json({
error: true,
message: 'حداقل یک نقش باید ثبت شود'
})
}
const countryFind = country
? await CountryModel.findOne({ id: country }, { _id: 0 })
: null
const provinceFind = await ProvinceModel.findOne(
{ id: province },
{ _id: 0 }
)
const cityFind = await CityModel.findOne({ id: city })
const ageLabel = buildAgeLabel(age, age_min, age_max)
const genderValue = gender || 'any'
const conversationValue =
conversation_projects === undefined || conversation_projects === null
? true
: conversation_projects
const sharedFields = {
title,
expertise: primaryExpertise,
sub_expertise: primarySubExpertise,
gender: genderValue,
age: ageLabel,
age_min: age_min != null && age_min !== '' ? Number(age_min) : null,
age_max: age_max != null && age_max !== '' ? Number(age_max) : null,
conversation_projects: conversationValue,
country: countryFind
? { id: countryFind.id, name: countryFind.name_fa, iso2: countryFind.iso2, slug: countryFind.slug }
: null,
province: provinceFind || province,
city: cityFind || city,
lat: lat != null && lat !== '' ? Number(lat) : null,
lng: lng != null && lng !== '' ? Number(lng) : null,
neighborhood: neighborhood || null,
address: address || null,
offer_time,
offer_price,
description: maskPhoneNumbers(description),
...(payment_method ? { payment_method } : {}),
start_date: start_date || null,
work_hours_start: work_hours_start || null,
work_hours_end: work_hours_end || null,
...(collaboration_type ? { collaboration_type } : {}),
experience: experience || null,
roles: normalizedRoles,
creator_id: user._id,
public_status: public_status || 'public',
created_for_user: created_for_user || null
}
// private project for a specific user
if (public_status && public_status === 'private' && created_for_user) {
const createdUser = await UserModel.findById(created_for_user)
if (!createdUser) {
return res.status(422).json({
error: true,
message: 'کاربر مورد نظر برای ساخت پروژه یافت نشد'
})
}
const project = new ProjectModel({
...sharedFields,
project_type
})
await project.save()
const freeResult = await activateFreeProjectIfEligible(user, project)
if (freeResult.error) {
return res.status(403).json({ error: true, message: freeResult.error })
}
return res.status(201).json({
message: 'پروژه با موفقیت ساخته شد',
id: project._id
})
}
// Legacy: multiple projects by headcount when no multi-role payload intent
const headcount = Number(number_of_person) || 1
if (headcount > 1 && normalizedRoles.length <= 1) {
for (let i = 1; i <= headcount; i++) {
let currentProjectType
if (project_type === 'free' && i === 1) {
currentProjectType = 'free'
} else if (project_type === 'highlight' || project_type === 'force') {
currentProjectType = project_type
} else {
currentProjectType = 'normal'
}
const newProject = new ProjectModel({
...sharedFields,
title: `${title}_${i}`,
project_type: currentProjectType
})
await newProject.save()
if (currentProjectType === 'free') {
const freeResult = await activateFreeProjectIfEligible(user, newProject)
if (freeResult.error) {
return res.status(403).json({ error: true, message: freeResult.error })
}
}
}
return res.status(201).json({
message: 'پروژه‌ها با موفقیت ساخته شدند'
})
}
const project = new ProjectModel({
...sharedFields,
project_type
})
await project.save()
const freeResult = await activateFreeProjectIfEligible(user, project)
if (freeResult.error) {
return res.status(403).json({ error: true, message: freeResult.error })
}
return res.status(201).json({
message: 'پروژه با موفقیت ساخته شد',
id: project._id
})
} catch (error) {
next(error)
}
}
module.exports = {
createProjectValidationRules,
createProject
}
/* eslint-disable camelcase */
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
const ProjectModel = require('../../../models/ProjectModel')
const UserModel = require('../../../models/UserModel')
const { check, validationResult } = require('express-validator')
const jwt = require('jsonwebtoken')
const createProjectValidationRules = () => {
return [
check('title').notEmpty().withMessage('عنوان نمی‌تواند خالی باشد'),
check('expertise').notEmpty().withMessage('تخصص نمی‌تواند خالی باشد'),
check('gender')
.isIn(['male', 'female'])
.withMessage('جنسیت باید male یا female باشد'),
check('age').notEmpty().withMessage('سن نمی‌تواند خالی باشد'),
check('conversation_projects')
.notEmpty()
.withMessage('پروژه‌های مکالمه نمی‌تواند خالی باشد')
.isBoolean()
.withMessage('مقدار پروژه‌های مکالمه باید یک مقدار boolean باشد'),
check('province').notEmpty().withMessage('استان نمی‌تواند خالی باشد'),
check('city').notEmpty().withMessage('شهر نمی‌تواند خالی باشد'),
check('offer_time')
.notEmpty()
.withMessage('زمان پروژه نمی‌تواند خالی باشد'),
check('offer_price')
.notEmpty()
.withMessage('قیمت پیشنهادی نمی‌تواند خالی باشد'),
check('description')
.notEmpty()
.withMessage('توضیحات نمی‌تواند خالی باشد'),
check('project_type')
.notEmpty()
.withMessage('نوع پروژه نمی‌تواند خالی باشد')
.isIn(['free', 'normal', 'force', 'highlight'])
.withMessage('نوع پروژه باید normal یا force یا highlight باشد')
]
}
const createProject = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const user = await UserModel.findById(decodedToken.id)
if (!user) {
return res.status(422).json({
error: true,
message: 'شما دسترسی به این بخش ندارید'
})
}
if (user.is_verified !== 'verified') {
return res.status(422).json({
error: true,
message: 'مدارک شما تایید نشده است'
})
}
// اعتبارسنجی درخواست
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
const {
title,
expertise,
sub_expertise,
gender,
age,
conversation_projects,
province,
city,
offer_time,
offer_price,
description,
project_type,
public_status,
created_for_user,
number_of_person
} = req.body
if (
!title ||
!expertise ||
!age ||
conversation_projects === null ||
!province ||
!city ||
!offer_time ||
!offer_price ||
!description ||
!project_type
) {
return res.status(422).json({
error: true,
message: 'اطلاعات ارسالی اشتباه است'
})
}
const provinceFind = await ProvinceModel.findOne(
{ id: province },
{ _id: 0 }
)
const cityFind = await CityModel.findOne({ id: city })
// اگر پروژه برای یک کاربر خاص باید ساخته شود
if (public_status && public_status === 'private' && created_for_user) {
const createdUser = await UserModel.findById(created_for_user)
if (!createdUser) {
return res.status(422).json({
error: true, message: 'کاربر مورد نظر برای ساخت پروژه یافت نشد'
})
}
// ایجاد شیء پروژه
const projectData = {
title,
expertise,
sub_expertise,
gender,
age,
conversation_projects,
province,
city,
offer_time,
offer_price,
description,
project_type,
creator_id: user._id, // ارتباط با کاربر
public_status,
created_for_user
}
// ساخت شیء پروژه
const project = new ProjectModel(projectData)
// ذخیره کردن رکورد در پایگاه داده
await project.save()
res.status(201).json({ message: 'پروژه با موفقیت ساخته شد', id: project._id }
)
} else {
if (number_of_person > 1) {
for (let i = 1; i <= number_of_person; i++) {
let currentProjectType
if (project_type === 'free' && i === 1) {
currentProjectType = 'free'
} else if (project_type === 'highlight' || project_type === 'force') {
currentProjectType = project_type
} else {
currentProjectType = 'normal'
}
const newProject = new ProjectModel({
title: `${title}_${i}`,
expertise,
sub_expertise,
gender,
age,
conversation_projects,
province: provinceFind,
city: cityFind,
offer_time,
offer_price,
description,
creator_id: user._id, // ارتباط با کاربر
project_type: currentProjectType,
public_status,
created_for_user
})
// ذخیره کردن رکورد در پایگاه داده
await newProject.save()
}
res.status(201).json({
message: 'پروژه‌ها با موفقیت ساخته شدند'
})
} else {
const project = new ProjectModel({
title,
expertise,
sub_expertise,
gender,
age,
conversation_projects,
province: provinceFind,
city: cityFind,
offer_time,
offer_price,
description,
creator_id: user._id, // ارتباط با کاربر
project_type
})
// ذخیره کردن رکورد در پایگاه داده
await project.save()
res.status(201).json({
message: 'پروژه با موفقیت ساخته شد',
id: project._id
})
}
}
} catch (error) {
next(error)
}
}
module.exports = {
createProjectValidationRules,
createProject
}

Some files were not shown because too many files have changed in this diff Show More