Compare commits
2 Commits
59c7c8e11c
...
51eb7d0224
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51eb7d0224 | ||
|
|
0b01b262fa |
@@ -15,6 +15,13 @@ const CourseComment = require("../../../models/CourseComentModel");
|
||||
const IsPaymentCourse = require("../../../models/IsPaymentCourseModel");
|
||||
const Tax = require("../../../models/TaxModel");
|
||||
const AcademyCategoryModel = require('../../../models/AcademyCategoryModel');
|
||||
const {
|
||||
createLikeNotification,
|
||||
resolveCourseOwnerId
|
||||
} = require('../../../utils/likeNotification');
|
||||
const {
|
||||
createCommentNotification
|
||||
} = require('../../../utils/commentNotification');
|
||||
|
||||
const onServerRestart = async () => {
|
||||
const CoursePayment = await CoursePaymentModel.findOne({
|
||||
@@ -1178,6 +1185,14 @@ const likeCourseContent = async (req, res, next) => {
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const courseOwnerId = await resolveCourseOwnerId(course);
|
||||
await createLikeNotification({
|
||||
ownerId: courseOwnerId,
|
||||
likerId: userId,
|
||||
entityId: course._id,
|
||||
type: 'academy_like'
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "دوره با موفقیت لایک شد",
|
||||
@@ -1380,12 +1395,28 @@ const createComment = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const parsedRate = Number(rate) || 0;
|
||||
const existingRatedComment = await CourseComment.findOne({
|
||||
user_id: userId,
|
||||
course_id,
|
||||
rate: { $gt: 0 },
|
||||
});
|
||||
|
||||
if (existingRatedComment && parsedRate > 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: "شما قبلاً به این دوره امتیاز دادهاید.",
|
||||
});
|
||||
}
|
||||
|
||||
const finalRate = existingRatedComment ? 0 : parsedRate;
|
||||
|
||||
// ایجاد کامنت جدید
|
||||
const newComment = await CourseComment.create({
|
||||
user_id: userId,
|
||||
course_id: course_id,
|
||||
comment: comment,
|
||||
rate: rate || 0,
|
||||
rate: finalRate,
|
||||
status: "accepted", // با توجه به مدل شما که default: 'accepted' است
|
||||
});
|
||||
|
||||
@@ -1394,6 +1425,14 @@ const createComment = async (req, res) => {
|
||||
newComment._id
|
||||
).populate("user_id", "name username");
|
||||
|
||||
const courseOwnerId = await resolveCourseOwnerId(course);
|
||||
await createCommentNotification({
|
||||
ownerId: courseOwnerId,
|
||||
commenterId: userId,
|
||||
entityId: course._id,
|
||||
type: 'academy_comment'
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "کامنت با موفقیت ثبت شد",
|
||||
@@ -4161,6 +4200,84 @@ const getFeatured = async (req, res) => {
|
||||
};
|
||||
|
||||
|
||||
const getFreeAcademyExploreContent = async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const limit = parseInt(req.query.limit, 10) || 15;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = { is_free: true, status: "accept" };
|
||||
const totalItems = await AcademyContentModel.countDocuments(query);
|
||||
const contents = await AcademyContentModel.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean();
|
||||
|
||||
const courseIds = [
|
||||
...new Set(contents.map((item) => item.courseId).filter(Boolean)),
|
||||
];
|
||||
const courses = courseIds.length
|
||||
? await CourseModel.find({ _id: { $in: courseIds } })
|
||||
.select(
|
||||
"cuorse_name course_image teacher_name academyId user_id caption likes"
|
||||
)
|
||||
.lean()
|
||||
: [];
|
||||
const courseMap = Object.fromEntries(
|
||||
courses.map((course) => [String(course._id), course])
|
||||
);
|
||||
|
||||
const userIds = [
|
||||
...new Set(courses.map((course) => String(course.user_id)).filter(Boolean)),
|
||||
];
|
||||
const users = userIds.length
|
||||
? await UserModel.find({ _id: { $in: userIds } })
|
||||
.select("user_name first_name last_name profile_image")
|
||||
.lean()
|
||||
: [];
|
||||
const userMap = Object.fromEntries(
|
||||
users.map((user) => [String(user._id), user])
|
||||
);
|
||||
|
||||
const items = contents.map((content) => {
|
||||
const course = courseMap[String(content.courseId)] || {};
|
||||
const user = userMap[String(course.user_id)] || {};
|
||||
return {
|
||||
...content,
|
||||
course_name: course.cuorse_name || content.file_name || "",
|
||||
course_image: course.course_image || "",
|
||||
teacher_name: course.teacher_name || "",
|
||||
academyId: course.academyId || "",
|
||||
user_name: user.user_name || "",
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
profile_image: user.profile_image || "",
|
||||
likesCount: Array.isArray(course.likes) ? course.likes.length : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(totalItems / limit) || 1;
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages,
|
||||
totalItems,
|
||||
hasNextPage: page < totalPages,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in getFreeAcademyExploreContent:", error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
// ==========================================
|
||||
// 🏢 ماژول آکادمی (Academy)
|
||||
@@ -4211,6 +4328,9 @@ module.exports = {
|
||||
|
||||
// دریافت لیست همه دورهها با فیلتر و جستجو
|
||||
getCourses,
|
||||
|
||||
// محتوای رایگان آموزشگاه برای اکسپلور
|
||||
getFreeAcademyExploreContent,
|
||||
|
||||
// دریافت دورههای یک آکادمی خاص
|
||||
getAcademyCourse,
|
||||
|
||||
@@ -14,6 +14,11 @@ const AdvertisingLikeModel = require('../../../models/AdvertisingLikeModel')
|
||||
const AdvertisingComment = require('../../../models/AdvertisingCommentModel')
|
||||
const AdvertisingRatingModel = require('../../../models/AdvertisingRatingModel')
|
||||
const AdvertisingProfileModel = require('../../../models/AdvertisingProfile')
|
||||
const { createLikeNotification } = require('../../../utils/likeNotification')
|
||||
const {
|
||||
createCommentNotification,
|
||||
createRatingNotification
|
||||
} = require('../../../utils/commentNotification')
|
||||
|
||||
const createAdvertisingValidationRules = () => {
|
||||
return [
|
||||
@@ -698,6 +703,14 @@ const toggleLike = async (req, res, next) => {
|
||||
// افزایش تعداد لایکها در تبلیغات
|
||||
advertising.likesCount += 1
|
||||
await advertising.save()
|
||||
|
||||
await createLikeNotification({
|
||||
ownerId: advertising.creator_id,
|
||||
likerId: userId,
|
||||
entityId: advertising._id,
|
||||
type: 'billboard_like'
|
||||
})
|
||||
|
||||
return res.status(201).json({ message: 'ویترین با موفقیت لایک شد' })
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -727,8 +740,16 @@ const addComment = async (req, res, next) => {
|
||||
})
|
||||
|
||||
await newComment.save()
|
||||
// افزایش تعداد کامنتهای تبلیغ
|
||||
// await AdvertisingModel.findByIdAndUpdate(advertisingId, { $inc: { commentsCount: 1 } })
|
||||
|
||||
const advertising = await AdvertisingModel.findById(advertisingId)
|
||||
if (advertising) {
|
||||
await createCommentNotification({
|
||||
ownerId: advertising.creator_id,
|
||||
commenterId: userId,
|
||||
entityId: advertising._id,
|
||||
type: 'billboard_comment'
|
||||
})
|
||||
}
|
||||
|
||||
res.status(201).json({ message: 'کامنت با موفقیت اضافه شد', comment: newComment })
|
||||
} catch (error) {
|
||||
@@ -783,6 +804,13 @@ const rateAdvertising = async (req, res, next) => {
|
||||
rating
|
||||
})
|
||||
await newRating.save()
|
||||
|
||||
await createRatingNotification({
|
||||
ownerId: advertising.creator_id,
|
||||
raterId: userId,
|
||||
entityId: advertising._id,
|
||||
type: 'billboard_rating'
|
||||
})
|
||||
}
|
||||
|
||||
// بهروزرسانی امتیازات کلی کاربر
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -34,18 +35,22 @@ const loginUser = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
const otpSentAt = new Date()
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const user = await UserModel.findOneAndUpdate(
|
||||
{ mobile },
|
||||
{ $set: { mobile, otp } },
|
||||
{ $set: { mobile, otp: String(otp), otpSentAt } },
|
||||
{ upsert: true, new: true, lean: true }
|
||||
)
|
||||
// زمانبندی تنظیم مقدار otp به null پس از 10 دقیقه
|
||||
setTimeout(() => {
|
||||
UserModel.findOneAndUpdate({ mobile }, { $set: { otp: null } }, { new: true })
|
||||
UserModel.findOneAndUpdate(
|
||||
{ mobile },
|
||||
{ $set: { otp: null, otpSentAt: null } },
|
||||
{ new: true }
|
||||
)
|
||||
.then(() => {})
|
||||
.catch(error => console.error('Error setting OTP to null:', error))
|
||||
}, 5 * 60 * 1000)
|
||||
}, OTP_VALID_MS)
|
||||
const data = JSON.stringify({
|
||||
mobile,
|
||||
templateId: '930719',
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const bcrypt = require('bcryptjs')
|
||||
const TokenService = require('../../../services/TokenService')
|
||||
const {
|
||||
expireBlockSuspensionIfNeeded,
|
||||
getBlockedAccountResponse
|
||||
} = require('../../../utils/blockSuspension')
|
||||
// const jwt = require('jsonwebtoken')
|
||||
|
||||
const INVALID_CREDENTIALS = 'نام کاربری یا کلمه عبور را اشتباه وارد کردید'
|
||||
|
||||
const loginWithUserName = async (req, res, next) => {
|
||||
try {
|
||||
// اعتبارسنجی ورودی ها
|
||||
@@ -11,30 +17,36 @@ const loginWithUserName = async (req, res, next) => {
|
||||
if (!user_name || !password) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'اطلاعات ارسالی اشتباه است'
|
||||
message: INVALID_CREDENTIALS
|
||||
})
|
||||
}
|
||||
// یافتن کاربر با نام کاربری
|
||||
const userLow = user_name.toLowerCase()
|
||||
const user = await UserModel.findOne({ user_name: userLow })
|
||||
let user = await UserModel.findOne({ user_name: userLow })
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'کاربری با این نام کاربری یافت نشد'
|
||||
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: 'رمز عبور برای این کاربر تنظیم نشده است'
|
||||
message: INVALID_CREDENTIALS
|
||||
})
|
||||
}
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password)
|
||||
if (!isPasswordValid) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'نام کاربری یا رمز عبور اشتباه است'
|
||||
message: INVALID_CREDENTIALS
|
||||
})
|
||||
}
|
||||
// اگر هم نام کاربری و هم رمز عبور درست بود، ارسال پیام موفقیت آمیز
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const TokenService = require('../../../services/TokenService')
|
||||
const { isOtpExpired } = require('../../../utils/otpExpiry')
|
||||
const {
|
||||
expireBlockSuspensionIfNeeded,
|
||||
getBlockedAccountResponse
|
||||
} = require('../../../utils/blockSuspension')
|
||||
// const jwt = require('jsonwebtoken')
|
||||
|
||||
const verifyUser = async (req, res, next) => {
|
||||
@@ -12,15 +17,30 @@ const verifyUser = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
const user = await UserModel.findOne({ mobile })
|
||||
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 === otp) {
|
||||
|
||||
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: 'کد تایید صحیح بود',
|
||||
@@ -77,7 +97,7 @@ const verifyUser = async (req, res, next) => {
|
||||
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'کد تایید اشتباه است'
|
||||
message: 'کد را اشتباه وارد کردید'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
|
||||
@@ -2,6 +2,10 @@ const MessageModel = require('../../../models/MessageModel')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const moment = require('moment-jalaali')
|
||||
const {
|
||||
viewerBlockedUser,
|
||||
userBlockedViewer,
|
||||
} = require('../../../utils/blockVisibility')
|
||||
const getMessages = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization').split(' ')[1]
|
||||
@@ -74,8 +78,8 @@ const getMessages = async (req, res, next) => {
|
||||
}
|
||||
|
||||
// بررسی آیا کاربر فعلی در لیست بلاکشدههای این کاربر است یا خیر
|
||||
const blockedByCurrentUser = user.blocked_by.includes(userId)
|
||||
const blockedYou = user.blocked_users.includes(userId)
|
||||
const blockedByCurrentUser = viewerBlockedUser(user, userId)
|
||||
const blockedYou = userBlockedViewer(user, userId)
|
||||
|
||||
return {
|
||||
first_name: user.first_name,
|
||||
|
||||
@@ -8,6 +8,10 @@ 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 getOfferPriceFromDatabase = async (itemName) => {
|
||||
const item = await OfferTypeModel.findOne({ name: itemName })
|
||||
@@ -374,55 +378,35 @@ const createOfferComment = async (req, res, next) => {
|
||||
const userId = decodedToken.id
|
||||
|
||||
const { offerId, comment, rate, user_id } = req.body
|
||||
if (!offerId || !comment || !rate || !user_id) {
|
||||
if (!offerId || !comment || !user_id) {
|
||||
return res.status(400).send({ message: 'All fields are required' })
|
||||
}
|
||||
// بررسی اینکه آیا کاربر قبلاً برای این پیشنهاد نظر داده است
|
||||
const existingComment = await CommentModel.findOne({
|
||||
offer: offerId,
|
||||
creator: userId
|
||||
|
||||
const { ratingValue, isNewRating, requiresRating } = await resolveRatingForComment({
|
||||
creatorId: userId,
|
||||
targetUserId: user_id,
|
||||
rate
|
||||
})
|
||||
if (existingComment) {
|
||||
return res.status(400).send({ message: 'شما قبلاً نظر خود را برای این درخواست ثبت کردهاید' })
|
||||
|
||||
if (requiresRating) {
|
||||
return res.status(400).send({ message: 'Rating must be between 1 and 5' })
|
||||
}
|
||||
|
||||
const newComment = new CommentModel({
|
||||
user: user_id,
|
||||
offer: offerId,
|
||||
creator: userId,
|
||||
rating: rate,
|
||||
rating: ratingValue,
|
||||
comment,
|
||||
comment_for: 'user',
|
||||
status: 'pending'
|
||||
})
|
||||
await newComment.save()
|
||||
|
||||
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 = 'استاد'
|
||||
if (isNewRating) {
|
||||
await recalculateUserRating(user_id)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 {
|
||||
@@ -48,6 +49,13 @@ const toggleLike = async (req, res, next) => {
|
||||
await post.save();
|
||||
await LikeModel.create({ postId, userId });
|
||||
|
||||
await createLikeNotification({
|
||||
ownerId: post.user_id,
|
||||
likerId: userId,
|
||||
entityId: post._id,
|
||||
type: 'post_like'
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'پست با موفقیت لایک شد',
|
||||
is_liked: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ const { check, validationResult } = require('express-validator');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const path = require('path');
|
||||
const { default: mongoose } = require('mongoose');
|
||||
const { viewerIsBlockedBy } = require('../../../utils/blockVisibility');
|
||||
|
||||
const createPostValidationRules = () => {
|
||||
console.log(4);
|
||||
@@ -139,6 +140,15 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
const data = {}
|
||||
const options = {
|
||||
page: req.query.page || 1,
|
||||
@@ -174,9 +184,89 @@ const getUserPostsWeb = async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
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 || post.status !== 'accept') {
|
||||
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'
|
||||
)
|
||||
.lean()
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'پست یافت نشد' })
|
||||
}
|
||||
|
||||
if (userReqId && viewerIsBlockedBy(user, userReqId)) {
|
||||
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: 'accepted',
|
||||
})
|
||||
|
||||
return res.status(200).json({
|
||||
post: {
|
||||
...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,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPostValidationRules,
|
||||
createPost,
|
||||
getUserPosts,
|
||||
getUserPostsWeb
|
||||
getUserPostsWeb,
|
||||
getPostByIdWeb
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const getUserById = async (req, res, next) => {
|
||||
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,
|
||||
|
||||
@@ -4,6 +4,10 @@ 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 createProjectValidationRules = () => {
|
||||
return [
|
||||
check('title').notEmpty().withMessage('عنوان نمیتواند خالی باشد'),
|
||||
@@ -59,11 +63,8 @@ const createProject = async (req, res, next) => {
|
||||
message: 'شما دسترسی به این بخش ندارید'
|
||||
})
|
||||
}
|
||||
if (user.is_verified !== 'verified') {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'مدارک شما تایید نشده است'
|
||||
})
|
||||
if (!canCreateProject(user)) {
|
||||
return respondProjectProfileIncomplete(res)
|
||||
}
|
||||
// اعتبارسنجی درخواست
|
||||
const errors = validationResult(req)
|
||||
|
||||
@@ -24,7 +24,7 @@ const requestProject = async (req, res, next) => {
|
||||
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 || user.user_type !== 'user') {
|
||||
if (!user) {
|
||||
return res.status(422).json({
|
||||
error: true, message: 'شما دسترسی به این بخش ندارید'
|
||||
})
|
||||
@@ -47,6 +47,21 @@ const requestProject = async (req, res, next) => {
|
||||
message: 'اطلاعات ارسالی اشتباه است'
|
||||
})
|
||||
}
|
||||
|
||||
const project = await ProjectModel.findById(project_id)
|
||||
if (!project) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'پروژه یافت نشد'
|
||||
})
|
||||
}
|
||||
if (String(project.creator_id) === String(user._id)) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'نمیتوانید برای پروژه خودتان درخواست ارسال کنید'
|
||||
})
|
||||
}
|
||||
|
||||
// چک کردن برای وجود درخواست قبلی
|
||||
const existingRequest = await RequestModel.findOne({ project: project_id, user: user._id })
|
||||
if (existingRequest) {
|
||||
@@ -65,7 +80,6 @@ const requestProject = async (req, res, next) => {
|
||||
await request.save()
|
||||
|
||||
// ثبت اعلان برای سازنده پروژه
|
||||
const project = await ProjectModel.findById(project_id)
|
||||
const creatorId = project.creator_id
|
||||
|
||||
const notification = new NotificationModel({
|
||||
@@ -130,7 +144,7 @@ const editRequestProject = async (req, res, next) => {
|
||||
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 || user.user_type !== 'user') {
|
||||
if (!user) {
|
||||
return res.status(422).json({
|
||||
error: true, message: 'شما دسترسی به این بخش ندارید'
|
||||
})
|
||||
|
||||
@@ -6,6 +6,54 @@ const { check, validationResult } = require('express-validator')
|
||||
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
|
||||
const NotificationModel = require('../../../models/NotificationModel')
|
||||
const CommentModel = require('../../../models/CommentModel')
|
||||
const {
|
||||
resolveRatingForComment,
|
||||
recalculateUserRating
|
||||
} = require('../../../utils/commentRating')
|
||||
const {
|
||||
canCreateProject,
|
||||
respondProjectProfileIncomplete
|
||||
} = require('../../../utils/projectProfile')
|
||||
|
||||
const saveProjectComment = async ({
|
||||
targetUserId,
|
||||
projectId,
|
||||
creatorId,
|
||||
comment,
|
||||
rate,
|
||||
commentFor
|
||||
}) => {
|
||||
const { ratingValue, isNewRating, requiresRating, alreadyRated } = await resolveRatingForComment({
|
||||
creatorId,
|
||||
targetUserId,
|
||||
rate
|
||||
})
|
||||
|
||||
if (alreadyRated) {
|
||||
return { error: { status: 400, message: 'شما قبلاً به این کاربر امتیاز دادهاید.' } }
|
||||
}
|
||||
|
||||
if (requiresRating) {
|
||||
return { error: { status: 400, message: 'Rating must be between 1 and 5' } }
|
||||
}
|
||||
|
||||
const newComment = new CommentModel({
|
||||
user: targetUserId,
|
||||
project: projectId,
|
||||
creator: creatorId,
|
||||
rating: ratingValue,
|
||||
comment,
|
||||
comment_for: commentFor,
|
||||
status: 'pending'
|
||||
})
|
||||
await newComment.save()
|
||||
|
||||
if (isNewRating && commentFor === 'user') {
|
||||
await recalculateUserRating(targetUserId)
|
||||
}
|
||||
|
||||
return { ratingValue, isNewRating }
|
||||
}
|
||||
|
||||
const doneProject = async (req, res, next) => {
|
||||
try {
|
||||
@@ -16,7 +64,7 @@ const doneProject = async (req, res, next) => {
|
||||
const userId = decodedToken.id
|
||||
|
||||
const { project_id, comment, rate, user_id } = req.body
|
||||
if (!project_id || !comment || !rate || !user_id) {
|
||||
if (!project_id || !comment || !user_id) {
|
||||
return res.status(400).send({ message: 'All fields are required' })
|
||||
}
|
||||
|
||||
@@ -42,43 +90,20 @@ const doneProject = async (req, res, next) => {
|
||||
project.status = 'done'
|
||||
await project.save()
|
||||
|
||||
const newComment = new CommentModel({
|
||||
user: user_id,
|
||||
project: project_id,
|
||||
creator: userId,
|
||||
rating: rate,
|
||||
const commentResult = await saveProjectComment({
|
||||
targetUserId: user_id,
|
||||
projectId: project_id,
|
||||
creatorId: userId,
|
||||
comment,
|
||||
comment_for: 'user',
|
||||
status: 'pending'
|
||||
rate,
|
||||
commentFor: 'user'
|
||||
})
|
||||
await newComment.save()
|
||||
|
||||
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 = 'استاد'
|
||||
if (commentResult.error) {
|
||||
return res.status(commentResult.error.status).send({ message: commentResult.error.message })
|
||||
}
|
||||
|
||||
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()
|
||||
const user = await UserModel.findById(user_id)
|
||||
|
||||
const notification = new NotificationModel({
|
||||
user_id: user._id,
|
||||
@@ -105,7 +130,7 @@ const doneProjectWeb = async (req, res, next) => {
|
||||
const userId = decodedToken.id
|
||||
|
||||
const { project_id, comment, rate, user_id } = req.body
|
||||
if (!project_id || !comment || !rate || !user_id) {
|
||||
if (!project_id || !comment || !user_id) {
|
||||
return res.status(400).send({ message: 'All fields are required' })
|
||||
}
|
||||
|
||||
@@ -117,43 +142,20 @@ const doneProjectWeb = async (req, res, next) => {
|
||||
project.status = 'done'
|
||||
await project.save()
|
||||
|
||||
const newComment = new CommentModel({
|
||||
user: user_id,
|
||||
project: project_id,
|
||||
creator: userId,
|
||||
rating: rate,
|
||||
const commentResult = await saveProjectComment({
|
||||
targetUserId: user_id,
|
||||
projectId: project_id,
|
||||
creatorId: userId,
|
||||
comment,
|
||||
comment_for: 'user',
|
||||
status: 'pending'
|
||||
rate,
|
||||
commentFor: 'user'
|
||||
})
|
||||
await newComment.save()
|
||||
|
||||
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 = 'استاد'
|
||||
if (commentResult.error) {
|
||||
return res.status(commentResult.error.status).send({ message: commentResult.error.message })
|
||||
}
|
||||
|
||||
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()
|
||||
const user = await UserModel.findById(user_id)
|
||||
|
||||
const notification = new NotificationModel({
|
||||
user_id: user._id,
|
||||
@@ -182,7 +184,7 @@ const cancleProject = async (req, res, next) => {
|
||||
|
||||
const { project_id, comment, rate, user_id } = req.body
|
||||
|
||||
if (!project_id || !comment || !rate || !user_id) {
|
||||
if (!project_id || !comment || !user_id) {
|
||||
return res.status(400).send({ message: 'All fields are required' })
|
||||
}
|
||||
|
||||
@@ -194,16 +196,18 @@ const cancleProject = async (req, res, next) => {
|
||||
project.status = 'cancled'
|
||||
await project.save()
|
||||
|
||||
const newComment = new CommentModel({
|
||||
user: user_id,
|
||||
project: project_id,
|
||||
creator: userId,
|
||||
rating: rate,
|
||||
const commentResult = await saveProjectComment({
|
||||
targetUserId: user_id,
|
||||
projectId: project_id,
|
||||
creatorId: userId,
|
||||
comment,
|
||||
comment_for: 'project',
|
||||
status: 'pending'
|
||||
rate,
|
||||
commentFor: 'project'
|
||||
})
|
||||
await newComment.save()
|
||||
|
||||
if (commentResult.error) {
|
||||
return res.status(commentResult.error.status).send({ message: commentResult.error.message })
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'پروژه با موفقیت تغییر یافت شد' })
|
||||
} catch (error) {
|
||||
@@ -260,11 +264,8 @@ const editProject = async (req, res, next) => {
|
||||
message: 'شما دسترسی به این بخش ندارید'
|
||||
})
|
||||
}
|
||||
if (user.is_verified !== 'verified') {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'مدارک شما تایید نشده است'
|
||||
})
|
||||
if (!canCreateProject(user)) {
|
||||
return respondProjectProfileIncomplete(res)
|
||||
}
|
||||
|
||||
const errors = validationResult(req)
|
||||
@@ -363,6 +364,14 @@ const setRateProject = async (req, res, next) => {
|
||||
if (!project) {
|
||||
return res.status(403).send({ message: 'Access denied: You are not the creator of this project.' })
|
||||
}
|
||||
|
||||
const alreadyRated = project.ratings.some((item) =>
|
||||
item.creator_id.toString() === userId.toString()
|
||||
)
|
||||
if (alreadyRated) {
|
||||
return res.status(400).send({ message: 'شما قبلاً به این پروژه امتیاز دادهاید' })
|
||||
}
|
||||
|
||||
project.ratings.push({
|
||||
project_id,
|
||||
rating: rate,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { default: axios } = require('axios')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const { check, validationResult } = require('express-validator')
|
||||
const { OTP_VALID_MS } = require('../../../utils/otpExpiry')
|
||||
// const { Token, VerificationCode } = require('sms-ir')
|
||||
|
||||
const registerValidationRules = () => {
|
||||
@@ -36,17 +37,22 @@ const registerUser = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
const otpSentAt = new Date()
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const user = await UserModel.findOneAndUpdate(
|
||||
{ mobile },
|
||||
{ $set: { mobile, otp } },
|
||||
{ $set: { mobile, otp: String(otp), otpSentAt } },
|
||||
{ upsert: true, new: true, lean: true }
|
||||
)
|
||||
setTimeout(() => {
|
||||
UserModel.findOneAndUpdate({ mobile }, { $set: { otp: null } }, { new: true })
|
||||
UserModel.findOneAndUpdate(
|
||||
{ mobile },
|
||||
{ $set: { otp: null, otpSentAt: null } },
|
||||
{ new: true }
|
||||
)
|
||||
.then(() => {})
|
||||
.catch(error => console.error('Error setting OTP to null:', error))
|
||||
}, 5 * 60 * 1000)
|
||||
}, OTP_VALID_MS)
|
||||
const data = JSON.stringify({
|
||||
mobile,
|
||||
templateId: '930719',
|
||||
|
||||
@@ -34,8 +34,8 @@ const setUserFullName = async (req, res, next) => {
|
||||
}
|
||||
|
||||
// آپدیت نام کاربری به صورت lowercase
|
||||
user.first_name = first_name.toLowerCase()
|
||||
user.last_name = last_name.toLowerCase()
|
||||
user.first_name = first_name.trim().toLowerCase()
|
||||
user.last_name = last_name.trim().toLowerCase()
|
||||
await user.save()
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -5,7 +5,8 @@ const jwt = require('jsonwebtoken')
|
||||
const setUserType = async (req, res, next) => {
|
||||
try {
|
||||
const { user_type } = req.body
|
||||
if (!user_type || !['user', 'employer'].includes(user_type)) {
|
||||
const normalizedType = 'user'
|
||||
if (user_type && user_type !== 'user' && user_type !== 'employer') {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'نوع یوزر معتبر نیست'
|
||||
@@ -26,8 +27,8 @@ const setUserType = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// آپدیت نوع یوزر
|
||||
user.user_type = user_type.toLowerCase()
|
||||
// همه کاربران با نوع «user» ثبت میشوند
|
||||
user.user_type = normalizedType
|
||||
await user.save()
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable camelcase */
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { validateUsername } = require('../../../utils/usernameValidation')
|
||||
const { generateUsernameSuggestions } = require('../../../utils/usernameSuggestions')
|
||||
|
||||
const setUserName = async (req, res, next) => {
|
||||
try {
|
||||
@@ -12,7 +14,6 @@ const setUserName = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// جستجوی کاربر با شماره موبایل ارسالی
|
||||
const user = await UserModel.findOne({ mobile })
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
@@ -20,32 +21,32 @@ const setUserName = async (req, res, next) => {
|
||||
message: 'کاربری با این شماره موبایل یافت نشد'
|
||||
})
|
||||
}
|
||||
// بررسی حداقل طول نام کاربری
|
||||
if (user_name.length < 5) {
|
||||
|
||||
const validationError = validateUsername(user_name)
|
||||
if (validationError) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'نام کاربری باید حداقل 5 کاراکتر باشد'
|
||||
})
|
||||
}
|
||||
// بررسی محدودیتهای مجاز در نام کاربری
|
||||
// const usernameRegex = /^[a-zA-Z0-9_]+$/
|
||||
// if (!usernameRegex.test(user_name)) {
|
||||
// return res.status(422).json({
|
||||
// error: true,
|
||||
// message: 'نام کاربری فقط میتواند شامل حروف الفبای انگلیسی، اعداد و کاراکتر _ باشد'
|
||||
// })
|
||||
// }
|
||||
// بررسی تکراری بودن user_name
|
||||
const existingUser = await UserModel.findOne({ user_name: { $regex: new RegExp('^' + user_name + '$', 'i') } })
|
||||
if (existingUser && existingUser._id.toString() !== user._id.toString()) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'نام کاربری تکراری است'
|
||||
message: validationError
|
||||
})
|
||||
}
|
||||
|
||||
// آپدیت نام کاربری به صورت lowercase
|
||||
user.user_name = user_name.toLowerCase()
|
||||
const normalizedUserName = user_name.trim().toLowerCase()
|
||||
|
||||
const existingUser = await UserModel.findOne({ user_name: { $regex: new RegExp('^' + normalizedUserName + '$', 'i') } })
|
||||
if (existingUser && existingUser._id.toString() !== user._id.toString()) {
|
||||
const suggestions = await generateUsernameSuggestions(
|
||||
UserModel,
|
||||
normalizedUserName,
|
||||
user._id.toString()
|
||||
)
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'نام کاربری تکراری است',
|
||||
suggestions
|
||||
})
|
||||
}
|
||||
|
||||
user.user_name = normalizedUserName
|
||||
await user.save()
|
||||
|
||||
return res.json({
|
||||
@@ -73,7 +74,37 @@ const updateUserName = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
const user = await UserModel.findByIdAndUpdate(userId, { user_name }, { new: true })
|
||||
const validationError = validateUsername(user_name)
|
||||
if (validationError) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: validationError
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedUserName = user_name.trim().toLowerCase()
|
||||
|
||||
const existingUser = await UserModel.findOne({
|
||||
user_name: { $regex: new RegExp('^' + normalizedUserName + '$', 'i') }
|
||||
})
|
||||
if (existingUser && existingUser._id.toString() !== userId) {
|
||||
const suggestions = await generateUsernameSuggestions(
|
||||
UserModel,
|
||||
normalizedUserName,
|
||||
userId
|
||||
)
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'نام کاربری تکراری است',
|
||||
suggestions
|
||||
})
|
||||
}
|
||||
|
||||
const user = await UserModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ user_name: normalizedUserName },
|
||||
{ new: true }
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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) => {
|
||||
@@ -25,7 +26,14 @@ const verifyUser = async (req, res, next) => {
|
||||
(!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.otp === otp) {
|
||||
if (!user.otp || isOtpExpired(user)) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید'
|
||||
})
|
||||
}
|
||||
|
||||
if (String(user.otp) === String(otp)) {
|
||||
return res.json({
|
||||
message: 'کد تایید صحیح بود',
|
||||
token,
|
||||
@@ -35,7 +43,7 @@ const verifyUser = async (req, res, next) => {
|
||||
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'کد تایید اشتباه است'
|
||||
message: 'کد را اشتباه وارد کردید'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
|
||||
197
controllers/application/stories/storyController.js
Normal file
197
controllers/application/stories/storyController.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/* eslint-disable camelcase */
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const StoryModel = require('../../../models/StoryModel')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const { getBlockerUserIds } = require('../../../utils/blockVisibility')
|
||||
|
||||
const STORY_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const storyStorageDir = path.join(__dirname, '../../../storage/stories')
|
||||
if (!fs.existsSync(storyStorageDir)) {
|
||||
fs.mkdirSync(storyStorageDir, { recursive: true })
|
||||
}
|
||||
|
||||
function storyMediaUrl (storagePath) {
|
||||
if (!storagePath) return ''
|
||||
const normalized = storagePath.replace(/\\/g, '/')
|
||||
const idx = normalized.indexOf('/storage/')
|
||||
if (idx >= 0) return normalized.slice(idx)
|
||||
return normalized.replace('/root/modstagram-back/storage', '/storage')
|
||||
}
|
||||
|
||||
async function resolveViewerId (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 ? String(decoded.id) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const createStoryBase64 = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id
|
||||
const { file } = req.body
|
||||
|
||||
if (!file?.data || !file?.type) {
|
||||
return res.status(400).json({ error: true, message: 'فایل استوری الزامی است' })
|
||||
}
|
||||
|
||||
const mediaType = file.type.startsWith('video/') ? 'video' : 'image'
|
||||
const buffer = Buffer.from(file.data.split(',')[1] || file.data, 'base64')
|
||||
const ext = mediaType === 'video' ? '.mp4' : '.webp'
|
||||
const filename = `${Date.now()}-${userId}${ext}`
|
||||
const filePath = path.join(storyStorageDir, filename)
|
||||
await fs.promises.writeFile(filePath, buffer)
|
||||
|
||||
const story = await StoryModel.create({
|
||||
user_id: userId,
|
||||
media_path: filePath,
|
||||
media_type: mediaType,
|
||||
expires_at: new Date(Date.now() + STORY_TTL_MS),
|
||||
viewers: []
|
||||
})
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'استوری با موفقیت منتشر شد',
|
||||
storyId: story._id
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('createStoryBase64:', err)
|
||||
return res.status(500).json({ error: true, message: 'خطا در انتشار استوری' })
|
||||
}
|
||||
}
|
||||
|
||||
const getStoriesFeed = async (req, res, next) => {
|
||||
try {
|
||||
const viewerId = await resolveViewerId(req)
|
||||
const now = new Date()
|
||||
|
||||
let stories = await StoryModel.find({ expires_at: { $gt: now } })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean()
|
||||
|
||||
if (viewerId) {
|
||||
const blockerIds = new Set(
|
||||
(await getBlockerUserIds(UserModel, viewerId)).map(String)
|
||||
)
|
||||
stories = stories.filter((s) => !blockerIds.has(String(s.user_id)))
|
||||
}
|
||||
|
||||
const userIds = [...new Set(stories.map((s) => String(s.user_id)))]
|
||||
const users = await UserModel.find({ _id: { $in: userIds } })
|
||||
.select('_id user_name first_name last_name profile_image')
|
||||
.lean()
|
||||
const usersById = Object.fromEntries(users.map((u) => [String(u._id), u]))
|
||||
|
||||
const grouped = {}
|
||||
for (const story of stories) {
|
||||
const uid = String(story.user_id)
|
||||
if (!grouped[uid]) grouped[uid] = []
|
||||
grouped[uid].push({
|
||||
_id: story._id,
|
||||
media_path: storyMediaUrl(story.media_path),
|
||||
media_type: story.media_type,
|
||||
createdAt: story.createdAt,
|
||||
expires_at: story.expires_at,
|
||||
viewed: viewerId
|
||||
? story.viewers?.some((v) => String(v.user_id) === viewerId)
|
||||
: false
|
||||
})
|
||||
}
|
||||
|
||||
let feed = Object.entries(grouped).map(([uid, userStories]) => {
|
||||
const user = usersById[uid] || {}
|
||||
const hasUnviewed = viewerId
|
||||
? userStories.some((s) => !s.viewed)
|
||||
: true
|
||||
return {
|
||||
user: {
|
||||
_id: uid,
|
||||
user_name: user.user_name || '',
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
profile_image: user.profile_image || ''
|
||||
},
|
||||
stories: userStories,
|
||||
has_unviewed: hasUnviewed,
|
||||
latest_at: userStories[userStories.length - 1]?.createdAt
|
||||
}
|
||||
})
|
||||
|
||||
feed.sort((a, b) => {
|
||||
if (viewerId && a.user._id === viewerId) return -1
|
||||
if (viewerId && b.user._id === viewerId) return 1
|
||||
if (a.has_unviewed !== b.has_unviewed) return a.has_unviewed ? -1 : 1
|
||||
return new Date(b.latest_at) - new Date(a.latest_at)
|
||||
})
|
||||
|
||||
let myActiveStory = null
|
||||
if (viewerId) {
|
||||
const mine = feed.find((f) => f.user._id === viewerId)
|
||||
if (mine) myActiveStory = mine
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
feed,
|
||||
my_story: myActiveStory,
|
||||
viewer_id: viewerId
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const markStoryViewed = async (req, res) => {
|
||||
try {
|
||||
const viewerId = req.user._id
|
||||
const { storyId } = req.body
|
||||
if (!storyId) {
|
||||
return res.status(422).json({ message: 'شناسه استوری الزامی است' })
|
||||
}
|
||||
|
||||
const story = await StoryModel.findById(storyId)
|
||||
if (!story || story.expires_at <= new Date()) {
|
||||
return res.status(404).json({ message: 'استوری یافت نشد' })
|
||||
}
|
||||
|
||||
const already = story.viewers.some(
|
||||
(v) => String(v.user_id) === String(viewerId)
|
||||
)
|
||||
if (!already) {
|
||||
story.viewers.push({ user_id: viewerId, viewed_at: new Date() })
|
||||
await story.save()
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: 'ok' })
|
||||
} catch (err) {
|
||||
console.error('markStoryViewed:', err)
|
||||
return res.status(500).json({ message: 'خطا در ثبت بازدید' })
|
||||
}
|
||||
}
|
||||
|
||||
const deleteStory = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id
|
||||
const { storyId } = req.params
|
||||
const story = await StoryModel.findOne({ _id: storyId, user_id: userId })
|
||||
if (!story) {
|
||||
return res.status(404).json({ message: 'استوری یافت نشد' })
|
||||
}
|
||||
await StoryModel.deleteOne({ _id: storyId })
|
||||
return res.status(200).json({ message: 'استوری حذف شد' })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: 'خطا در حذف استوری' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createStoryBase64,
|
||||
getStoriesFeed,
|
||||
markStoryViewed,
|
||||
deleteStory
|
||||
}
|
||||
@@ -6,12 +6,207 @@ const jwt = require("jsonwebtoken");
|
||||
const moment = require("moment-jalaali");
|
||||
const NotificationModel = require("../../../models/NotificationModel");
|
||||
const CommentModel = require("../../../models/CommentModel");
|
||||
const {
|
||||
hasCreatorRatedUser,
|
||||
resolveRatingForComment,
|
||||
recalculateUserRating,
|
||||
} = require("../../../utils/commentRating");
|
||||
const { createCommentNotification } = require("../../../utils/commentNotification");
|
||||
const { ProvinceModel, CityModel } = require("../../../models/StateCity");
|
||||
const OfferModel = require("../../../models/OfferModel");
|
||||
const PostModel = require("../../../models/PostModel");
|
||||
const ProjectModel = require("../../../models/ProjectModel");
|
||||
const LicenseModel = require("../../../models/license")
|
||||
const {
|
||||
viewerIsBlockedBy,
|
||||
viewerBlockedUser,
|
||||
userBlockedViewer,
|
||||
blockedProfilePayload,
|
||||
getBlockerUserIds,
|
||||
} = require("../../../utils/blockVisibility")
|
||||
const { buildUserLevelMongoFilter, normalizeUserLevel } = require("../../../utils/userLevelFilter")
|
||||
const { paginatePersonalizedExplore } = require("../../../utils/exploreAlgorithm")
|
||||
const ExploreInteractionModel = require("../../../models/ExploreInteractionModel")
|
||||
|
||||
const EXPLORE_NEAR_RADIUS_METERS = 2000;
|
||||
|
||||
function haversineMeters(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function applyExploreFilterToUserFilter(exploreFilter, userFilter) {
|
||||
if (!exploreFilter) return;
|
||||
|
||||
switch (exploreFilter) {
|
||||
case "model":
|
||||
userFilter.expertise = "مدل";
|
||||
break;
|
||||
case "photographer":
|
||||
userFilter.expertise = "عکاس";
|
||||
break;
|
||||
case "hairstylist":
|
||||
userFilter.expertise = "آرایشگر";
|
||||
break;
|
||||
case "makeup":
|
||||
userFilter.sub_expertise = "میکاپ";
|
||||
break;
|
||||
case "professional":
|
||||
Object.assign(userFilter, buildUserLevelMongoFilter("حرفهای"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function mapPostsWithUsers(posts, users, decodedToken) {
|
||||
let filteredPosts = posts;
|
||||
|
||||
if (decodedToken?.id) {
|
||||
const blockerIds = new Set(
|
||||
(await getBlockerUserIds(UserModel, decodedToken.id)).map(String)
|
||||
);
|
||||
filteredPosts = filteredPosts.filter(
|
||||
(post) => !blockerIds.has(String(post.user_id))
|
||||
);
|
||||
}
|
||||
|
||||
const likes = decodedToken
|
||||
? await LikeModel.find({
|
||||
userId: decodedToken.id,
|
||||
postId: { $in: filteredPosts.map((p) => p._id) },
|
||||
}).lean()
|
||||
: [];
|
||||
const likesMap = {};
|
||||
likes.forEach((l) => {
|
||||
likesMap[l.postId.toString()] = true;
|
||||
});
|
||||
|
||||
const comments = await CommentModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
post: { $in: filteredPosts.map((p) => p._id) },
|
||||
status: "accepted",
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$post", count: { $sum: 1 } } },
|
||||
]);
|
||||
const commentsMap = {};
|
||||
comments.forEach((c) => {
|
||||
commentsMap[c._id.toString()] = c.count;
|
||||
});
|
||||
|
||||
return filteredPosts.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 || "",
|
||||
likesCount: post.likes ? post.likes.length : 0,
|
||||
is_liked: !!likesMap[post._id.toString()],
|
||||
commentsCount: commentsMap[post._id.toString()] || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getRankedExplorePosts({
|
||||
windowDays,
|
||||
page,
|
||||
limit,
|
||||
postFilter,
|
||||
decodedToken,
|
||||
}) {
|
||||
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
|
||||
const postIdsWithActivity = new Set();
|
||||
|
||||
const recentLikes = await LikeModel.find({ createdAt: { $gte: since } })
|
||||
.select("postId")
|
||||
.lean();
|
||||
recentLikes.forEach((like) => {
|
||||
if (like.postId) postIdsWithActivity.add(String(like.postId));
|
||||
});
|
||||
|
||||
const recentComments = await CommentModel.find({
|
||||
createdAt: { $gte: since },
|
||||
status: "accepted",
|
||||
comment_for: "post",
|
||||
post: { $ne: null },
|
||||
})
|
||||
.select("post")
|
||||
.lean();
|
||||
recentComments.forEach((comment) => {
|
||||
if (comment.post) postIdsWithActivity.add(String(comment.post));
|
||||
});
|
||||
|
||||
const recentPosts = await PostModel.find({
|
||||
...postFilter,
|
||||
createdAt: { $gte: since },
|
||||
})
|
||||
.select("_id")
|
||||
.lean();
|
||||
recentPosts.forEach((post) => postIdsWithActivity.add(String(post._id)));
|
||||
|
||||
if (!postIdsWithActivity.size) {
|
||||
return { posts: [], totalItems: 0 };
|
||||
}
|
||||
|
||||
let posts = await PostModel.find({
|
||||
...postFilter,
|
||||
_id: { $in: [...postIdsWithActivity] },
|
||||
}).lean();
|
||||
|
||||
const likeCounts = {};
|
||||
recentLikes.forEach((like) => {
|
||||
const id = String(like.postId);
|
||||
likeCounts[id] = (likeCounts[id] || 0) + 1;
|
||||
});
|
||||
|
||||
const commentCounts = {};
|
||||
recentComments.forEach((comment) => {
|
||||
const id = String(comment.post);
|
||||
commentCounts[id] = (commentCounts[id] || 0) + 1;
|
||||
});
|
||||
|
||||
posts = posts
|
||||
.map((post) => {
|
||||
const id = String(post._id);
|
||||
const score =
|
||||
(likeCounts[id] || 0) +
|
||||
(commentCounts[id] || 0) +
|
||||
(post.likes ? post.likes.length : 0) * 0.1;
|
||||
return { ...post, exploreScore: score };
|
||||
})
|
||||
.sort((a, b) => b.exploreScore - a.exploreScore);
|
||||
|
||||
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"
|
||||
)
|
||||
.lean();
|
||||
|
||||
posts = await mapPostsWithUsers(posts, users, decodedToken);
|
||||
|
||||
const totalItems = posts.length;
|
||||
const startIndex = (page - 1) * limit;
|
||||
const paginatedPosts = posts.slice(startIndex, startIndex + parseInt(limit, 10));
|
||||
|
||||
return { posts: paginatedPosts, totalItems };
|
||||
}
|
||||
|
||||
const getAllLicenses = async (req, res, next) => {
|
||||
try {
|
||||
@@ -310,7 +505,8 @@ const getUsers = async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
if (userLevel) {
|
||||
filter.user_level = userLevel;
|
||||
const levelFilter = buildUserLevelMongoFilter(userLevel)
|
||||
if (levelFilter) Object.assign(filter, levelFilter)
|
||||
}
|
||||
|
||||
let users = await UserModel.aggregate([
|
||||
@@ -761,51 +957,155 @@ const getPostsWeb = async (req, res, next) => {
|
||||
rateFilter,
|
||||
_id,
|
||||
type,
|
||||
exploreFilter,
|
||||
subExpertise,
|
||||
lat,
|
||||
lng,
|
||||
feedMode,
|
||||
seedPostId,
|
||||
} = req.query;
|
||||
|
||||
const reelsMode = feedMode === "reels" || type === "video";
|
||||
const gridMode = feedMode === "grid";
|
||||
|
||||
const postFilter = { status: { $ne: null } };
|
||||
if (type === 'video' || type === 'image') {
|
||||
postFilter.type = type;
|
||||
}
|
||||
|
||||
// اکسپلور: ویدئوهای همه کاربران بدون فیلتر تخصص
|
||||
if (type === 'video' && !expertise && !_id && !province && !city) {
|
||||
let explorePosts = await PostModel.find(postFilter)
|
||||
const parsedLat = parseFloat(lat);
|
||||
const parsedLng = parseFloat(lng);
|
||||
const hasNearMeCoords =
|
||||
exploreFilter === "near_me" &&
|
||||
Number.isFinite(parsedLat) &&
|
||||
Number.isFinite(parsedLng);
|
||||
|
||||
// اکسپلور: همه پستها (عکس/ویدئو) بدون فیلتر تخصص/مکان
|
||||
const isExploreFeed =
|
||||
!expertise &&
|
||||
!_id &&
|
||||
!province &&
|
||||
!city &&
|
||||
!userLevel &&
|
||||
!rateFilter &&
|
||||
!exploreFilter &&
|
||||
!subExpertise &&
|
||||
!hasNearMeCoords;
|
||||
|
||||
if (exploreFilter === "trending" || exploreFilter === "best_month") {
|
||||
const windowDays = exploreFilter === "trending" ? 7 : 30;
|
||||
const ranked = await getRankedExplorePosts({
|
||||
windowDays,
|
||||
page: parseInt(page, 10),
|
||||
limit: parseInt(limit, 10),
|
||||
postFilter: { ...postFilter, status: "accept" },
|
||||
decodedToken,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
posts: ranked.posts,
|
||||
totalPages: Math.ceil(ranked.totalItems / limit) || 1,
|
||||
totalItems: ranked.totalItems,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasNearMeCoords) {
|
||||
const nearbyUsers = await UserModel.find({
|
||||
show_location: true,
|
||||
lat: { $nin: [null, ""] },
|
||||
lng: { $nin: [null, ""] },
|
||||
})
|
||||
.select(
|
||||
"_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image lat lng blocked_users"
|
||||
)
|
||||
.lean();
|
||||
|
||||
const nearbyUserIds = nearbyUsers
|
||||
.filter((user) => {
|
||||
const userLat = parseFloat(user.lat);
|
||||
const userLng = parseFloat(user.lng);
|
||||
if (!Number.isFinite(userLat) || !Number.isFinite(userLng)) return false;
|
||||
return (
|
||||
haversineMeters(parsedLat, parsedLng, userLat, userLng) <=
|
||||
EXPLORE_NEAR_RADIUS_METERS
|
||||
);
|
||||
})
|
||||
.map((user) => user._id);
|
||||
|
||||
if (!nearbyUserIds.length) {
|
||||
return res.status(200).json({ posts: [], totalPages: 0, totalItems: 0 });
|
||||
}
|
||||
|
||||
let posts = await PostModel.find({
|
||||
...postFilter,
|
||||
status: "accept",
|
||||
user_id: { $in: nearbyUserIds },
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
const users = nearbyUsers.filter((user) =>
|
||||
nearbyUserIds.some((id) => String(id) === String(user._id))
|
||||
);
|
||||
posts = await mapPostsWithUsers(posts, users, decodedToken);
|
||||
|
||||
const totalItems = posts.length;
|
||||
const startIndex = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
||||
const paginatedPosts = posts.slice(
|
||||
startIndex,
|
||||
startIndex + parseInt(limit, 10)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
posts: paginatedPosts,
|
||||
totalPages: Math.ceil(totalItems / limit) || 1,
|
||||
totalItems,
|
||||
});
|
||||
}
|
||||
|
||||
if (isExploreFeed) {
|
||||
let explorePosts = await PostModel.find({ ...postFilter, status: "accept" })
|
||||
.lean();
|
||||
const userIds = [...new Set(explorePosts.map((p) => String(p.user_id)))];
|
||||
const usersMap = await UserModel.find({ _id: { $in: userIds } })
|
||||
.select('_id user_name first_name last_name expertise province city user_level')
|
||||
.select('_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image blocked_users')
|
||||
.lean();
|
||||
const usersById = Object.fromEntries(usersMap.map((u) => [String(u._id), u]));
|
||||
explorePosts = explorePosts.map((post) => {
|
||||
const user = usersById[String(post.user_id)] || {};
|
||||
return {
|
||||
...post,
|
||||
user_name: user.user_name || '',
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
expertise: user.expertise || '',
|
||||
province: user.province || {},
|
||||
city: user.city || {},
|
||||
user_level: user.user_level || '',
|
||||
likesCount: post.likes ? post.likes.length : 0,
|
||||
is_liked: false,
|
||||
commentsCount: 0
|
||||
};
|
||||
|
||||
if (decodedToken?.id) {
|
||||
const blockerIds = new Set(
|
||||
(await getBlockerUserIds(UserModel, decodedToken.id)).map(String)
|
||||
);
|
||||
explorePosts = explorePosts.filter(
|
||||
(post) => !blockerIds.has(String(post.user_id))
|
||||
);
|
||||
}
|
||||
|
||||
explorePosts = await mapPostsWithUsers(explorePosts, usersMap, decodedToken);
|
||||
|
||||
const { posts: paginatedPosts, totalItems } = await paginatePersonalizedExplore({
|
||||
posts: explorePosts,
|
||||
usersById,
|
||||
page,
|
||||
limit,
|
||||
userId: decodedToken?.id,
|
||||
seedPostId: reelsMode ? seedPostId : undefined,
|
||||
reelsMode,
|
||||
blendFresh: gridMode || (!reelsMode && !seedPostId),
|
||||
});
|
||||
const startIndex = (page - 1) * limit;
|
||||
const paginatedPosts = explorePosts.slice(startIndex, startIndex + parseInt(limit));
|
||||
|
||||
return res.status(200).json({
|
||||
posts: paginatedPosts,
|
||||
totalPages: Math.ceil(explorePosts.length / limit) || 1,
|
||||
totalItems: explorePosts.length
|
||||
totalPages: Math.ceil(totalItems / limit) || 1,
|
||||
totalItems
|
||||
});
|
||||
}
|
||||
|
||||
const userFilter = {};
|
||||
|
||||
applyExploreFilterToUserFilter(exploreFilter, userFilter);
|
||||
if (expertise) userFilter.expertise = expertise;
|
||||
if (subExpertise) userFilter.sub_expertise = subExpertise;
|
||||
if (province) {
|
||||
const provinceFind = await ProvinceModel.findOne({ id: province }, { _id: 0 });
|
||||
if (provinceFind) userFilter["province.id"] = provinceFind.id;
|
||||
@@ -814,11 +1114,21 @@ const getPostsWeb = async (req, res, next) => {
|
||||
const cityFind = await CityModel.findOne({ id: city });
|
||||
if (cityFind) userFilter["city.id"] = cityFind.id;
|
||||
}
|
||||
if (userLevel) userFilter.user_level = userLevel;
|
||||
if (userLevel) {
|
||||
const levelFilter = buildUserLevelMongoFilter(userLevel)
|
||||
if (levelFilter) Object.assign(userFilter, levelFilter)
|
||||
}
|
||||
if (_id) userFilter._id = _id;
|
||||
|
||||
if (_id && decodedToken?.id) {
|
||||
const profileOwner = await UserModel.findById(_id).select('blocked_users');
|
||||
if (viewerIsBlockedBy(profileOwner, decodedToken.id)) {
|
||||
return res.status(200).json({ posts: [], totalPages: 0, totalItems: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const users = await UserModel.find(userFilter)
|
||||
.select("_id user_name first_name last_name expertise province city user_level")
|
||||
.select("_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image")
|
||||
.lean();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
@@ -836,6 +1146,16 @@ const getPostsWeb = async (req, res, next) => {
|
||||
return res.status(200).json({ posts: [], totalPages: 0, totalItems: 0 });
|
||||
}
|
||||
|
||||
if (decodedToken?.id) {
|
||||
const blockerIds = new Set(
|
||||
(await getBlockerUserIds(UserModel, decodedToken.id)).map(String)
|
||||
);
|
||||
posts = posts.filter((post) => !blockerIds.has(String(post.user_id)));
|
||||
if (!posts.length) {
|
||||
return res.status(200).json({ posts: [], totalPages: 0, totalItems: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
let likeFilter = {};
|
||||
if (decodedToken) likeFilter.userId = decodedToken.id;
|
||||
const likes = decodedToken
|
||||
@@ -860,9 +1180,12 @@ const getPostsWeb = async (req, res, next) => {
|
||||
first_name: user?.first_name || "",
|
||||
last_name: user?.last_name || "",
|
||||
expertise: user?.expertise || "",
|
||||
province: user?.province || {},
|
||||
city: user?.city || {},
|
||||
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: !!likesMap[post._id.toString()],
|
||||
commentsCount: commentsMap[post._id.toString()] || 0,
|
||||
@@ -877,6 +1200,42 @@ const getPostsWeb = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
const usersById = Object.fromEntries(
|
||||
users.map((u) => [String(u._id), u])
|
||||
);
|
||||
|
||||
if (feedMode === "reels" && !_id && posts.length > 1) {
|
||||
const { posts: paginatedPosts, totalItems } = await paginatePersonalizedExplore({
|
||||
posts,
|
||||
usersById,
|
||||
page,
|
||||
limit,
|
||||
userId: decodedToken?.id,
|
||||
seedPostId,
|
||||
reelsMode: true,
|
||||
blendFresh: false,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
posts: paginatedPosts,
|
||||
totalPages: Math.ceil(totalItems / limit) || 1,
|
||||
totalItems,
|
||||
});
|
||||
}
|
||||
|
||||
if (feedMode === "reels" && _id && seedPostId && posts.length > 1) {
|
||||
const seedIdx = posts.findIndex(
|
||||
(p) => String(p._id) === String(seedPostId)
|
||||
);
|
||||
if (seedIdx > 0) {
|
||||
const seed = posts[seedIdx];
|
||||
const rest = posts.filter(
|
||||
(p) => String(p._id) !== String(seedPostId)
|
||||
);
|
||||
posts = [seed, ...rest];
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = page * limit;
|
||||
const totalItems = posts.length;
|
||||
@@ -915,8 +1274,12 @@ const getSingleUser = async (req, res, next) => {
|
||||
const lastOnline = moment(user.last_online).format("jYYYY-jMM-jDD");
|
||||
|
||||
// بررسی بلاک شدن
|
||||
const blockedByCurrentUser = user.blocked_by.includes(userReqId);
|
||||
const blockedYou = user.blocked_users.includes(userReqId);
|
||||
const blockedByCurrentUser = viewerBlockedUser(user, userReqId);
|
||||
const blockedYou = userBlockedViewer(user, userReqId);
|
||||
|
||||
if (blockedYou) {
|
||||
return res.status(200).json({ user: blockedProfilePayload(user) });
|
||||
}
|
||||
|
||||
// Allow chat
|
||||
const offerExists = await OfferModel.exists({
|
||||
@@ -937,6 +1300,7 @@ const getSingleUser = async (req, res, next) => {
|
||||
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,
|
||||
@@ -948,8 +1312,8 @@ const getSingleUser = async (req, res, next) => {
|
||||
profile_image: user.profile_image,
|
||||
lat: user.show_location ? user.lat : null,
|
||||
lng: user.show_location ? user.lng : null,
|
||||
province: user.province,
|
||||
city: user.city,
|
||||
province: user.show_location ? user.province : null,
|
||||
city: user.show_location ? user.city : null,
|
||||
address: user.show_location ? user.address : null,
|
||||
is_blocked: blockedByCurrentUser,
|
||||
blocked_you: blockedYou,
|
||||
@@ -1009,13 +1373,18 @@ const getSingleUserWeb = async (req, res, next) => {
|
||||
let offerExists = false;
|
||||
|
||||
if (userReqId) {
|
||||
blockedByCurrentUser = user.blocked_by.includes(userReqId);
|
||||
blockedYou = user.blocked_users.includes(userReqId);
|
||||
blockedByCurrentUser = viewerBlockedUser(user, userReqId);
|
||||
blockedYou = userBlockedViewer(user, userReqId);
|
||||
offerExists = await OfferModel.exists({
|
||||
sender: userReqId,
|
||||
receiver: user._id,
|
||||
});
|
||||
}
|
||||
|
||||
if (blockedYou && userReqId) {
|
||||
return res.status(200).json({ user: blockedProfilePayload(user) });
|
||||
}
|
||||
|
||||
// دریافت تعداد پستها و پروژهها
|
||||
const postsCount = await PostModel.countDocuments({
|
||||
user_id: user._id,
|
||||
@@ -1043,6 +1412,7 @@ const getSingleUserWeb = async (req, res, next) => {
|
||||
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,
|
||||
@@ -1054,8 +1424,8 @@ const getSingleUserWeb = async (req, res, next) => {
|
||||
profile_image: user.profile_image,
|
||||
lat: user.show_location ? user.lat : null,
|
||||
lng: user.show_location ? user.lng : null,
|
||||
province: user.province,
|
||||
city: user.city,
|
||||
province: user.show_location ? user.province : null,
|
||||
city: user.show_location ? user.city : null,
|
||||
address: user.show_location ? user.address : null,
|
||||
is_blocked: blockedByCurrentUser,
|
||||
blocked_you: blockedYou,
|
||||
@@ -1099,6 +1469,11 @@ const getUserContactInfo = async (req, res, next) => {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
const {
|
||||
applyAutoBlockSuspension,
|
||||
clearAutoBlockSuspension
|
||||
} = require('../../../utils/blockSuspension')
|
||||
|
||||
const blockUser = async (req, res) => {
|
||||
try {
|
||||
const { user_to_block } = req.body;
|
||||
@@ -1107,17 +1482,30 @@ const blockUser = async (req, res) => {
|
||||
const decodedToken = jwt.verify(token, process.env.APP_SECRET);
|
||||
const userReqId = decodedToken.id;
|
||||
|
||||
const currentUser = await UserModel.findById(userReqId); // Req
|
||||
const userToBlock = await UserModel.findById(user_to_block); // To Block
|
||||
|
||||
userToBlock.blocked_by.push(currentUser);
|
||||
currentUser.blocked_users.push(userToBlock);
|
||||
|
||||
// بررسی تعداد بلاک ها و تنظیم block_status
|
||||
if (userToBlock.blocked_by.length >= 3) {
|
||||
userToBlock.block_status = true;
|
||||
if (userReqId === user_to_block) {
|
||||
return res.status(400).json({ message: "امکان بلاک خودتان وجود ندارد" });
|
||||
}
|
||||
|
||||
const currentUser = await UserModel.findById(userReqId);
|
||||
const userToBlock = await UserModel.findById(user_to_block);
|
||||
|
||||
if (!currentUser || !userToBlock) {
|
||||
return res.status(404).json({ message: "کاربر یافت نشد" });
|
||||
}
|
||||
|
||||
const alreadyBlocked = userToBlock.blocked_by.some((id) =>
|
||||
id.equals(currentUser._id)
|
||||
);
|
||||
|
||||
if (alreadyBlocked) {
|
||||
return res.status(400).json({ message: "این کاربر قبلاً بلاک شده است" });
|
||||
}
|
||||
|
||||
userToBlock.blocked_by.push(currentUser._id);
|
||||
currentUser.blocked_users.push(userToBlock._id);
|
||||
|
||||
applyAutoBlockSuspension(userToBlock);
|
||||
|
||||
await currentUser.save();
|
||||
await userToBlock.save();
|
||||
|
||||
@@ -1136,18 +1524,17 @@ const unblockUser = async (req, res) => {
|
||||
const decodedToken = jwt.verify(token, process.env.APP_SECRET);
|
||||
const userReqId = decodedToken.id;
|
||||
|
||||
const currentUser = await UserModel.findById(userReqId); // Req
|
||||
const userToUnblock = await UserModel.findById(user_to_unblock); // To Unblock
|
||||
const currentUser = await UserModel.findById(userReqId);
|
||||
const userToUnblock = await UserModel.findById(user_to_unblock);
|
||||
|
||||
if (!currentUser || !userToUnblock) {
|
||||
return res.status(404).json({ message: "کاربر یافت نشد" });
|
||||
}
|
||||
|
||||
// حذف کاربر از لیست blocked_by کاربر فعلی
|
||||
currentUser.blocked_users.pull(userToUnblock._id);
|
||||
// حذف کاربر از لیست blocked_users کاربر مورد نظر
|
||||
userToUnblock.blocked_by.pull(currentUser._id);
|
||||
|
||||
// بررسی تعداد بلاک ها و تنظیم block_status
|
||||
if (userToUnblock.blocked_by.length < 3) {
|
||||
userToUnblock.block_status = null;
|
||||
}
|
||||
clearAutoBlockSuspension(userToUnblock);
|
||||
|
||||
await currentUser.save();
|
||||
await userToUnblock.save();
|
||||
@@ -1163,6 +1550,7 @@ const getComments = 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_id = req.query.user_id;
|
||||
const post_id = req.query.post_id;
|
||||
|
||||
@@ -1192,27 +1580,53 @@ const getComments = async (req, res, next) => {
|
||||
|
||||
const comments = await CommentModel.paginate(filter, options);
|
||||
|
||||
const formattedComments = comments.docs.map((comment) => ({
|
||||
_id: comment._id,
|
||||
comment: comment.comment,
|
||||
rating: comment.rating,
|
||||
createdAt: moment(comment.createdAt).format("jYYYY-jMM-jDD HH:mm"),
|
||||
status: comment.status,
|
||||
user: {
|
||||
profile_image: comment.creator.profile_image,
|
||||
user_name: comment.creator.user_name,
|
||||
is_verified: comment.creator.is_verified,
|
||||
_id: comment.creator._id,
|
||||
},
|
||||
project: comment.project ? comment.project.project_name : null,
|
||||
post_id: comment.post ? comment.post.toString() : null,
|
||||
}));
|
||||
const earliestRatedComments = await CommentModel.find({
|
||||
user: user_id,
|
||||
rating: { $ne: null, $gt: 0 },
|
||||
})
|
||||
.select("creator _id createdAt")
|
||||
.sort({ createdAt: 1 });
|
||||
|
||||
const ratedCreatorCommentIds = new Map();
|
||||
for (const ratedComment of earliestRatedComments) {
|
||||
const creatorId = ratedComment.creator.toString();
|
||||
if (!ratedCreatorCommentIds.has(creatorId)) {
|
||||
ratedCreatorCommentIds.set(creatorId, ratedComment._id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const formattedComments = comments.docs.map((comment) => {
|
||||
const creatorId = comment.creator._id.toString();
|
||||
const isPrimaryRatingComment =
|
||||
comment.rating != null &&
|
||||
comment.rating > 0 &&
|
||||
ratedCreatorCommentIds.get(creatorId) === comment._id.toString();
|
||||
|
||||
return {
|
||||
_id: comment._id,
|
||||
comment: comment.comment,
|
||||
rating: isPrimaryRatingComment ? comment.rating : null,
|
||||
createdAt: moment(comment.createdAt).format("jYYYY-jMM-jDD HH:mm"),
|
||||
status: comment.status,
|
||||
user: {
|
||||
profile_image: comment.creator.profile_image,
|
||||
user_name: comment.creator.user_name,
|
||||
is_verified: comment.creator.is_verified,
|
||||
_id: comment.creator._id,
|
||||
},
|
||||
project: comment.project ? comment.project.project_name : null,
|
||||
post_id: comment.post ? comment.post.toString() : null,
|
||||
};
|
||||
});
|
||||
|
||||
const hasRated = await hasCreatorRatedUser(decodedToken.id, user_id);
|
||||
|
||||
res.status(200).json({
|
||||
comments: formattedComments,
|
||||
totalPages: comments.totalPages,
|
||||
totalItems: comments.totalDocs,
|
||||
currentPage: comments.page,
|
||||
has_rated: hasRated,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -1231,20 +1645,23 @@ const createUserComment = async (req, res, next) => {
|
||||
return res.status(400).send({ message: "All fields are required" });
|
||||
}
|
||||
|
||||
const existingRating = await CommentModel.findOne({
|
||||
user: user_id,
|
||||
creator: creatorId,
|
||||
comment_for: post_id ? "post" : "user",
|
||||
rating: { $exists: true, $ne: null }
|
||||
});
|
||||
const { ratingValue, isNewRating, requiresRating, alreadyRated } =
|
||||
await resolveRatingForComment({
|
||||
creatorId,
|
||||
targetUserId: user_id,
|
||||
rate,
|
||||
});
|
||||
|
||||
let ratingValue = rate;
|
||||
if (existingRating) {
|
||||
ratingValue = existingRating.rating;
|
||||
} else {
|
||||
if (!rate || rate < 1 || rate > 5) {
|
||||
return res.status(400).send({ message: "Rating must be between 1 and 5" });
|
||||
}
|
||||
if (alreadyRated) {
|
||||
return res.status(400).json({
|
||||
message: "شما قبلاً به این کاربر امتیاز دادهاید.",
|
||||
});
|
||||
}
|
||||
|
||||
if (requiresRating) {
|
||||
return res
|
||||
.status(400)
|
||||
.send({ message: "Rating must be between 1 and 5" });
|
||||
}
|
||||
|
||||
const newComment = new CommentModel({
|
||||
@@ -1258,50 +1675,16 @@ const createUserComment = async (req, res, next) => {
|
||||
});
|
||||
await newComment.save();
|
||||
|
||||
const user = await UserModel.findById(user_id);
|
||||
|
||||
// محاسبه امتیاز جدید کاربر
|
||||
const comments = await CommentModel.find({
|
||||
user: user_id,
|
||||
comment_for: "user",
|
||||
});
|
||||
const totalRating = comments.reduce(
|
||||
(acc, comment) => acc + comment.rating,
|
||||
0
|
||||
);
|
||||
const totalComments = comments.length;
|
||||
const averageRating = totalRating / totalComments;
|
||||
|
||||
// تعیین سطح کاربر
|
||||
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 = "استاد";
|
||||
if (isNewRating) {
|
||||
await recalculateUserRating(user_id);
|
||||
}
|
||||
|
||||
// بهروزرسانی اطلاعات کاربر
|
||||
user.user_score = totalRating;
|
||||
user.user_level = userLevel;
|
||||
user.rate = averageRating.toFixed(1);
|
||||
await user.save();
|
||||
|
||||
// ارسال نوتیفیکیشن
|
||||
const notification = new NotificationModel({
|
||||
user_id: user._id,
|
||||
type: "user_comment",
|
||||
title: "نظر جدید",
|
||||
description: `یک کاربر جدید برای شما نظر ثبت کرد:
|
||||
${comment}
|
||||
امتیاز: ${rate} از 5`,
|
||||
await createCommentNotification({
|
||||
ownerId: user_id,
|
||||
commenterId: creatorId,
|
||||
entityId: post_id || user_id,
|
||||
type: post_id ? "post_comment" : "profile_comment",
|
||||
});
|
||||
await notification.save();
|
||||
|
||||
res.status(201).json({
|
||||
message: "نظر با موفقیت ثبت شد و بعد از تایید، منتشر میشود.",
|
||||
@@ -1360,6 +1743,53 @@ const inviteEmployer = async (req, res, next) => {
|
||||
res.status(201).json({ message: "درخواست همکاری با موفقیت ثبت شد" });
|
||||
};
|
||||
|
||||
const recordExploreInteraction = 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 { targetType, targetId, authorId, contentType, action, dwellMs } = req.body;
|
||||
|
||||
if (!targetType || !targetId) {
|
||||
return res.status(422).json({ message: "اطلاعات ناقص است" });
|
||||
}
|
||||
|
||||
const normalizedAction = action || "view";
|
||||
|
||||
if (normalizedAction === "view") {
|
||||
const recentView = await ExploreInteractionModel.findOne({
|
||||
viewerId: decodedToken.id,
|
||||
targetType,
|
||||
targetId,
|
||||
action: "view",
|
||||
createdAt: { $gte: new Date(Date.now() - 30 * 60 * 1000) },
|
||||
})
|
||||
.select("_id")
|
||||
.lean();
|
||||
|
||||
if (recentView) {
|
||||
return res.status(201).json({ message: "ok" });
|
||||
}
|
||||
}
|
||||
|
||||
await ExploreInteractionModel.create({
|
||||
viewerId: decodedToken.id,
|
||||
targetType,
|
||||
targetId,
|
||||
authorId: authorId || null,
|
||||
contentType: contentType || "image",
|
||||
action: normalizedAction,
|
||||
dwellMs: dwellMs ?? null,
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: "ok" });
|
||||
} catch (error) {
|
||||
console.error("recordExploreInteraction:", error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getUsers,
|
||||
getSingleUser,
|
||||
@@ -1375,4 +1805,5 @@ module.exports = {
|
||||
getLicenseByUserId,
|
||||
createLicense,
|
||||
updateLicenseConfirmation,
|
||||
recordExploreInteraction,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,18 @@ const setAddress = async (req, res, next) => {
|
||||
if (!token) return res.status(401).send('Access Denied')
|
||||
const { province_id, city_id, address, lat, lng, show_location } = req.body
|
||||
// اعتبارسنجی دادهها
|
||||
if (!province_id || !city_id || !address || !lat || !lng || show_location === 'undefined') {
|
||||
if (
|
||||
!province_id ||
|
||||
!city_id ||
|
||||
!address ||
|
||||
lat === undefined ||
|
||||
lat === null ||
|
||||
lat === '' ||
|
||||
lng === undefined ||
|
||||
lng === null ||
|
||||
lng === '' ||
|
||||
show_location === undefined
|
||||
) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'اطلاعات ارسالی ناقص است'
|
||||
@@ -41,7 +52,8 @@ const setAddress = async (req, res, next) => {
|
||||
user.address = address
|
||||
user.lat = lat
|
||||
user.lng = lng
|
||||
user.show_location = show_location
|
||||
user.show_location =
|
||||
show_location === true || show_location === 'true'
|
||||
|
||||
await user.save()
|
||||
|
||||
@@ -60,7 +72,18 @@ const updateAddress = async (req, res, next) => {
|
||||
const { province_id, city_id, address, lat, lng, show_location } = req.body
|
||||
|
||||
// اعتبارسنجی دادهها
|
||||
if (!province_id || !city_id || !address || !lat || !lng || show_location === undefined) {
|
||||
if (
|
||||
!province_id ||
|
||||
!city_id ||
|
||||
!address ||
|
||||
lat === undefined ||
|
||||
lat === null ||
|
||||
lat === '' ||
|
||||
lng === undefined ||
|
||||
lng === null ||
|
||||
lng === '' ||
|
||||
show_location === undefined
|
||||
) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'اطلاعات ارسالی ناقص است'
|
||||
@@ -95,7 +118,8 @@ const updateAddress = async (req, res, next) => {
|
||||
user.address = address
|
||||
user.lat = lat
|
||||
user.lng = lng
|
||||
user.show_location = show_location
|
||||
user.show_location =
|
||||
show_location === true || show_location === 'true'
|
||||
|
||||
await user.save()
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
const completeRegistration = 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(404).json({
|
||||
error: true,
|
||||
message: 'کاربر یافت نشد'
|
||||
})
|
||||
}
|
||||
|
||||
user.is_Register = true
|
||||
|
||||
if (!user.is_verified || user.is_verified === 'none') {
|
||||
user.is_verified = 'pending'
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
return res.json({
|
||||
message: 'ثبت نام تکمیل شد',
|
||||
is_verified: user.is_verified,
|
||||
is_Register: user.is_Register
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
completeRegistration
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const setNationalCardImage = async (req, res, next) => {
|
||||
|
||||
// ذخیره مسیر فایل در دیتابیس
|
||||
user.national_card_image = `/carts/${uniqueFileName}`
|
||||
user.is_Register = true
|
||||
user.is_verified = 'pending'
|
||||
await user.save()
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const setProfileImage = async (req, res, next) => {
|
||||
}
|
||||
|
||||
// ذخیره فایل عکس پروفایل
|
||||
const uploadDir = path.join(__dirname, '../../../../storage/profiles')
|
||||
const uploadDir = path.join(__dirname, '../../../storage/profiles')
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true })
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const updateProfileImage = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
const uploadDir = path.join(__dirname, '../../../../storage/profiles')
|
||||
const uploadDir = path.join(__dirname, '../../../storage/profiles')
|
||||
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true })
|
||||
|
||||
@@ -13,62 +13,52 @@ const getProjects = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization').split(' ')[1]
|
||||
if (!token) return res.status(401).send('Access Denied')
|
||||
// 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(decodedToken.id)
|
||||
const { status_filter, page = 1, limit = 10 } = req.query // افزودن پارامترهای صفحهبندی به درخواست
|
||||
// ساخت فیلتر برای استفاده در جستجوی MongoDB
|
||||
let filter = {} // افزودن شرط برای payment_status
|
||||
if (user.user_type === 'user') {
|
||||
if (status_filter) {
|
||||
if (status_filter === 'دریافتی') {
|
||||
filter = { created_for_user: userId, status: 'accepted' }
|
||||
} else if (status_filter === 'ارسال شده') {
|
||||
filter = { requested_users: userId, status: 'accepted' }
|
||||
} else if (status_filter === 'در دست اقدام') {
|
||||
filter = { selected_user: userId, status: 'ongoing' }
|
||||
} else if (status_filter === 'اتمام پروژه') {
|
||||
filter = { selected_user: userId, status: 'done' }
|
||||
} else if (status_filter === 'کنسل شده') {
|
||||
filter = { selected_user: userId, status: 'cancled' }
|
||||
} else {
|
||||
filter = {
|
||||
$or: [
|
||||
{ selected_user: userId },
|
||||
{ requested_users: userId },
|
||||
{ created_for_user: userId }
|
||||
]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { status_filter, page = 1, limit = 10 } = req.query
|
||||
|
||||
const participantFilter = {
|
||||
$or: [
|
||||
{ creator_id: userId },
|
||||
{ selected_user: userId },
|
||||
{ requested_users: userId },
|
||||
{ created_for_user: userId }
|
||||
]
|
||||
}
|
||||
|
||||
let filter = participantFilter
|
||||
if (status_filter) {
|
||||
if (status_filter === 'دریافتی') {
|
||||
filter = { created_for_user: userId, status: 'accepted' }
|
||||
} else if (status_filter === 'ارسال شده') {
|
||||
filter = { requested_users: userId, status: 'accepted' }
|
||||
} else if (status_filter === 'منتشر شده') {
|
||||
filter = { creator_id: userId, status: 'accepted' }
|
||||
} else if (status_filter === 'در دست اقدام') {
|
||||
filter = {
|
||||
$or: [
|
||||
{ selected_user: userId },
|
||||
{ requested_users: userId },
|
||||
{ created_for_user: userId }
|
||||
{ creator_id: userId, status: 'ongoing' },
|
||||
{ selected_user: userId, status: 'ongoing' }
|
||||
]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (status_filter) {
|
||||
if (status_filter === 'منتشر شده') {
|
||||
filter = { creator_id: userId, status: 'accepted' }
|
||||
} else if (status_filter === 'در دست اقدام') {
|
||||
filter = { creator_id: userId, status: 'ongoing' }
|
||||
} else if (status_filter === 'اتمام پروژه') {
|
||||
filter = { creator_id: userId, status: 'done' }
|
||||
} else if (status_filter === 'کنسل شده') {
|
||||
filter = { creator_id: userId, status: 'cancled' }
|
||||
} else if (status_filter === 'در دست بررسی') {
|
||||
filter = { creator_id: userId, status: { $in: ['paid', 'rejected'] } }
|
||||
} else if (status_filter === 'پرداخت نشده') {
|
||||
filter = { creator_id: userId, status: 'pre_payment' }
|
||||
} else {
|
||||
filter = { creator_id: userId }
|
||||
} else if (status_filter === 'اتمام پروژه') {
|
||||
filter = {
|
||||
$or: [
|
||||
{ creator_id: userId, status: 'done' },
|
||||
{ selected_user: userId, status: 'done' }
|
||||
]
|
||||
}
|
||||
} else {
|
||||
filter = { creator_id: userId }
|
||||
} else if (status_filter === 'کنسل شده') {
|
||||
filter = {
|
||||
$or: [
|
||||
{ creator_id: userId, status: 'cancled' },
|
||||
{ selected_user: userId, status: 'cancled' }
|
||||
]
|
||||
}
|
||||
} else if (status_filter === 'در دست بررسی') {
|
||||
filter = { creator_id: userId, status: { $in: ['paid', 'rejected'] } }
|
||||
} else if (status_filter === 'پرداخت نشده') {
|
||||
filter = { creator_id: userId, status: 'pre_payment' }
|
||||
}
|
||||
}
|
||||
// paid
|
||||
|
||||
@@ -30,6 +30,14 @@ async function enrichMessage(message) {
|
||||
return {
|
||||
...doc,
|
||||
createdAt: formatTimeOnly(doc.createdAt),
|
||||
createdAtIso: doc.createdAt ? new Date(doc.createdAt).toISOString() : undefined,
|
||||
expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined,
|
||||
viewOnce: Boolean(doc.viewOnce),
|
||||
viewOnceLocked: Boolean(doc.viewOnceLocked),
|
||||
viewOnceExpired: Boolean(doc.viewOnceExpired),
|
||||
viewOnceOpenedAt: doc.viewOnceOpenedAt
|
||||
? new Date(doc.viewOnceOpenedAt).toISOString()
|
||||
: undefined,
|
||||
replyTo,
|
||||
forwardedFrom: doc.forwardedFrom || undefined
|
||||
}
|
||||
|
||||
129
helpers/expiredMessages.js
Normal file
129
helpers/expiredMessages.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const MessageModel = require('../models/MessageModel')
|
||||
|
||||
const ALLOWED_SELF_DESTRUCT_SECONDS = [10, 30, 60, 300, 3600]
|
||||
|
||||
function parseSelfDestructSeconds(raw) {
|
||||
if (raw == null || raw === '' || raw === '0' || raw === 0) return null
|
||||
const n = parseInt(String(raw), 10)
|
||||
if (!Number.isFinite(n) || !ALLOWED_SELF_DESTRUCT_SECONDS.includes(n)) return null
|
||||
return n
|
||||
}
|
||||
|
||||
function buildExpiresAt(seconds) {
|
||||
if (!seconds) return undefined
|
||||
return new Date(Date.now() + seconds * 1000)
|
||||
}
|
||||
|
||||
function activeMessageFilter(baseFilter) {
|
||||
return {
|
||||
$and: [
|
||||
baseFilter,
|
||||
{
|
||||
$or: [
|
||||
{ expiresAt: { $exists: false } },
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { $gt: new Date() } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMessageFiles(messages) {
|
||||
for (const msg of messages) {
|
||||
if (!msg.file) continue
|
||||
const relative = String(msg.file).replace(/^\//, '')
|
||||
const filePath = path.join(__dirname, '../storage', relative)
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.remove(filePath).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMessagesAndNotify(io, messages, options = {}) {
|
||||
if (!messages?.length) return []
|
||||
|
||||
await removeMessageFiles(messages)
|
||||
const deletedIds = messages.map((m) => String(m._id))
|
||||
await MessageModel.deleteMany({ _id: { $in: deletedIds } })
|
||||
|
||||
if (io) {
|
||||
const byThread = new Map()
|
||||
messages.forEach((m) => {
|
||||
const s = String(m.senderId)
|
||||
const r = String(m.receiverId)
|
||||
const key = [s, r].sort().join(':')
|
||||
if (!byThread.has(key)) byThread.set(key, { s, r, ids: [] })
|
||||
byThread.get(key).ids.push(String(m._id))
|
||||
})
|
||||
|
||||
byThread.forEach(({ s, r, ids }) => {
|
||||
io.to(`chat:${s}:${r}`)
|
||||
.to(`chat:${r}:${s}`)
|
||||
.emit('messagesDeleted', {
|
||||
messageIds: ids,
|
||||
deletedBy: options.deletedBy,
|
||||
reason: options.reason || (options.deletedBy ? 'manual' : 'expired')
|
||||
})
|
||||
io.to(`user:${s}`).to(`user:${r}`).emit('chatListUpdate', { senderId: s, receiverId: r })
|
||||
})
|
||||
}
|
||||
|
||||
return deletedIds
|
||||
}
|
||||
|
||||
async function purgeExpiredMessages(io) {
|
||||
try {
|
||||
const expired = await MessageModel.find({ expiresAt: { $lte: new Date() } })
|
||||
if (!expired.length) return []
|
||||
return deleteMessagesAndNotify(io, expired)
|
||||
} catch (err) {
|
||||
console.error('purgeExpiredMessages error:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const scheduledTimers = new Map()
|
||||
|
||||
function scheduleMessageExpiry(io, message) {
|
||||
if (!message?.expiresAt || !message._id) return
|
||||
|
||||
const id = String(message._id)
|
||||
if (scheduledTimers.has(id)) {
|
||||
clearTimeout(scheduledTimers.get(id))
|
||||
}
|
||||
|
||||
const ms = new Date(message.expiresAt).getTime() - Date.now()
|
||||
if (ms <= 0) {
|
||||
MessageModel.findById(id)
|
||||
.then((doc) => doc && deleteMessagesAndNotify(io, [doc]))
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
scheduledTimers.delete(id)
|
||||
try {
|
||||
const doc = await MessageModel.findById(id)
|
||||
if (doc && doc.expiresAt && new Date(doc.expiresAt) <= new Date()) {
|
||||
await deleteMessagesAndNotify(io, [doc])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('scheduleMessageExpiry error:', err)
|
||||
}
|
||||
}, ms)
|
||||
|
||||
scheduledTimers.set(id, timer)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_SELF_DESTRUCT_SECONDS,
|
||||
parseSelfDestructSeconds,
|
||||
buildExpiresAt,
|
||||
activeMessageFilter,
|
||||
purgeExpiredMessages,
|
||||
scheduleMessageExpiry,
|
||||
deleteMessagesAndNotify
|
||||
}
|
||||
104
helpers/viewOnceMessages.js
Normal file
104
helpers/viewOnceMessages.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const MessageModel = require('../models/MessageModel')
|
||||
const { deleteMessagesAndNotify } = require('./expiredMessages')
|
||||
|
||||
function parseViewOnce(raw) {
|
||||
if (raw == null || raw === '' || raw === false || raw === 'false' || raw === 0) {
|
||||
return false
|
||||
}
|
||||
return raw === true || raw === 'true' || raw === 1 || raw === '1'
|
||||
}
|
||||
|
||||
const VIEW_ONCE_MEDIA = new Set(['image', 'video', 'voice'])
|
||||
|
||||
function isViewOnceMediaType(fileType) {
|
||||
return VIEW_ONCE_MEDIA.has(fileType)
|
||||
}
|
||||
|
||||
/** Hide file URL for receiver until they open view-once media */
|
||||
function sanitizeMessageForViewer(message, viewerId) {
|
||||
const doc = message._doc ? { ...message._doc } : { ...message }
|
||||
const viewer = String(viewerId)
|
||||
const sender = String(doc.senderId)
|
||||
const receiver = String(doc.receiverId)
|
||||
|
||||
if (!doc.viewOnce || !isViewOnceMediaType(doc.fileType)) {
|
||||
return doc
|
||||
}
|
||||
|
||||
if (viewer === sender) {
|
||||
return doc
|
||||
}
|
||||
|
||||
if (viewer === receiver) {
|
||||
if (doc.viewOnceOpenedAt) {
|
||||
return {
|
||||
...doc,
|
||||
file: '',
|
||||
viewOnceExpired: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
...doc,
|
||||
file: '',
|
||||
viewOnceLocked: true
|
||||
}
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
async function openViewOnceMessage(messageId, userId) {
|
||||
const msg = await MessageModel.findById(messageId)
|
||||
if (!msg || !msg.viewOnce) {
|
||||
return { error: 'پیام یافت نشد', status: 404 }
|
||||
}
|
||||
if (String(msg.receiverId) !== String(userId)) {
|
||||
return { error: 'دسترسی مجاز نیست', status: 403 }
|
||||
}
|
||||
if (msg.viewOnceOpenedAt) {
|
||||
return { error: 'این پیام قبلاً باز شده است', status: 410 }
|
||||
}
|
||||
|
||||
msg.viewOnceOpenedAt = new Date()
|
||||
await msg.save()
|
||||
|
||||
return {
|
||||
message: msg.toObject(),
|
||||
file: msg.file,
|
||||
fileType: msg.fileType
|
||||
}
|
||||
}
|
||||
|
||||
async function completeViewOnceMessage(io, messageId, userId) {
|
||||
const msg = await MessageModel.findById(messageId)
|
||||
if (!msg || !msg.viewOnce) {
|
||||
return { error: 'پیام یافت نشد', status: 404 }
|
||||
}
|
||||
|
||||
const uid = String(userId)
|
||||
const isReceiver = String(msg.receiverId) === uid
|
||||
const isSender = String(msg.senderId) === uid
|
||||
|
||||
if (!isReceiver && !isSender) {
|
||||
return { error: 'دسترسی مجاز نیست', status: 403 }
|
||||
}
|
||||
|
||||
if (isReceiver && !msg.viewOnceOpenedAt) {
|
||||
return { error: 'پیام هنوز باز نشده', status: 400 }
|
||||
}
|
||||
|
||||
await deleteMessagesAndNotify(io, [msg], {
|
||||
deletedBy: userId,
|
||||
reason: 'viewOnce'
|
||||
})
|
||||
|
||||
return { deletedIds: [String(msg._id)] }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseViewOnce,
|
||||
isViewOnceMediaType,
|
||||
sanitizeMessageForViewer,
|
||||
openViewOnceMessage,
|
||||
completeViewOnceMessage
|
||||
}
|
||||
158
index.js
158
index.js
@@ -17,6 +17,7 @@ const allowedOrigins = [
|
||||
'http://localhost:3001', // اضافه شد — مهم برای توسعه فرانت
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003',
|
||||
'http://localhost:3004',
|
||||
'http://localhost',
|
||||
'https://modstagram.com',
|
||||
'http://modstagram.com',
|
||||
@@ -98,14 +99,22 @@ const io = require('socket.io')(http, {
|
||||
app.use(express.json({ limit: '1000mb' }));
|
||||
app.use(express.urlencoded({ limit: '1000mb', extended: true }));
|
||||
|
||||
app.use('/storage', express.static(path.join(__dirname, 'storage')));
|
||||
// مسیرهای مشخص قبل از /storage عمومی — پروفایل از هر دو محل (قدیم و جدید) سرو میشود
|
||||
app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles')));
|
||||
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/carts', express.static(path.join(__dirname, 'storage/carts')));
|
||||
app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts')));
|
||||
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/messages', express.static(path.join(__dirname, 'storage/messages')));
|
||||
app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets')));
|
||||
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/advertising', express.static(path.join(__dirname, 'storage/advertising')));
|
||||
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services')));
|
||||
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services')));
|
||||
app.use('/storage', express.static(path.join(__dirname, 'storage')));
|
||||
|
||||
require('./boot');
|
||||
|
||||
@@ -146,19 +155,50 @@ app.set('trust proxy', 'loopback');
|
||||
require('./routes')(app);
|
||||
|
||||
const { enrichMessage, emitChatEvents, parseForwardedContent } = require('./helpers/chatHelpers')
|
||||
const {
|
||||
parseSelfDestructSeconds,
|
||||
buildExpiresAt,
|
||||
activeMessageFilter,
|
||||
purgeExpiredMessages,
|
||||
scheduleMessageExpiry,
|
||||
deleteMessagesAndNotify
|
||||
} = require('./helpers/expiredMessages')
|
||||
const {
|
||||
parseViewOnce,
|
||||
isViewOnceMediaType,
|
||||
sanitizeMessageForViewer,
|
||||
openViewOnceMessage,
|
||||
completeViewOnceMessage
|
||||
} = require('./helpers/viewOnceMessages')
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
app.get('/api/v1/chat', async (req, res) => {
|
||||
try {
|
||||
const { senderId, receiverId, limit, page } = req.query
|
||||
|
||||
if (!senderId || !receiverId) {
|
||||
return res.status(400).json({ error: 'senderId and receiverId are required' })
|
||||
}
|
||||
|
||||
if (
|
||||
!mongoose.Types.ObjectId.isValid(String(senderId)) ||
|
||||
!mongoose.Types.ObjectId.isValid(String(receiverId))
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid senderId or receiverId' })
|
||||
}
|
||||
|
||||
const sid = new mongoose.Types.ObjectId(String(senderId))
|
||||
const rid = new mongoose.Types.ObjectId(String(receiverId))
|
||||
const pageNumber = parseInt(page) || 1
|
||||
const limitPerPage = parseInt(limit) || 40
|
||||
|
||||
const filter = {
|
||||
const filter = activeMessageFilter({
|
||||
$or: [
|
||||
{ senderId, receiverId },
|
||||
{ senderId: receiverId, receiverId: senderId }
|
||||
{ senderId: sid, receiverId: rid },
|
||||
{ senderId: rid, receiverId: sid }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const totalMessagesCount = await MessageModel.countDocuments(filter)
|
||||
const totalPages = Math.ceil(totalMessagesCount / limitPerPage) || 1
|
||||
@@ -170,11 +210,15 @@ app.get('/api/v1/chat', async (req, res) => {
|
||||
.limit(limitPerPage)
|
||||
|
||||
await MessageModel.updateMany(
|
||||
{ _id: { $in: messages.map((m) => m._id) }, receiverId: senderId },
|
||||
{ _id: { $in: messages.map((m) => m._id) }, receiverId: sid },
|
||||
{ $set: { readStatus: 1 } }
|
||||
)
|
||||
|
||||
const enriched = await Promise.all(messages.map((m) => enrichMessage(m)))
|
||||
const enriched = await Promise.all(
|
||||
messages.map((m) =>
|
||||
enrichMessage(sanitizeMessageForViewer(m, sid))
|
||||
)
|
||||
)
|
||||
res.json({ messages: enriched.reverse(), totalPages })
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error)
|
||||
@@ -184,15 +228,17 @@ app.get('/api/v1/chat', async (req, res) => {
|
||||
|
||||
app.post('/api/v1/chat', [blockCheck], async (req, res) => {
|
||||
try {
|
||||
const { senderId, receiverId, content, replyToId, forwardedFrom: fwdBody } = req.body
|
||||
const { senderId, receiverId, content, replyToId, forwardedFrom: fwdBody, selfDestructSeconds } = req.body
|
||||
const { body, forwardedFrom: fwdParsed } = parseForwardedContent(content)
|
||||
const destructSec = parseSelfDestructSeconds(selfDestructSeconds)
|
||||
|
||||
const payload = {
|
||||
senderId,
|
||||
receiverId,
|
||||
content: body || content,
|
||||
replyToId: replyToId || undefined,
|
||||
forwardedFrom: fwdBody || fwdParsed || undefined
|
||||
forwardedFrom: fwdBody || fwdParsed || undefined,
|
||||
expiresAt: buildExpiresAt(destructSec)
|
||||
}
|
||||
|
||||
const newMessage = new MessageModel(payload)
|
||||
@@ -201,6 +247,7 @@ app.post('/api/v1/chat', [blockCheck], async (req, res) => {
|
||||
|
||||
res.status(201).json({ data: formatted })
|
||||
emitChatEvents(io, formatted, senderId, receiverId)
|
||||
scheduleMessageExpiry(io, newMessage)
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error)
|
||||
res.status(500).json({ error: 'Server error' })
|
||||
@@ -263,7 +310,14 @@ app.post("/api/v1/notification/send-sms", async (req, res) => {
|
||||
|
||||
app.post('/api/v1/chat/file', [blockCheck], async (req, res) => {
|
||||
try {
|
||||
const { senderId, receiverId, content, replyToId, fileType } = req.body
|
||||
const { senderId, receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body
|
||||
const destructSec = parseSelfDestructSeconds(selfDestructSeconds)
|
||||
const viewOnceFlag = parseViewOnce(viewOnce)
|
||||
const resolvedFileType = fileType || undefined
|
||||
|
||||
if (viewOnceFlag && !isViewOnceMediaType(resolvedFileType)) {
|
||||
return res.status(422).json({ error: 'View once is only for image, video, and voice' })
|
||||
}
|
||||
const { file } = req.files
|
||||
let fileUrl = null
|
||||
if (file) {
|
||||
@@ -282,19 +336,94 @@ app.post('/api/v1/chat/file', [blockCheck], async (req, res) => {
|
||||
receiverId,
|
||||
content: content || '',
|
||||
file: fileUrl,
|
||||
fileType: fileType || undefined,
|
||||
replyToId: replyToId || undefined
|
||||
fileType: resolvedFileType,
|
||||
replyToId: replyToId || undefined,
|
||||
expiresAt: buildExpiresAt(destructSec),
|
||||
viewOnce: viewOnceFlag
|
||||
})
|
||||
await newMessage.save()
|
||||
const formatted = await enrichMessage(newMessage)
|
||||
|
||||
res.status(201).json({ data: formatted })
|
||||
emitChatEvents(io, formatted, senderId, receiverId)
|
||||
scheduleMessageExpiry(io, newMessage)
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error)
|
||||
res.status(500).json({ error: 'Server error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/v1/chat/view-once/open', [blockCheck], async (req, res) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { messageId } = req.body
|
||||
if (!messageId) {
|
||||
return res.status(422).json({ error: 'messageId is required' })
|
||||
}
|
||||
|
||||
const result = await openViewOnceMessage(messageId, userId)
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json({ error: result.error })
|
||||
}
|
||||
|
||||
res.json({
|
||||
file: result.file,
|
||||
fileType: result.fileType,
|
||||
messageId: String(result.message._id)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('view-once open error:', error)
|
||||
res.status(500).json({ error: 'Server error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/v1/chat/view-once/complete', [blockCheck], async (req, res) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { messageId } = req.body
|
||||
if (!messageId) {
|
||||
return res.status(422).json({ error: 'messageId is required' })
|
||||
}
|
||||
|
||||
const result = await completeViewOnceMessage(io, messageId, userId)
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json({ error: result.error })
|
||||
}
|
||||
|
||||
res.json({ deletedIds: result.deletedIds })
|
||||
} catch (error) {
|
||||
console.error('view-once complete error:', error)
|
||||
res.status(500).json({ error: 'Server error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/v1/chat', [blockCheck], async (req, res) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { messageIds } = req.body
|
||||
|
||||
if (!Array.isArray(messageIds) || messageIds.length === 0) {
|
||||
return res.status(422).json({ message: 'پیامی انتخاب نشده است' })
|
||||
}
|
||||
|
||||
const messages = await MessageModel.find({
|
||||
_id: { $in: messageIds },
|
||||
senderId: userId
|
||||
})
|
||||
|
||||
if (!messages.length) {
|
||||
return res.status(403).json({ message: 'فقط پیامهای خودتان قابل حذف هستند' })
|
||||
}
|
||||
|
||||
const deletedIds = await deleteMessagesAndNotify(io, messages, { deletedBy: userId })
|
||||
|
||||
res.json({ deletedIds, message: 'پیامها حذف شدند' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting messages:', error)
|
||||
res.status(500).json({ error: 'Server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// Errors
|
||||
require('./middlewares/exception')(app)
|
||||
require('./middlewares/404')(app)
|
||||
@@ -336,6 +465,11 @@ io.on('connection', (socket) => {
|
||||
// Free Project
|
||||
require('./services/AdvertisingCron')
|
||||
require('./services/ProjectCron')
|
||||
|
||||
cron.schedule('*/30 * * * * *', () => {
|
||||
purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err))
|
||||
})
|
||||
|
||||
cron.schedule('0 0 1 * *', async () => {
|
||||
try {
|
||||
await UserModel.updateMany({}, {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
const UserModel = require('../models/UserModel') // مسیر درست به مدل کاربر
|
||||
const UserModel = require('../models/UserModel')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const {
|
||||
expireBlockSuspensionIfNeeded,
|
||||
getBlockedAccountResponse
|
||||
} = require('../utils/blockSuspension')
|
||||
|
||||
module.exports = async (req, res, next) => {
|
||||
console.log(2);
|
||||
try {
|
||||
if (!('authorization' in req.headers)) {
|
||||
return res.status(401).send({
|
||||
@@ -24,17 +27,15 @@ module.exports = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(decodedToken.id)
|
||||
if (user.block_status === true) {
|
||||
return res.status(403).send({
|
||||
status: 'error',
|
||||
code: 403,
|
||||
message: 'حساب کاربری شما مسدود شده است، برای اطلاعات بیشتر با پشتیبانی تماس بگیرید!',
|
||||
type: 'block'
|
||||
})
|
||||
let user = await UserModel.findById(decodedToken.id)
|
||||
user = await expireBlockSuspensionIfNeeded(user)
|
||||
|
||||
const blockResponse = getBlockedAccountResponse(user)
|
||||
if (blockResponse) {
|
||||
return res.status(403).send(blockResponse)
|
||||
}
|
||||
|
||||
req.user = user // کاربر را به درخواست اضافه کنید تا در سایر middleware ها و کنترلرها استفاده شود.
|
||||
req.user = user
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('Error in blockCheck middleware:', error)
|
||||
|
||||
@@ -25,7 +25,8 @@ const commentSchema = new mongoose.Schema({
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
required: true
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
comment: {
|
||||
type: String,
|
||||
|
||||
54
models/ExploreInteractionModel.js
Normal file
54
models/ExploreInteractionModel.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const mongoose = require('mongoose')
|
||||
const timestamp = require('mongoose-timestamp')
|
||||
|
||||
const exploreInteractionSchema = new mongoose.Schema({
|
||||
viewerId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
targetType: {
|
||||
type: String,
|
||||
enum: ['post', 'profile', 'academy'],
|
||||
required: true
|
||||
},
|
||||
targetId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
required: true
|
||||
},
|
||||
authorId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
default: null
|
||||
},
|
||||
contentType: {
|
||||
type: String,
|
||||
enum: ['image', 'video', 'academy'],
|
||||
default: 'image'
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
enum: [
|
||||
'view',
|
||||
'profile_visit',
|
||||
'share',
|
||||
'like',
|
||||
'comment',
|
||||
'offer',
|
||||
'watch_complete',
|
||||
'skip',
|
||||
'dwell'
|
||||
],
|
||||
default: 'view'
|
||||
},
|
||||
dwellMs: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
exploreInteractionSchema.index({ viewerId: 1, createdAt: -1 })
|
||||
exploreInteractionSchema.plugin(timestamp)
|
||||
|
||||
module.exports = mongoose.model('ExploreInteraction', exploreInteractionSchema)
|
||||
@@ -43,6 +43,21 @@ const messageSchema = new Schema({
|
||||
readStatus: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/** Auto-delete after this time (self-destruct messages) */
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
required: false,
|
||||
index: true
|
||||
},
|
||||
/** View-once photo / video / voice */
|
||||
viewOnce: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
viewOnceOpenedAt: {
|
||||
type: Date,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
41
models/StoryModel.js
Normal file
41
models/StoryModel.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const mongoose = require('mongoose')
|
||||
const timestamp = require('mongoose-timestamp')
|
||||
|
||||
const storySchema = new mongoose.Schema({
|
||||
user_id: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
media_path: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
media_type: {
|
||||
type: String,
|
||||
enum: ['image', 'video'],
|
||||
required: true
|
||||
},
|
||||
expires_at: {
|
||||
type: Date,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
viewers: [{
|
||||
user_id: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
viewed_at: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
storySchema.index({ expires_at: 1 }, { expireAfterSeconds: 0 })
|
||||
|
||||
storySchema.plugin(timestamp)
|
||||
|
||||
module.exports = mongoose.model('Story', storySchema)
|
||||
@@ -16,6 +16,11 @@ const userSchema = new mongoose.Schema({
|
||||
maxLength: 6,
|
||||
default: null
|
||||
},
|
||||
otpSentAt: {
|
||||
type: Date,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
user_name: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -312,6 +317,11 @@ const userSchema = new mongoose.Schema({
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
blocked_until: {
|
||||
type: Date,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
advertising_profiles: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'AdvertisingProfile'
|
||||
|
||||
@@ -92,6 +92,12 @@ router.get("/academy/get/course", academyController.getCourse);
|
||||
// دریافت لیست همه دورهها با فیلتر و صفحهبندی
|
||||
router.get("/academy/get/curses", academyController.getCourses);
|
||||
|
||||
// محتوای رایگان آموزشگاه برای اکسپلور
|
||||
router.get(
|
||||
"/academy/explore/free-content",
|
||||
academyController.getFreeAcademyExploreContent
|
||||
);
|
||||
|
||||
// دریافت دورههای یک آکادمی خاص
|
||||
router.get("/academy/get/getAcademyCourse/:Id", academyController.getAcademyCourse);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { createPostValidationRules, createPost, getUserPosts, getUserPostsWeb } = require('../../../controllers/application/posts/postController');
|
||||
const { createPostValidationRules, createPost, getUserPosts, getUserPostsWeb, getPostByIdWeb } = require('../../../controllers/application/posts/postController');
|
||||
const { toggleLike } = require('../../../controllers/application/posts/likeController');
|
||||
const blockCheck = require('../../../middlewares/blockCheck');
|
||||
const auth = require('../../../middlewares/auth');
|
||||
@@ -196,6 +196,7 @@ router.post(
|
||||
);
|
||||
|
||||
router.post('/like', [auth, blockCheck], toggleLike);
|
||||
router.get('/web/:postId', getPostByIdWeb);
|
||||
router.get('/user-posts', [auth], getUserPosts);
|
||||
router.get('/user-posts/web', getUserPostsWeb);
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ router.get('/get/:projectId', [auth], getSingleProjects)
|
||||
router.get('/get/web/:projectId', getSingleProjectsWeb)
|
||||
router.get('/payment', handlePaymentCallback)
|
||||
router.get('/payment-web', handlePaymentCallbackWeb)
|
||||
router.post('/create', [blockCheck], [auth], [isRegister], createProjectValidationRules(), createProject)
|
||||
router.post('/edit', [blockCheck], [auth], [isRegister], editProjectValidationRules(), editProject)
|
||||
router.post('/create', [blockCheck], [auth], createProjectValidationRules(), createProject)
|
||||
router.post('/edit', [blockCheck], [auth], editProjectValidationRules(), editProject)
|
||||
router.post('/rate', [blockCheck], [auth], [isRegister], setRateProject)
|
||||
router.post('/initiate-payment', [blockCheck], [isRegister], [auth], initiatePayment)
|
||||
router.post('/initiate-payment-web', [blockCheck], [isRegister], [auth], initiatePaymentWeb)
|
||||
|
||||
19
routes/application/stories/index.js
Normal file
19
routes/application/stories/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const express = require('express')
|
||||
const auth = require('../../../middlewares/auth')
|
||||
const blockCheck = require('../../../middlewares/blockCheck')
|
||||
const isHalfRegister = require('../../../middlewares/isHalfRegister')
|
||||
const {
|
||||
createStoryBase64,
|
||||
getStoriesFeed,
|
||||
markStoryViewed,
|
||||
deleteStory
|
||||
} = require('../../../controllers/application/stories/storyController')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/feed', getStoriesFeed)
|
||||
router.post('/create-base64', auth, blockCheck, isHalfRegister, createStoryBase64)
|
||||
router.post('/view', auth, blockCheck, markStoryViewed)
|
||||
router.delete('/:storyId', auth, blockCheck, deleteStory)
|
||||
|
||||
module.exports = router
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express')
|
||||
const { getUsers, getSingleUser, blockUser, unblockUser, getComments,createLicense, getAllLicenses,updateLicenseConfirmation, getLicenseByUserId, inviteEmployer, getUserContactInfo, getPostsWeb, getSingleUserWeb, createUserComment } = require('../../../controllers/application/users/getUserController')
|
||||
const { getUsers, getSingleUser, blockUser, unblockUser, getComments,createLicense, getAllLicenses,updateLicenseConfirmation, getLicenseByUserId, inviteEmployer, getUserContactInfo, getPostsWeb, getSingleUserWeb, createUserComment, recordExploreInteraction } = require('../../../controllers/application/users/getUserController')
|
||||
const blockCheck = require('../../../middlewares/blockCheck')
|
||||
const isRegister = require('../../../middlewares/isRegister')
|
||||
const auth = require('../../../middlewares/auth')
|
||||
@@ -12,6 +12,7 @@ router.post('/block', [auth], [isRegister], [blockCheck], blockUser)
|
||||
router.post('/unblock', [auth], [isRegister], [blockCheck], unblockUser)
|
||||
router.get('/comments', [auth], getComments)
|
||||
router.post('/comments', [auth], [isRegister], [blockCheck], createUserComment)
|
||||
router.post('/explore/interaction', [auth], recordExploreInteraction)
|
||||
router.get('/invite-employer', [auth], [isRegister], [blockCheck], inviteEmployer)
|
||||
router.get('/contact-info', [auth], getUserContactInfo)
|
||||
router.get('/License', getAllLicenses)
|
||||
|
||||
@@ -9,6 +9,7 @@ const { setConversation, updateConversation } = require('../../../controllers/ap
|
||||
const { saveAuth, saveAuthValidationRules, updateShaba } = require('../../../controllers/application/verify/authController')
|
||||
const { setAddress, updateAddress } = require('../../../controllers/application/verify/addressController')
|
||||
const { setNationalCardImage } = require('../../../controllers/application/verify/setNationalCardImageController')
|
||||
const { completeRegistration } = require('../../../controllers/application/verify/completeRegistrationController')
|
||||
const blockCheck = require('../../../middlewares/blockCheck')
|
||||
const auth = require('../../../middlewares/auth')
|
||||
const { setServices, updateServices } = require('../../../controllers/application/verify/servicesController')
|
||||
@@ -34,5 +35,6 @@ router.patch('/shaba', [blockCheck], [auth], updateShaba)
|
||||
router.post('/address', setAddress)
|
||||
router.patch('/address', [blockCheck], [auth], updateAddress)
|
||||
router.post('/national_card_image', setNationalCardImage)
|
||||
router.post('/complete', completeRegistration)
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -20,6 +20,7 @@ const settingsRouter = require('./application/settings')
|
||||
const versionRouter = require('./application/version')
|
||||
const offerRouter = require('./application/offers')
|
||||
const academyRoute = require('./application/academy')
|
||||
const storiesRouter = require('./application/stories')
|
||||
|
||||
const projectsPanelRouter = require('./panel/projects')
|
||||
const postsPanelRouter = require('./panel/posts')
|
||||
@@ -60,6 +61,7 @@ module.exports = (app) => {
|
||||
app.use('/api/v1/settings', settingsRouter)
|
||||
app.use('/api/v1/version', versionRouter)
|
||||
app.use('/api/v1/academy', academyRoute)
|
||||
app.use('/api/v1/stories', storiesRouter)
|
||||
|
||||
// Panel
|
||||
app.use('/api/v1/panel/login', loginPanelRouter)
|
||||
|
||||
77
utils/blockSuspension.js
Normal file
77
utils/blockSuspension.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const moment = require('moment-jalaali')
|
||||
|
||||
const BLOCK_THRESHOLD = 5
|
||||
const BLOCK_DURATION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
const applyAutoBlockSuspension = (user) => {
|
||||
if (user.blocked_by.length >= BLOCK_THRESHOLD) {
|
||||
user.block_status = true
|
||||
user.blocked_until = new Date(Date.now() + BLOCK_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const clearAutoBlockSuspension = (user) => {
|
||||
if (user.blocked_by.length < BLOCK_THRESHOLD && user.blocked_until) {
|
||||
user.block_status = null
|
||||
user.blocked_until = null
|
||||
}
|
||||
}
|
||||
|
||||
const isUserCurrentlyBlocked = (user) => {
|
||||
if (!user.block_status) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (user.blocked_until) {
|
||||
if (new Date() >= new Date(user.blocked_until)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return user.block_status === true
|
||||
}
|
||||
|
||||
const expireBlockSuspensionIfNeeded = async (user) => {
|
||||
if (!user.blocked_until) {
|
||||
return user
|
||||
}
|
||||
|
||||
if (new Date() >= new Date(user.blocked_until)) {
|
||||
user.block_status = null
|
||||
user.blocked_until = null
|
||||
await user.save()
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
const getBlockedAccountResponse = (user) => {
|
||||
if (!isUserCurrentlyBlocked(user)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const untilText = user.blocked_until
|
||||
? moment(user.blocked_until).format('jYYYY/jMM/jDD')
|
||||
: null
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
code: 403,
|
||||
message: untilText
|
||||
? `حساب کاربری شما به دلیل بلاک شدن توسط ${BLOCK_THRESHOLD} کاربر تا تاریخ ${untilText} غیرفعال است.`
|
||||
: 'حساب کاربری شما مسدود شده است، برای اطلاعات بیشتر با پشتیبانی تماس بگیرید!',
|
||||
type: 'block',
|
||||
blocked_until: user.blocked_until || null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BLOCK_THRESHOLD,
|
||||
BLOCK_DURATION_MS,
|
||||
applyAutoBlockSuspension,
|
||||
clearAutoBlockSuspension,
|
||||
isUserCurrentlyBlocked,
|
||||
expireBlockSuspensionIfNeeded,
|
||||
getBlockedAccountResponse
|
||||
}
|
||||
49
utils/blockVisibility.js
Normal file
49
utils/blockVisibility.js
Normal file
@@ -0,0 +1,49 @@
|
||||
function listIncludesUserId(list, userId) {
|
||||
if (!userId || !list?.length) return false
|
||||
return list.some((id) => String(id) === String(userId))
|
||||
}
|
||||
|
||||
/** Viewer has blocked the profile owner */
|
||||
function viewerBlockedUser(user, viewerId) {
|
||||
if (!user || !viewerId) return false
|
||||
return listIncludesUserId(user.blocked_by, viewerId)
|
||||
}
|
||||
|
||||
/** Profile owner has blocked the viewer */
|
||||
function userBlockedViewer(user, viewerId) {
|
||||
if (!user || !viewerId) return false
|
||||
return listIncludesUserId(user.blocked_users, viewerId)
|
||||
}
|
||||
|
||||
/** Viewer (blocked person) cannot see profile/posts of user who blocked them */
|
||||
function viewerIsBlockedBy(user, viewerId) {
|
||||
return userBlockedViewer(user, viewerId)
|
||||
}
|
||||
|
||||
function blockedProfilePayload(user) {
|
||||
return {
|
||||
_id: user._id,
|
||||
user_name: user.user_name,
|
||||
blocked_you: true,
|
||||
is_self: false,
|
||||
user_type: user.user_type,
|
||||
postsCount: 0,
|
||||
allProjectsCount: 0,
|
||||
successfulProjectsCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** User IDs who have blocked the viewer */
|
||||
async function getBlockerUserIds(UserModel, viewerId) {
|
||||
if (!viewerId) return []
|
||||
return UserModel.find({ blocked_users: viewerId }).distinct('_id')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listIncludesUserId,
|
||||
viewerBlockedUser,
|
||||
userBlockedViewer,
|
||||
viewerIsBlockedBy,
|
||||
blockedProfilePayload,
|
||||
getBlockerUserIds,
|
||||
}
|
||||
57
utils/commentNotification.js
Normal file
57
utils/commentNotification.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const NotificationModel = require('../models/NotificationModel')
|
||||
const { resolveCourseOwnerId } = require('./likeNotification')
|
||||
|
||||
const COMMENT_TITLE = 'یک کامنت برای شما ثبت شد'
|
||||
const COMMENT_DESCRIPTION = 'یک کامنت برای شما ثبت شد'
|
||||
const RATING_TITLE = 'یک امتیاز برای شما ثبت شد'
|
||||
const RATING_DESCRIPTION = 'یک امتیاز برای شما ثبت شد'
|
||||
|
||||
const createCommentNotification = async ({
|
||||
ownerId,
|
||||
commenterId,
|
||||
entityId,
|
||||
type = 'post_comment'
|
||||
}) => {
|
||||
try {
|
||||
if (!ownerId || !commenterId || !entityId) return
|
||||
if (String(ownerId) === String(commenterId)) return
|
||||
|
||||
await NotificationModel.create({
|
||||
user_id: ownerId,
|
||||
project_post_id: entityId,
|
||||
type,
|
||||
title: COMMENT_TITLE,
|
||||
description: COMMENT_DESCRIPTION
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating comment notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const createRatingNotification = async ({
|
||||
ownerId,
|
||||
raterId,
|
||||
entityId,
|
||||
type = 'billboard_rating'
|
||||
}) => {
|
||||
try {
|
||||
if (!ownerId || !raterId || !entityId) return
|
||||
if (String(ownerId) === String(raterId)) return
|
||||
|
||||
await NotificationModel.create({
|
||||
user_id: ownerId,
|
||||
project_post_id: entityId,
|
||||
type,
|
||||
title: RATING_TITLE,
|
||||
description: RATING_DESCRIPTION
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating rating notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCommentNotification,
|
||||
createRatingNotification,
|
||||
resolveCourseOwnerId
|
||||
}
|
||||
91
utils/commentRating.js
Normal file
91
utils/commentRating.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const CommentModel = require('../models/CommentModel')
|
||||
const UserModel = require('../models/UserModel')
|
||||
|
||||
const RATED_FILTER = { $ne: null, $gt: 0 }
|
||||
|
||||
const findExistingUserRating = (creatorId, targetUserId) => {
|
||||
return CommentModel.findOne({
|
||||
creator: creatorId,
|
||||
user: targetUserId,
|
||||
rating: RATED_FILTER
|
||||
})
|
||||
}
|
||||
|
||||
const hasCreatorRatedUser = async (creatorId, targetUserId) => {
|
||||
const existing = await findExistingUserRating(creatorId, targetUserId)
|
||||
return !!existing
|
||||
}
|
||||
|
||||
const resolveRatingForComment = async ({ creatorId, targetUserId, rate }) => {
|
||||
const existingRating = await findExistingUserRating(creatorId, targetUserId)
|
||||
|
||||
if (existingRating) {
|
||||
if (rate && rate >= 1 && rate <= 5) {
|
||||
return {
|
||||
ratingValue: null,
|
||||
isNewRating: false,
|
||||
alreadyRated: true,
|
||||
}
|
||||
}
|
||||
return { ratingValue: null, isNewRating: false }
|
||||
}
|
||||
|
||||
if (!rate || rate < 1 || rate > 5) {
|
||||
return { ratingValue: null, isNewRating: false, requiresRating: true }
|
||||
}
|
||||
|
||||
return { ratingValue: rate, isNewRating: true }
|
||||
}
|
||||
|
||||
const getUserLevel = (user, totalRating) => {
|
||||
if (user.expertise === 'مدل') {
|
||||
if (totalRating <= 50) return 'تازه وارد'
|
||||
if (totalRating <= 100) return 'استاندارد'
|
||||
if (totalRating <= 300) return 'حرفهای'
|
||||
return 'استاد'
|
||||
}
|
||||
|
||||
if (['زیبایی', 'عکاس'].includes(user.expertise)) {
|
||||
if (totalRating <= 50) return 'تازه وارد'
|
||||
if (totalRating <= 100) return 'استاندارد'
|
||||
if (totalRating <= 300) return 'حرفهای'
|
||||
return 'استاد'
|
||||
}
|
||||
|
||||
return user.user_level
|
||||
}
|
||||
|
||||
const recalculateUserRating = async (targetUserId) => {
|
||||
const user = await UserModel.findById(targetUserId)
|
||||
if (!user) return
|
||||
|
||||
const ratedComments = await CommentModel.find({
|
||||
user: targetUserId,
|
||||
rating: RATED_FILTER
|
||||
}).sort({ createdAt: 1 })
|
||||
|
||||
const uniqueRatingsByCreator = new Map()
|
||||
for (const comment of ratedComments) {
|
||||
const creatorKey = comment.creator.toString()
|
||||
if (!uniqueRatingsByCreator.has(creatorKey)) {
|
||||
uniqueRatingsByCreator.set(creatorKey, comment.rating)
|
||||
}
|
||||
}
|
||||
|
||||
const ratings = Array.from(uniqueRatingsByCreator.values())
|
||||
const totalRating = ratings.reduce((acc, rating) => acc + rating, 0)
|
||||
const ratedCount = ratings.length
|
||||
|
||||
user.user_score = totalRating
|
||||
user.user_level = getUserLevel(user, totalRating)
|
||||
user.rate = ratedCount > 0 ? (totalRating / ratedCount).toFixed(1) : '0'
|
||||
await user.save()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RATED_FILTER,
|
||||
findExistingUserRating,
|
||||
hasCreatorRatedUser,
|
||||
resolveRatingForComment,
|
||||
recalculateUserRating
|
||||
}
|
||||
543
utils/exploreAlgorithm.js
Normal file
543
utils/exploreAlgorithm.js
Normal file
@@ -0,0 +1,543 @@
|
||||
const LikeModel = require('../models/LikeModel')
|
||||
const CommentModel = require('../models/CommentModel')
|
||||
const OfferModel = require('../models/OfferModel')
|
||||
const UserModel = require('../models/UserModel')
|
||||
const PostModel = require('../models/PostModel')
|
||||
const ExploreInteractionModel = require('../models/ExploreInteractionModel')
|
||||
|
||||
const EXPERTISE_ALIASES = {
|
||||
مدل: ['مدل', 'مدلینگ'],
|
||||
مدلینگ: ['مدل', 'مدلینگ'],
|
||||
عکاس: ['عکاس', 'عکاسی'],
|
||||
عکاسی: ['عکاس', 'عکاسی'],
|
||||
آرایشگر: ['آرایشگر', 'زیبایی'],
|
||||
زیبایی: ['آرایشگر', 'زیبایی']
|
||||
}
|
||||
|
||||
const ACTION_WEIGHTS = {
|
||||
offer: 10,
|
||||
profile_visit: 6,
|
||||
share: 5,
|
||||
comment: 4,
|
||||
like: 3,
|
||||
watch_complete: 3.5,
|
||||
view: 2,
|
||||
skip: -1.5,
|
||||
dwell: 1.5
|
||||
}
|
||||
|
||||
function expertiseMatches(userExpertise, authorExpertise) {
|
||||
if (!userExpertise || !authorExpertise) return false
|
||||
const aliases = EXPERTISE_ALIASES[userExpertise] || [userExpertise]
|
||||
return aliases.includes(authorExpertise)
|
||||
}
|
||||
|
||||
function interactionDecay(createdAt) {
|
||||
const ageDays =
|
||||
(Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
return Math.exp(-ageDays / 21)
|
||||
}
|
||||
|
||||
function engagementVelocity(likesCount, commentsCount, ageHours) {
|
||||
const raw = likesCount * 0.55 + commentsCount * 1.1
|
||||
const ageFactor = Math.max(1, ageHours / 24)
|
||||
return raw / Math.sqrt(ageFactor)
|
||||
}
|
||||
|
||||
function shuffleArray(items, seed = Date.now()) {
|
||||
const copy = [...items]
|
||||
let state = seed % 2147483647 || 1
|
||||
const random = () => {
|
||||
state = (state * 16807) % 2147483647
|
||||
return (state - 1) / 2147483646
|
||||
}
|
||||
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(random() * (i + 1))
|
||||
;[copy[i], copy[j]] = [copy[j], copy[i]]
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
function interleaveWeighted(primary, secondary, primaryRatio = 0.7) {
|
||||
if (!primary.length) return shuffleArray(secondary)
|
||||
if (!secondary.length) return shuffleArray(primary)
|
||||
|
||||
const result = []
|
||||
let primaryIdx = 0
|
||||
let secondaryIdx = 0
|
||||
const primaryBurst = Math.max(2, Math.round(primaryRatio * 10))
|
||||
const secondaryBurst = Math.max(1, 10 - primaryBurst)
|
||||
let primaryCount = 0
|
||||
let secondaryCount = 0
|
||||
|
||||
while (primaryIdx < primary.length || secondaryIdx < secondary.length) {
|
||||
const shouldTakeSecondary =
|
||||
secondaryIdx < secondary.length &&
|
||||
(primaryIdx >= primary.length ||
|
||||
(secondaryCount < secondaryBurst &&
|
||||
primaryCount >= primaryBurst))
|
||||
|
||||
if (shouldTakeSecondary) {
|
||||
result.push(secondary[secondaryIdx])
|
||||
secondaryIdx += 1
|
||||
secondaryCount += 1
|
||||
primaryCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (primaryIdx < primary.length) {
|
||||
result.push(primary[primaryIdx])
|
||||
primaryIdx += 1
|
||||
primaryCount += 1
|
||||
secondaryCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (secondaryIdx < secondary.length) {
|
||||
result.push(secondary[secondaryIdx])
|
||||
secondaryIdx += 1
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function matchesSeedCategory(author, seedAuthor) {
|
||||
if (!seedAuthor || !author) return false
|
||||
|
||||
if (
|
||||
seedAuthor.expertise &&
|
||||
expertiseMatches(seedAuthor.expertise, author.expertise)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
seedAuthor.sub_expertise?.length &&
|
||||
author.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasEnoughUserSignals(signals) {
|
||||
const meaningful =
|
||||
(signals.likedPostIds?.length || 0) +
|
||||
(signals.commentedPostIds?.length || 0) +
|
||||
Object.keys(signals.offerAuthors || {}).length
|
||||
|
||||
const affinityStrength = Object.values(signals.authorAffinity || {}).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0
|
||||
)
|
||||
|
||||
return meaningful >= 8 || affinityStrength >= 22
|
||||
}
|
||||
|
||||
function scoreColdStartPost(post, author, seedPost, seedAuthor) {
|
||||
let score = Math.random() * 80
|
||||
|
||||
if (matchesSeedCategory(author, seedAuthor)) {
|
||||
score += 50
|
||||
}
|
||||
|
||||
if (
|
||||
seedAuthor?.sub_expertise?.length &&
|
||||
author?.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
|
||||
) {
|
||||
score += 22
|
||||
}
|
||||
|
||||
if (seedPost?.type && post.type === seedPost.type) {
|
||||
score += 14
|
||||
}
|
||||
|
||||
const likesCount = post.likes ? post.likes.length : post.likesCount || 0
|
||||
const commentsCount = post.commentsCount || 0
|
||||
const ageHours =
|
||||
(Date.now() - new Date(post.createdAt).getTime()) / (1000 * 60 * 60)
|
||||
score += engagementVelocity(likesCount, commentsCount, ageHours) * 0.25
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function rankColdStartReels(pool, usersById, seedAuthor, seedPost) {
|
||||
const sameCategory = []
|
||||
const others = []
|
||||
|
||||
pool.forEach((post) => {
|
||||
const author = usersById[String(post.user_id)] || {}
|
||||
if (matchesSeedCategory(author, seedAuthor)) {
|
||||
sameCategory.push(post)
|
||||
} else {
|
||||
others.push(post)
|
||||
}
|
||||
})
|
||||
|
||||
const seedKey = seedPost?._id ? String(seedPost._id) : '0'
|
||||
const shuffledSame = shuffleArray(sameCategory, seedKey.length * 997)
|
||||
const shuffledOthers = shuffleArray(others, seedKey.length * 499)
|
||||
|
||||
return interleaveWeighted(shuffledSame, shuffledOthers, 0.72)
|
||||
}
|
||||
|
||||
function diversifyByAuthor(posts, maxPerPage = 2) {
|
||||
const authorCounts = {}
|
||||
const picked = []
|
||||
const deferred = []
|
||||
|
||||
for (const post of posts) {
|
||||
const authorId = String(post.user_id)
|
||||
const count = authorCounts[authorId] || 0
|
||||
if (count < maxPerPage) {
|
||||
picked.push(post)
|
||||
authorCounts[authorId] = count + 1
|
||||
} else {
|
||||
deferred.push(post)
|
||||
}
|
||||
}
|
||||
|
||||
return [...picked, ...deferred]
|
||||
}
|
||||
|
||||
function blendFreshIntoRanked(rankedPosts, allPosts, ratio = 0.12) {
|
||||
if (!rankedPosts.length) return rankedPosts
|
||||
|
||||
const rankedIds = new Set(rankedPosts.map((p) => String(p._id)))
|
||||
const fresh = [...allPosts]
|
||||
.filter((p) => !rankedIds.has(String(p._id)))
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
if (!fresh.length) return rankedPosts
|
||||
|
||||
const result = [...rankedPosts]
|
||||
const step = Math.max(4, Math.round(1 / ratio))
|
||||
|
||||
let freshIdx = 0
|
||||
for (let i = step - 1; i < result.length && freshIdx < fresh.length; i += step) {
|
||||
result.splice(i, 0, fresh[freshIdx])
|
||||
freshIdx += 1
|
||||
}
|
||||
|
||||
while (freshIdx < fresh.length && result.length < rankedPosts.length + fresh.length) {
|
||||
result.push(fresh[freshIdx])
|
||||
freshIdx += 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function buildUserExploreSignals(userId) {
|
||||
if (!userId) {
|
||||
return {
|
||||
userExpertise: null,
|
||||
userSubExpertise: [],
|
||||
contentTypeWeight: { image: 0, video: 0, academy: 0 },
|
||||
authorAffinity: {},
|
||||
offerAuthors: {},
|
||||
viewedPosts: {},
|
||||
viewedAuthors: {},
|
||||
likedPostIds: [],
|
||||
commentedPostIds: [],
|
||||
isColdStart: true
|
||||
}
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('expertise sub_expertise')
|
||||
.lean()
|
||||
|
||||
const [likes, comments, offers, interactions] = await Promise.all([
|
||||
LikeModel.find({ userId }).select('postId createdAt').lean(),
|
||||
CommentModel.find({
|
||||
creator: userId,
|
||||
comment_for: 'post',
|
||||
post: { $ne: null }
|
||||
})
|
||||
.select('post user createdAt')
|
||||
.lean(),
|
||||
OfferModel.find({ sender: userId }).select('receiver createdAt').lean(),
|
||||
ExploreInteractionModel.find({ viewerId: userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(1200)
|
||||
.lean()
|
||||
])
|
||||
|
||||
const likedPostIds = likes.map((l) => String(l.postId))
|
||||
const likedPosts = likedPostIds.length
|
||||
? await PostModel.find({ _id: { $in: likedPostIds } })
|
||||
.select('user_id type')
|
||||
.lean()
|
||||
: []
|
||||
|
||||
const contentTypeWeight = { image: 0, video: 0, academy: 0 }
|
||||
const authorAffinity = {}
|
||||
const offerAuthors = {}
|
||||
const viewedPosts = {}
|
||||
const viewedAuthors = {}
|
||||
|
||||
const bumpAuthor = (authorId, weight) => {
|
||||
if (!authorId) return
|
||||
const key = String(authorId)
|
||||
authorAffinity[key] = (authorAffinity[key] || 0) + weight
|
||||
}
|
||||
|
||||
const bumpContent = (type, weight) => {
|
||||
if (!type) return
|
||||
contentTypeWeight[type] = (contentTypeWeight[type] || 0) + weight
|
||||
}
|
||||
|
||||
const bumpViewedPost = (postId, weight) => {
|
||||
if (!postId) return
|
||||
const key = String(postId)
|
||||
viewedPosts[key] = Math.max(viewedPosts[key] || 0, weight)
|
||||
}
|
||||
|
||||
const bumpViewedAuthor = (authorId, weight) => {
|
||||
if (!authorId) return
|
||||
const key = String(authorId)
|
||||
viewedAuthors[key] = Math.max(viewedAuthors[key] || 0, weight)
|
||||
}
|
||||
|
||||
offers.forEach((offer) => {
|
||||
const decay = interactionDecay(offer.createdAt)
|
||||
const key = String(offer.receiver)
|
||||
offerAuthors[key] = (offerAuthors[key] || 0) + 12 * decay
|
||||
bumpAuthor(offer.receiver, 8 * decay)
|
||||
})
|
||||
|
||||
likedPosts.forEach((post) => {
|
||||
bumpAuthor(post.user_id, 4)
|
||||
bumpContent(post.type, 2)
|
||||
})
|
||||
|
||||
comments.forEach((comment) => {
|
||||
const decay = interactionDecay(comment.createdAt)
|
||||
bumpAuthor(comment.user, 5 * decay)
|
||||
})
|
||||
|
||||
interactions.forEach((item) => {
|
||||
const decay = interactionDecay(item.createdAt)
|
||||
const base =
|
||||
ACTION_WEIGHTS[item.action] ?? ACTION_WEIGHTS.view
|
||||
const weight = base * decay
|
||||
|
||||
if (item.contentType) bumpContent(item.contentType, weight * 0.5)
|
||||
if (item.authorId) bumpAuthor(item.authorId, weight)
|
||||
|
||||
if (
|
||||
item.targetType === 'post' &&
|
||||
['view', 'watch_complete', 'skip', 'dwell'].includes(item.action)
|
||||
) {
|
||||
const viewWeight =
|
||||
item.action === 'watch_complete'
|
||||
? 1
|
||||
: item.action === 'skip'
|
||||
? 0.85
|
||||
: item.action === 'dwell'
|
||||
? 0.55
|
||||
: 0.35
|
||||
bumpViewedPost(item.targetId, viewWeight * decay)
|
||||
if (item.authorId) bumpViewedAuthor(item.authorId, viewWeight * decay * 0.6)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
userExpertise: user?.expertise || null,
|
||||
userSubExpertise: user?.sub_expertise || [],
|
||||
contentTypeWeight,
|
||||
authorAffinity,
|
||||
offerAuthors,
|
||||
viewedPosts,
|
||||
viewedAuthors,
|
||||
likedPostIds,
|
||||
commentedPostIds: comments.map((c) => String(c.post)),
|
||||
isColdStart: !hasEnoughUserSignals({
|
||||
likedPostIds,
|
||||
commentedPostIds: comments.map((c) => String(c.post)),
|
||||
offerAuthors,
|
||||
authorAffinity
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scorePost(post, author, signals, options = {}) {
|
||||
const { reelsMode = false, seedPost = null, seedAuthor = null } = options
|
||||
let score = Math.random() * 4
|
||||
|
||||
const postId = String(post._id)
|
||||
const authorId = String(post.user_id)
|
||||
const likesCount = post.likes ? post.likes.length : post.likesCount || 0
|
||||
const commentsCount = post.commentsCount || 0
|
||||
|
||||
const ageHours =
|
||||
(Date.now() - new Date(post.createdAt).getTime()) / (1000 * 60 * 60)
|
||||
|
||||
score += engagementVelocity(likesCount, commentsCount, ageHours)
|
||||
|
||||
if (signals.userExpertise && expertiseMatches(signals.userExpertise, author?.expertise)) {
|
||||
score += 24
|
||||
}
|
||||
|
||||
if (
|
||||
signals.userSubExpertise?.length &&
|
||||
author?.sub_expertise?.some((s) => signals.userSubExpertise.includes(s))
|
||||
) {
|
||||
score += 16
|
||||
}
|
||||
|
||||
if (post.type && signals.contentTypeWeight[post.type]) {
|
||||
score += signals.contentTypeWeight[post.type] * 2
|
||||
}
|
||||
|
||||
score += signals.authorAffinity[authorId] || 0
|
||||
score += signals.offerAuthors[authorId] || 0
|
||||
|
||||
if (signals.likedPostIds?.includes(postId)) score -= 35
|
||||
if (signals.commentedPostIds?.includes(postId)) score -= 28
|
||||
|
||||
const viewedWeight = signals.viewedPosts?.[postId] || 0
|
||||
if (viewedWeight > 0) score -= 8 + viewedWeight * 22
|
||||
|
||||
const authorFatigue = signals.viewedAuthors?.[authorId] || 0
|
||||
if (authorFatigue > 0) score -= authorFatigue * 6
|
||||
|
||||
if (ageHours < 24) score += 6
|
||||
else if (ageHours < 48) score += 4
|
||||
else if (ageHours < 168) score += 2
|
||||
|
||||
if (reelsMode && post.type === 'video') score += 14
|
||||
|
||||
if (seedPost) {
|
||||
if (String(seedPost.user_id) === authorId) score += 20
|
||||
if (
|
||||
seedAuthor?.expertise &&
|
||||
expertiseMatches(seedAuthor.expertise, author?.expertise)
|
||||
) {
|
||||
score += 18
|
||||
}
|
||||
if (
|
||||
seedAuthor?.sub_expertise?.length &&
|
||||
author?.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
|
||||
) {
|
||||
score += 8
|
||||
}
|
||||
if (seedPost.type && post.type === seedPost.type) score += 6
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
async function rankPostsForFeed({
|
||||
posts,
|
||||
usersById,
|
||||
userId,
|
||||
seedPostId,
|
||||
reelsMode = false,
|
||||
blendFresh = false
|
||||
}) {
|
||||
const signals = await buildUserExploreSignals(userId)
|
||||
const coldStart = signals.isColdStart
|
||||
|
||||
let seedPost = null
|
||||
let seedAuthor = null
|
||||
if (seedPostId) {
|
||||
seedPost = posts.find((p) => String(p._id) === String(seedPostId)) || null
|
||||
if (!seedPost) {
|
||||
seedPost = await PostModel.findById(seedPostId).lean()
|
||||
}
|
||||
if (seedPost) {
|
||||
seedAuthor =
|
||||
usersById[String(seedPost.user_id)] ||
|
||||
(await UserModel.findById(seedPost.user_id)
|
||||
.select(
|
||||
'_id expertise sub_expertise user_name first_name last_name profile_image'
|
||||
)
|
||||
.lean())
|
||||
if (seedAuthor && !usersById[String(seedPost.user_id)]) {
|
||||
usersById[String(seedPost.user_id)] = seedAuthor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pool = seedPost
|
||||
? posts.filter((p) => String(p._id) !== String(seedPostId))
|
||||
: posts
|
||||
|
||||
let ranked = []
|
||||
|
||||
if (coldStart && reelsMode && seedAuthor) {
|
||||
ranked = rankColdStartReels(pool, usersById, seedAuthor, seedPost)
|
||||
} else if (coldStart && reelsMode && seedPost) {
|
||||
ranked = shuffleArray(pool, String(seedPostId).length * 131)
|
||||
} else if (coldStart) {
|
||||
ranked = shuffleArray(pool)
|
||||
if (blendFresh) {
|
||||
ranked = blendFreshIntoRanked(ranked, pool, 0.2)
|
||||
}
|
||||
} else {
|
||||
const scored = pool
|
||||
.map((post) => ({
|
||||
post,
|
||||
score: scorePost(post, usersById[String(post.user_id)] || {}, signals, {
|
||||
reelsMode,
|
||||
seedPost,
|
||||
seedAuthor
|
||||
})
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((item) => item.post)
|
||||
|
||||
ranked = diversifyByAuthor(scored, reelsMode ? 1 : 2)
|
||||
|
||||
if (blendFresh) {
|
||||
ranked = blendFreshIntoRanked(ranked, pool)
|
||||
}
|
||||
}
|
||||
|
||||
if (seedPost) {
|
||||
ranked = [seedPost, ...ranked]
|
||||
}
|
||||
|
||||
return ranked
|
||||
}
|
||||
|
||||
async function paginatePersonalizedExplore({
|
||||
posts,
|
||||
usersById,
|
||||
page,
|
||||
limit,
|
||||
userId,
|
||||
seedPostId,
|
||||
reelsMode = false,
|
||||
blendFresh = false
|
||||
}) {
|
||||
const pageNum = Math.max(1, parseInt(page, 10) || 1)
|
||||
const limitNum = Math.max(1, parseInt(limit, 10) || 10)
|
||||
|
||||
const ranked = await rankPostsForFeed({
|
||||
posts,
|
||||
usersById,
|
||||
userId,
|
||||
seedPostId,
|
||||
reelsMode,
|
||||
blendFresh: blendFresh && !seedPostId && !reelsMode
|
||||
})
|
||||
|
||||
const totalItems = ranked.length
|
||||
const startIndex = (pageNum - 1) * limitNum
|
||||
|
||||
return {
|
||||
posts: ranked.slice(startIndex, startIndex + limitNum),
|
||||
totalItems
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildUserExploreSignals,
|
||||
paginatePersonalizedExplore,
|
||||
rankPostsForFeed,
|
||||
scorePost
|
||||
}
|
||||
47
utils/likeNotification.js
Normal file
47
utils/likeNotification.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const NotificationModel = require('../models/NotificationModel')
|
||||
const AcademyModel = require('../models/AcademyModel')
|
||||
|
||||
const LIKE_TITLE = 'پست شما لایک شد'
|
||||
const LIKE_DESCRIPTION = 'پست شما لایک شد'
|
||||
|
||||
const createLikeNotification = async ({
|
||||
ownerId,
|
||||
likerId,
|
||||
entityId,
|
||||
type = 'post_like'
|
||||
}) => {
|
||||
try {
|
||||
if (!ownerId || !likerId || !entityId) return
|
||||
if (String(ownerId) === String(likerId)) return
|
||||
|
||||
await NotificationModel.create({
|
||||
user_id: ownerId,
|
||||
project_post_id: entityId,
|
||||
type,
|
||||
title: LIKE_TITLE,
|
||||
description: LIKE_DESCRIPTION
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating like notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveCourseOwnerId = async (course) => {
|
||||
if (course?.user_id) {
|
||||
return course.user_id
|
||||
}
|
||||
|
||||
if (course?.academyId) {
|
||||
const academy = await AcademyModel.findById(course.academyId)
|
||||
if (academy?.userId) {
|
||||
return academy.userId
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLikeNotification,
|
||||
resolveCourseOwnerId
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const OTP_VALID_MS = 5 * 60 * 1000
|
||||
const OTP_VALID_MS = 3 * 60 * 1000
|
||||
|
||||
const isOtpExpired = (user) => {
|
||||
if (!user?.otp) return true
|
||||
|
||||
20
utils/projectProfile.js
Normal file
20
utils/projectProfile.js
Normal file
@@ -0,0 +1,20 @@
|
||||
function canCreateProject(user) {
|
||||
return Boolean(
|
||||
user?.user_name?.trim() &&
|
||||
user?.first_name?.trim() &&
|
||||
user?.last_name?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function respondProjectProfileIncomplete(res) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message:
|
||||
"برای ثبت پروژه، تکمیل نام کاربری، نام و نام خانوادگی در تنظیمات الزامی است",
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canCreateProject,
|
||||
respondProjectProfileIncomplete,
|
||||
};
|
||||
48
utils/userLevelFilter.js
Normal file
48
utils/userLevelFilter.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const USER_LEVELS = {
|
||||
NEW: 'تازه وارد',
|
||||
STANDARD: 'استاندارد',
|
||||
PRO: 'حرفهای',
|
||||
MASTER: 'استاد',
|
||||
}
|
||||
|
||||
/** Normalize level strings from URL/query (handles ی / ZWNJ variants). */
|
||||
function normalizeUserLevel(raw) {
|
||||
if (raw == null || raw === '') return null
|
||||
|
||||
const text = String(raw)
|
||||
.trim()
|
||||
.replace(/\u200c/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
|
||||
if (text === USER_LEVELS.NEW) return USER_LEVELS.NEW
|
||||
if (text === USER_LEVELS.STANDARD) return USER_LEVELS.STANDARD
|
||||
if (text === USER_LEVELS.MASTER) return USER_LEVELS.MASTER
|
||||
if (text === USER_LEVELS.PRO || text === 'حرفه ای') return USER_LEVELS.PRO
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/** Build Mongo filter for user_level (includes unset levels for تازه وارد). */
|
||||
function buildUserLevelMongoFilter(rawLevel) {
|
||||
const level = normalizeUserLevel(rawLevel)
|
||||
if (!level) return null
|
||||
|
||||
if (level === USER_LEVELS.NEW) {
|
||||
return {
|
||||
$or: [
|
||||
{ user_level: USER_LEVELS.NEW },
|
||||
{ user_level: null },
|
||||
{ user_level: '' },
|
||||
{ user_level: { $exists: false } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return { user_level: level }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
USER_LEVELS,
|
||||
normalizeUserLevel,
|
||||
buildUserLevelMongoFilter,
|
||||
}
|
||||
56
utils/usernameSuggestions.js
Normal file
56
utils/usernameSuggestions.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const { validateUsername, USERNAME_MAX_LENGTH } = require('./usernameValidation')
|
||||
|
||||
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const isUsernameTaken = async (UserModel, userName, excludeUserId) => {
|
||||
const existing = await UserModel.findOne({
|
||||
user_name: { $regex: new RegExp(`^${escapeRegex(userName)}$`, 'i') }
|
||||
})
|
||||
|
||||
if (!existing) return false
|
||||
if (excludeUserId && existing._id.toString() === excludeUserId) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const buildCandidates = (base, attempt) => {
|
||||
const random2 = () => String(Math.floor(Math.random() * 90 + 10))
|
||||
const random3 = () => String(Math.floor(Math.random() * 900 + 100))
|
||||
const random4 = () => String(Math.floor(Math.random() * 9000 + 1000))
|
||||
|
||||
const builders = [
|
||||
() => `${base}_${random2()}`,
|
||||
() => `${base}.${random2()}`,
|
||||
() => `${base}${random3()}`,
|
||||
() => `${base}-${random2()}`,
|
||||
() => `${base}_${random3()}`,
|
||||
() => `${base}${random4()}`,
|
||||
]
|
||||
|
||||
return builders[attempt % builders.length]()
|
||||
}
|
||||
|
||||
const trimToMax = (value) =>
|
||||
value.length > USERNAME_MAX_LENGTH ? value.slice(0, USERNAME_MAX_LENGTH) : value
|
||||
|
||||
const generateUsernameSuggestions = async (UserModel, baseUserName, excludeUserId) => {
|
||||
const base = trimToMax(baseUserName.trim().toLowerCase())
|
||||
const suggestions = []
|
||||
let attempt = 0
|
||||
|
||||
while (suggestions.length < 3 && attempt < 30) {
|
||||
const candidate = trimToMax(buildCandidates(base, attempt))
|
||||
attempt += 1
|
||||
|
||||
if (validateUsername(candidate)) continue
|
||||
if (suggestions.includes(candidate)) continue
|
||||
if (await isUsernameTaken(UserModel, candidate, excludeUserId)) continue
|
||||
|
||||
suggestions.push(candidate)
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateUsernameSuggestions,
|
||||
}
|
||||
33
utils/usernameValidation.js
Normal file
33
utils/usernameValidation.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
|
||||
const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,99}$/
|
||||
|
||||
const USERNAME_MIN_LENGTH = 6
|
||||
const USERNAME_MAX_LENGTH = 100
|
||||
|
||||
const validateUsername = (user_name) => {
|
||||
if (!user_name || typeof user_name !== 'string') {
|
||||
return 'نام کاربری الزامی است'
|
||||
}
|
||||
|
||||
const trimmed = user_name.trim()
|
||||
if (trimmed.length < USERNAME_MIN_LENGTH) {
|
||||
return `نام کاربری باید حداقل ${USERNAME_MIN_LENGTH} کاراکتر باشد`
|
||||
}
|
||||
|
||||
if (trimmed.length > USERNAME_MAX_LENGTH) {
|
||||
return `نام کاربری نمیتواند بیشتر از ${USERNAME_MAX_LENGTH} کاراکتر باشد`
|
||||
}
|
||||
|
||||
if (!USERNAME_REGEX.test(trimmed)) {
|
||||
return 'نام کاربری فقط میتواند شامل حروف انگلیسی، اعداد و کاراکترهای . _ - باشد'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
USERNAME_REGEX,
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_MAX_LENGTH,
|
||||
validateUsername
|
||||
}
|
||||
Reference in New Issue
Block a user