From 51eb7d02240ee333dd476f49d3fd81274569ee25 Mon Sep 17 00:00:00 2001 From: payacom <59262811+payacom@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:25:16 +0330 Subject: [PATCH] Initial commit --- .../application/academy/academyControllers.js | 99 +++- .../application/posts/postController.js | 82 ++- .../projects/createProjectController.js | 11 +- .../projects/requestProjectController.js | 20 +- .../projects/updateProjectController.js | 17 +- .../register/userTypeController.js | 7 +- .../application/stories/storyController.js | 197 +++++++ .../application/users/getUserController.js | 418 +++++++++++++- .../workroom/workroomController.js | 88 ++- index.js | 1 + models/ExploreInteractionModel.js | 54 ++ models/StoryModel.js | 41 ++ routes/application/academy/index.js | 6 + routes/application/posts/index.js | 3 +- routes/application/projects/index.js | 4 +- routes/application/stories/index.js | 19 + routes/application/users/index.js | 3 +- routes/index.js | 2 + utils/commentRating.js | 7 + utils/exploreAlgorithm.js | 543 ++++++++++++++++++ utils/projectProfile.js | 20 + 21 files changed, 1544 insertions(+), 98 deletions(-) create mode 100644 controllers/application/stories/storyController.js create mode 100644 models/ExploreInteractionModel.js create mode 100644 models/StoryModel.js create mode 100644 routes/application/stories/index.js create mode 100644 utils/exploreAlgorithm.js create mode 100644 utils/projectProfile.js diff --git a/controllers/application/academy/academyControllers.js b/controllers/application/academy/academyControllers.js index 658adb7..ca84b88 100644 --- a/controllers/application/academy/academyControllers.js +++ b/controllers/application/academy/academyControllers.js @@ -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, diff --git a/controllers/application/posts/postController.js b/controllers/application/posts/postController.js index 8e3d9bd..c0ac0c4 100644 --- a/controllers/application/posts/postController.js +++ b/controllers/application/posts/postController.js @@ -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 } diff --git a/controllers/application/projects/createProjectController.js b/controllers/application/projects/createProjectController.js index e85964b..8ff3ce1 100644 --- a/controllers/application/projects/createProjectController.js +++ b/controllers/application/projects/createProjectController.js @@ -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) diff --git a/controllers/application/projects/requestProjectController.js b/controllers/application/projects/requestProjectController.js index 6f8f936..eb1bd5a 100644 --- a/controllers/application/projects/requestProjectController.js +++ b/controllers/application/projects/requestProjectController.js @@ -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: 'شما دسترسی به این بخش ندارید' }) diff --git a/controllers/application/projects/updateProjectController.js b/controllers/application/projects/updateProjectController.js index 41aaeed..b1c1824 100644 --- a/controllers/application/projects/updateProjectController.js +++ b/controllers/application/projects/updateProjectController.js @@ -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) diff --git a/controllers/application/register/userTypeController.js b/controllers/application/register/userTypeController.js index 943a5cb..4c9be51 100644 --- a/controllers/application/register/userTypeController.js +++ b/controllers/application/register/userTypeController.js @@ -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({ diff --git a/controllers/application/stories/storyController.js b/controllers/application/stories/storyController.js new file mode 100644 index 0000000..2185a75 --- /dev/null +++ b/controllers/application/stories/storyController.js @@ -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 +} diff --git a/controllers/application/users/getUserController.js b/controllers/application/users/getUserController.js index f461737..5eaca7c 100644 --- a/controllers/application/users/getUserController.js +++ b/controllers/application/users/getUserController.js @@ -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, }; diff --git a/controllers/application/workroom/workroomController.js b/controllers/application/workroom/workroomController.js index 3f70223..df4926d 100644 --- a/controllers/application/workroom/workroomController.js +++ b/controllers/application/workroom/workroomController.js @@ -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 diff --git a/index.js b/index.js index 587dbde..bf9b23a 100644 --- a/index.js +++ b/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', diff --git a/models/ExploreInteractionModel.js b/models/ExploreInteractionModel.js new file mode 100644 index 0000000..09ebb88 --- /dev/null +++ b/models/ExploreInteractionModel.js @@ -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) diff --git a/models/StoryModel.js b/models/StoryModel.js new file mode 100644 index 0000000..e7b1294 --- /dev/null +++ b/models/StoryModel.js @@ -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) diff --git a/routes/application/academy/index.js b/routes/application/academy/index.js index 47c8d14..78a52d4 100644 --- a/routes/application/academy/index.js +++ b/routes/application/academy/index.js @@ -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); diff --git a/routes/application/posts/index.js b/routes/application/posts/index.js index 0801b0e..52f4e51 100644 --- a/routes/application/posts/index.js +++ b/routes/application/posts/index.js @@ -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); diff --git a/routes/application/projects/index.js b/routes/application/projects/index.js index 79dceab..6b2f418 100644 --- a/routes/application/projects/index.js +++ b/routes/application/projects/index.js @@ -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) diff --git a/routes/application/stories/index.js b/routes/application/stories/index.js new file mode 100644 index 0000000..701a745 --- /dev/null +++ b/routes/application/stories/index.js @@ -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 diff --git a/routes/application/users/index.js b/routes/application/users/index.js index bacf3bd..a4c75f2 100644 --- a/routes/application/users/index.js +++ b/routes/application/users/index.js @@ -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) diff --git a/routes/index.js b/routes/index.js index 16e8984..825f95b 100644 --- a/routes/index.js +++ b/routes/index.js @@ -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) diff --git a/utils/commentRating.js b/utils/commentRating.js index 2c917ea..4755545 100644 --- a/utils/commentRating.js +++ b/utils/commentRating.js @@ -20,6 +20,13 @@ 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 } } diff --git a/utils/exploreAlgorithm.js b/utils/exploreAlgorithm.js new file mode 100644 index 0000000..c2548be --- /dev/null +++ b/utils/exploreAlgorithm.js @@ -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 +} diff --git a/utils/projectProfile.js b/utils/projectProfile.js new file mode 100644 index 0000000..6c2ae8f --- /dev/null +++ b/utils/projectProfile.js @@ -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, +};