Compare commits
4 Commits
main
...
29d5830d54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d5830d54 | ||
|
|
2c6d8a4434 | ||
|
|
a7e4f68495 | ||
|
|
5719818b9d |
58
.env.example
58
.env.example
@@ -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
9
.gitignore
vendored
@@ -6,9 +6,8 @@ logs/*
|
||||
node_modules/
|
||||
*.mp4
|
||||
*.mov
|
||||
storage/courses/videos/*.mp4
|
||||
storage/posts/videos/*.mp4
|
||||
*.mp4
|
||||
*.mov
|
||||
*.avi
|
||||
|
||||
# فایلهای آپلودی کاربران (عکس/ویدیو محصولات، پستها، پروفایل، استوری، دورهها) —
|
||||
# روی سرور واقعی وجود دارن، نیازی نیست توی گیت ذخیره بشن و فقط حجم رپو رو زیاد میکنن
|
||||
storage/*
|
||||
!storage/.gitkeep
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
const startMongoDB = require('./mongo')
|
||||
const { connectRedis } = require('../utils/redisClient')
|
||||
|
||||
startMongoDB()
|
||||
connectRedis()
|
||||
|
||||
@@ -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
|
||||
|
||||
74
controllers/application/academy/academyCategoryController.js
Normal file
74
controllers/application/academy/academyCategoryController.js
Normal 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
72
controllers/application/academy/helpers/chatHelpers.js
Normal file
72
controllers/application/academy/helpers/chatHelpers.js
Normal 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
|
||||
}
|
||||
357
controllers/application/academy/index.js
Normal file
357
controllers/application/academy/index.js
Normal 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}`)
|
||||
})
|
||||
}
|
||||
18
controllers/application/academy/logs/sms-irs/13-03-2024.log
Normal file
18
controllers/application/academy/logs/sms-irs/13-03-2024.log
Normal 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"}
|
||||
9
controllers/application/academy/middlewares/404.js
Normal file
9
controllers/application/academy/middlewares/404.js
Normal 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!'
|
||||
})
|
||||
})
|
||||
}
|
||||
55
controllers/application/academy/middlewares/adminAuth.js
Normal file
55
controllers/application/academy/middlewares/adminAuth.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
21
controllers/application/academy/middlewares/auth.js
Normal file
21
controllers/application/academy/middlewares/auth.js
Normal 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()
|
||||
}
|
||||
47
controllers/application/academy/middlewares/blockCheck.js
Normal file
47
controllers/application/academy/middlewares/blockCheck.js
Normal 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'
|
||||
})
|
||||
}
|
||||
}
|
||||
11
controllers/application/academy/middlewares/exception.js
Normal file
11
controllers/application/academy/middlewares/exception.js
Normal 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: 'خطایی در عملیات مورد نظر رخ داده است.'
|
||||
})
|
||||
})
|
||||
}
|
||||
17
controllers/application/academy/middlewares/index.js
Normal file
17
controllers/application/academy/middlewares/index.js
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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.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'
|
||||
})
|
||||
}
|
||||
}
|
||||
49
controllers/application/academy/middlewares/isRegister.js
Normal file
49
controllers/application/academy/middlewares/isRegister.js
Normal file
@@ -0,0 +1,49 @@
|
||||
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.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'
|
||||
})
|
||||
}
|
||||
}
|
||||
21
controllers/application/academy/middlewares/lastOnline.js
Normal file
21
controllers/application/academy/middlewares/lastOnline.js
Normal 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()
|
||||
}
|
||||
24
controllers/application/academy/middlewares/upload.js
Normal file
24
controllers/application/academy/middlewares/upload.js
Normal 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;
|
||||
190
controllers/application/academy/models/AcademyCategoryModel.js
Normal file
190
controllers/application/academy/models/AcademyCategoryModel.js
Normal 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);
|
||||
@@ -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;
|
||||
25
controllers/application/academy/models/AcademyLikeModel.js
Normal file
25
controllers/application/academy/models/AcademyLikeModel.js
Normal 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;
|
||||
55
controllers/application/academy/models/AcademyModel.js
Normal file
55
controllers/application/academy/models/AcademyModel.js
Normal 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;
|
||||
@@ -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;
|
||||
80
controllers/application/academy/models/AdminModel.js
Normal file
80
controllers/application/academy/models/AdminModel.js
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
230
controllers/application/academy/models/AdvertisingModel.js
Normal file
230
controllers/application/academy/models/AdvertisingModel.js
Normal 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
|
||||
113
controllers/application/academy/models/AdvertisingProfile.js
Normal file
113
controllers/application/academy/models/AdvertisingProfile.js
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
54
controllers/application/academy/models/CommentModel.js
Normal file
54
controllers/application/academy/models/CommentModel.js
Normal 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
|
||||
40
controllers/application/academy/models/CourseComentModel.js
Normal file
40
controllers/application/academy/models/CourseComentModel.js
Normal 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;
|
||||
79
controllers/application/academy/models/CourseModel.js
Normal file
79
controllers/application/academy/models/CourseModel.js
Normal 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;
|
||||
22
controllers/application/academy/models/CoursePaymentModel.js
Normal file
22
controllers/application/academy/models/CoursePaymentModel.js
Normal 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;
|
||||
47
controllers/application/academy/models/ExpertiseModel.js
Normal file
47
controllers/application/academy/models/ExpertiseModel.js
Normal 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
|
||||
@@ -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;
|
||||
25
controllers/application/academy/models/LikeModel.js
Normal file
25
controllers/application/academy/models/LikeModel.js
Normal 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
|
||||
51
controllers/application/academy/models/MessageModel.js
Normal file
51
controllers/application/academy/models/MessageModel.js
Normal 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
|
||||
37
controllers/application/academy/models/NotificationModel.js
Normal file
37
controllers/application/academy/models/NotificationModel.js
Normal 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
|
||||
32
controllers/application/academy/models/OfferModel.js
Normal file
32
controllers/application/academy/models/OfferModel.js
Normal 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
|
||||
21
controllers/application/academy/models/OfferTypeModel.js
Normal file
21
controllers/application/academy/models/OfferTypeModel.js
Normal 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
|
||||
46
controllers/application/academy/models/PaymentModel.js
Normal file
46
controllers/application/academy/models/PaymentModel.js
Normal 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
|
||||
60
controllers/application/academy/models/PostModel.js
Normal file
60
controllers/application/academy/models/PostModel.js
Normal 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;
|
||||
186
controllers/application/academy/models/ProjectModel.js
Normal file
186
controllers/application/academy/models/ProjectModel.js
Normal 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
|
||||
33
controllers/application/academy/models/RequestModel.js
Normal file
33
controllers/application/academy/models/RequestModel.js
Normal 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
|
||||
19
controllers/application/academy/models/SettingsModel.js
Normal file
19
controllers/application/academy/models/SettingsModel.js
Normal 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
|
||||
19
controllers/application/academy/models/StateCity.js
Normal file
19
controllers/application/academy/models/StateCity.js
Normal 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 }
|
||||
15
controllers/application/academy/models/TaxModel.js
Normal file
15
controllers/application/academy/models/TaxModel.js
Normal 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;
|
||||
20
controllers/application/academy/models/TicketMessageModel.js
Normal file
20
controllers/application/academy/models/TicketMessageModel.js
Normal 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
|
||||
24
controllers/application/academy/models/TicketModel.js
Normal file
24
controllers/application/academy/models/TicketModel.js
Normal 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
|
||||
21
controllers/application/academy/models/TypeAndPriceModel.js
Normal file
21
controllers/application/academy/models/TypeAndPriceModel.js
Normal 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
|
||||
381
controllers/application/academy/models/UserModel.js
Normal file
381
controllers/application/academy/models/UserModel.js
Normal 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
|
||||
12
controllers/application/academy/models/VersionModel.js
Normal file
12
controllers/application/academy/models/VersionModel.js
Normal 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
|
||||
14
controllers/application/academy/models/license.js
Normal file
14
controllers/application/academy/models/license.js
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,195 +1,138 @@
|
||||
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.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
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user