Initial commit
This commit is contained in:
@@ -1395,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' است
|
||||
});
|
||||
|
||||
@@ -4184,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)
|
||||
@@ -4234,6 +4328,9 @@ module.exports = {
|
||||
|
||||
// دریافت لیست همه دورهها با فیلتر و جستجو
|
||||
getCourses,
|
||||
|
||||
// محتوای رایگان آموزشگاه برای اکسپلور
|
||||
getFreeAcademyExploreContent,
|
||||
|
||||
// دریافت دورههای یک آکادمی خاص
|
||||
getAcademyCourse,
|
||||
|
||||
@@ -184,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
|
||||
}
|
||||
|
||||
@@ -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: 'شما دسترسی به این بخش ندارید'
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ const {
|
||||
resolveRatingForComment,
|
||||
recalculateUserRating
|
||||
} = require('../../../utils/commentRating')
|
||||
const {
|
||||
canCreateProject,
|
||||
respondProjectProfileIncomplete
|
||||
} = require('../../../utils/projectProfile')
|
||||
|
||||
const saveProjectComment = async ({
|
||||
targetUserId,
|
||||
@@ -19,12 +23,16 @@ const saveProjectComment = async ({
|
||||
rate,
|
||||
commentFor
|
||||
}) => {
|
||||
const { ratingValue, isNewRating, requiresRating } = await resolveRatingForComment({
|
||||
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' } }
|
||||
}
|
||||
@@ -256,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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
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
|
||||
}
|
||||
@@ -25,7 +25,188 @@ const {
|
||||
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 {
|
||||
@@ -776,21 +957,118 @@ 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 show_location blocked_users')
|
||||
.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]));
|
||||
|
||||
@@ -803,35 +1081,31 @@ const getPostsWeb = async (req, res, next) => {
|
||||
);
|
||||
}
|
||||
|
||||
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.show_location ? user.province || {} : {},
|
||||
city: user.show_location ? user.city || {} : {},
|
||||
show_location: user.show_location,
|
||||
user_level: user.user_level || '',
|
||||
likesCount: post.likes ? post.likes.length : 0,
|
||||
is_liked: false,
|
||||
commentsCount: 0
|
||||
};
|
||||
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;
|
||||
@@ -854,7 +1128,7 @@ const getPostsWeb = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const users = await UserModel.find(userFilter)
|
||||
.select("_id user_name first_name last_name expertise province city user_level show_location")
|
||||
.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) {
|
||||
@@ -906,10 +1180,12 @@ const getPostsWeb = async (req, res, next) => {
|
||||
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,
|
||||
@@ -924,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;
|
||||
@@ -1333,13 +1645,19 @@ const createUserComment = async (req, res, next) => {
|
||||
return res.status(400).send({ message: "All fields are required" });
|
||||
}
|
||||
|
||||
const { ratingValue, isNewRating, requiresRating } =
|
||||
const { ratingValue, isNewRating, requiresRating, alreadyRated } =
|
||||
await resolveRatingForComment({
|
||||
creatorId,
|
||||
targetUserId: user_id,
|
||||
rate,
|
||||
});
|
||||
|
||||
if (alreadyRated) {
|
||||
return res.status(400).json({
|
||||
message: "شما قبلاً به این کاربر امتیاز دادهاید.",
|
||||
});
|
||||
}
|
||||
|
||||
if (requiresRating) {
|
||||
return res
|
||||
.status(400)
|
||||
@@ -1425,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,
|
||||
@@ -1440,4 +1805,5 @@ module.exports = {
|
||||
getLicenseByUserId,
|
||||
createLicense,
|
||||
updateLicenseConfirmation,
|
||||
recordExploreInteraction,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user