Initial commit

This commit is contained in:
payacom
2026-07-09 15:25:16 +03:30
parent 0b01b262fa
commit 51eb7d0224
21 changed files with 1544 additions and 98 deletions

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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: 'شما دسترسی به این بخش ندارید'
})

View File

@@ -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)

View File

@@ -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({

View 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
}

View File

@@ -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,
};

View File

@@ -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

View File

@@ -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',

View File

@@ -0,0 +1,54 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const exploreInteractionSchema = new mongoose.Schema({
viewerId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
targetType: {
type: String,
enum: ['post', 'profile', 'academy'],
required: true
},
targetId: {
type: mongoose.Schema.Types.ObjectId,
required: true
},
authorId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
default: null
},
contentType: {
type: String,
enum: ['image', 'video', 'academy'],
default: 'image'
},
action: {
type: String,
enum: [
'view',
'profile_visit',
'share',
'like',
'comment',
'offer',
'watch_complete',
'skip',
'dwell'
],
default: 'view'
},
dwellMs: {
type: Number,
default: null
}
})
exploreInteractionSchema.index({ viewerId: 1, createdAt: -1 })
exploreInteractionSchema.plugin(timestamp)
module.exports = mongoose.model('ExploreInteraction', exploreInteractionSchema)

41
models/StoryModel.js Normal file
View File

@@ -0,0 +1,41 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const storySchema = new mongoose.Schema({
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
media_path: {
type: String,
required: true
},
media_type: {
type: String,
enum: ['image', 'video'],
required: true
},
expires_at: {
type: Date,
required: true,
index: true
},
viewers: [{
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
viewed_at: {
type: Date,
default: Date.now
}
}]
})
storySchema.index({ expires_at: 1 }, { expireAfterSeconds: 0 })
storySchema.plugin(timestamp)
module.exports = mongoose.model('Story', storySchema)

View File

@@ -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);

View File

@@ -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);

View File

@@ -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)

View File

@@ -0,0 +1,19 @@
const express = require('express')
const auth = require('../../../middlewares/auth')
const blockCheck = require('../../../middlewares/blockCheck')
const isHalfRegister = require('../../../middlewares/isHalfRegister')
const {
createStoryBase64,
getStoriesFeed,
markStoryViewed,
deleteStory
} = require('../../../controllers/application/stories/storyController')
const router = express.Router()
router.get('/feed', getStoriesFeed)
router.post('/create-base64', auth, blockCheck, isHalfRegister, createStoryBase64)
router.post('/view', auth, blockCheck, markStoryViewed)
router.delete('/:storyId', auth, blockCheck, deleteStory)
module.exports = router

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 }
}

543
utils/exploreAlgorithm.js Normal file
View File

@@ -0,0 +1,543 @@
const LikeModel = require('../models/LikeModel')
const CommentModel = require('../models/CommentModel')
const OfferModel = require('../models/OfferModel')
const UserModel = require('../models/UserModel')
const PostModel = require('../models/PostModel')
const ExploreInteractionModel = require('../models/ExploreInteractionModel')
const EXPERTISE_ALIASES = {
مدل: ['مدل', 'مدلینگ'],
مدلینگ: ['مدل', 'مدلینگ'],
عکاس: ['عکاس', 'عکاسی'],
عکاسی: ['عکاس', 'عکاسی'],
آرایشگر: ['آرایشگر', 'زیبایی'],
زیبایی: ['آرایشگر', 'زیبایی']
}
const ACTION_WEIGHTS = {
offer: 10,
profile_visit: 6,
share: 5,
comment: 4,
like: 3,
watch_complete: 3.5,
view: 2,
skip: -1.5,
dwell: 1.5
}
function expertiseMatches(userExpertise, authorExpertise) {
if (!userExpertise || !authorExpertise) return false
const aliases = EXPERTISE_ALIASES[userExpertise] || [userExpertise]
return aliases.includes(authorExpertise)
}
function interactionDecay(createdAt) {
const ageDays =
(Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24)
return Math.exp(-ageDays / 21)
}
function engagementVelocity(likesCount, commentsCount, ageHours) {
const raw = likesCount * 0.55 + commentsCount * 1.1
const ageFactor = Math.max(1, ageHours / 24)
return raw / Math.sqrt(ageFactor)
}
function shuffleArray(items, seed = Date.now()) {
const copy = [...items]
let state = seed % 2147483647 || 1
const random = () => {
state = (state * 16807) % 2147483647
return (state - 1) / 2147483646
}
for (let i = copy.length - 1; i > 0; i -= 1) {
const j = Math.floor(random() * (i + 1))
;[copy[i], copy[j]] = [copy[j], copy[i]]
}
return copy
}
function interleaveWeighted(primary, secondary, primaryRatio = 0.7) {
if (!primary.length) return shuffleArray(secondary)
if (!secondary.length) return shuffleArray(primary)
const result = []
let primaryIdx = 0
let secondaryIdx = 0
const primaryBurst = Math.max(2, Math.round(primaryRatio * 10))
const secondaryBurst = Math.max(1, 10 - primaryBurst)
let primaryCount = 0
let secondaryCount = 0
while (primaryIdx < primary.length || secondaryIdx < secondary.length) {
const shouldTakeSecondary =
secondaryIdx < secondary.length &&
(primaryIdx >= primary.length ||
(secondaryCount < secondaryBurst &&
primaryCount >= primaryBurst))
if (shouldTakeSecondary) {
result.push(secondary[secondaryIdx])
secondaryIdx += 1
secondaryCount += 1
primaryCount = 0
continue
}
if (primaryIdx < primary.length) {
result.push(primary[primaryIdx])
primaryIdx += 1
primaryCount += 1
secondaryCount = 0
continue
}
if (secondaryIdx < secondary.length) {
result.push(secondary[secondaryIdx])
secondaryIdx += 1
}
}
return result
}
function matchesSeedCategory(author, seedAuthor) {
if (!seedAuthor || !author) return false
if (
seedAuthor.expertise &&
expertiseMatches(seedAuthor.expertise, author.expertise)
) {
return true
}
if (
seedAuthor.sub_expertise?.length &&
author.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
) {
return true
}
return false
}
function hasEnoughUserSignals(signals) {
const meaningful =
(signals.likedPostIds?.length || 0) +
(signals.commentedPostIds?.length || 0) +
Object.keys(signals.offerAuthors || {}).length
const affinityStrength = Object.values(signals.authorAffinity || {}).reduce(
(sum, value) => sum + value,
0
)
return meaningful >= 8 || affinityStrength >= 22
}
function scoreColdStartPost(post, author, seedPost, seedAuthor) {
let score = Math.random() * 80
if (matchesSeedCategory(author, seedAuthor)) {
score += 50
}
if (
seedAuthor?.sub_expertise?.length &&
author?.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
) {
score += 22
}
if (seedPost?.type && post.type === seedPost.type) {
score += 14
}
const likesCount = post.likes ? post.likes.length : post.likesCount || 0
const commentsCount = post.commentsCount || 0
const ageHours =
(Date.now() - new Date(post.createdAt).getTime()) / (1000 * 60 * 60)
score += engagementVelocity(likesCount, commentsCount, ageHours) * 0.25
return score
}
function rankColdStartReels(pool, usersById, seedAuthor, seedPost) {
const sameCategory = []
const others = []
pool.forEach((post) => {
const author = usersById[String(post.user_id)] || {}
if (matchesSeedCategory(author, seedAuthor)) {
sameCategory.push(post)
} else {
others.push(post)
}
})
const seedKey = seedPost?._id ? String(seedPost._id) : '0'
const shuffledSame = shuffleArray(sameCategory, seedKey.length * 997)
const shuffledOthers = shuffleArray(others, seedKey.length * 499)
return interleaveWeighted(shuffledSame, shuffledOthers, 0.72)
}
function diversifyByAuthor(posts, maxPerPage = 2) {
const authorCounts = {}
const picked = []
const deferred = []
for (const post of posts) {
const authorId = String(post.user_id)
const count = authorCounts[authorId] || 0
if (count < maxPerPage) {
picked.push(post)
authorCounts[authorId] = count + 1
} else {
deferred.push(post)
}
}
return [...picked, ...deferred]
}
function blendFreshIntoRanked(rankedPosts, allPosts, ratio = 0.12) {
if (!rankedPosts.length) return rankedPosts
const rankedIds = new Set(rankedPosts.map((p) => String(p._id)))
const fresh = [...allPosts]
.filter((p) => !rankedIds.has(String(p._id)))
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
if (!fresh.length) return rankedPosts
const result = [...rankedPosts]
const step = Math.max(4, Math.round(1 / ratio))
let freshIdx = 0
for (let i = step - 1; i < result.length && freshIdx < fresh.length; i += step) {
result.splice(i, 0, fresh[freshIdx])
freshIdx += 1
}
while (freshIdx < fresh.length && result.length < rankedPosts.length + fresh.length) {
result.push(fresh[freshIdx])
freshIdx += 1
}
return result
}
async function buildUserExploreSignals(userId) {
if (!userId) {
return {
userExpertise: null,
userSubExpertise: [],
contentTypeWeight: { image: 0, video: 0, academy: 0 },
authorAffinity: {},
offerAuthors: {},
viewedPosts: {},
viewedAuthors: {},
likedPostIds: [],
commentedPostIds: [],
isColdStart: true
}
}
const user = await UserModel.findById(userId)
.select('expertise sub_expertise')
.lean()
const [likes, comments, offers, interactions] = await Promise.all([
LikeModel.find({ userId }).select('postId createdAt').lean(),
CommentModel.find({
creator: userId,
comment_for: 'post',
post: { $ne: null }
})
.select('post user createdAt')
.lean(),
OfferModel.find({ sender: userId }).select('receiver createdAt').lean(),
ExploreInteractionModel.find({ viewerId: userId })
.sort({ createdAt: -1 })
.limit(1200)
.lean()
])
const likedPostIds = likes.map((l) => String(l.postId))
const likedPosts = likedPostIds.length
? await PostModel.find({ _id: { $in: likedPostIds } })
.select('user_id type')
.lean()
: []
const contentTypeWeight = { image: 0, video: 0, academy: 0 }
const authorAffinity = {}
const offerAuthors = {}
const viewedPosts = {}
const viewedAuthors = {}
const bumpAuthor = (authorId, weight) => {
if (!authorId) return
const key = String(authorId)
authorAffinity[key] = (authorAffinity[key] || 0) + weight
}
const bumpContent = (type, weight) => {
if (!type) return
contentTypeWeight[type] = (contentTypeWeight[type] || 0) + weight
}
const bumpViewedPost = (postId, weight) => {
if (!postId) return
const key = String(postId)
viewedPosts[key] = Math.max(viewedPosts[key] || 0, weight)
}
const bumpViewedAuthor = (authorId, weight) => {
if (!authorId) return
const key = String(authorId)
viewedAuthors[key] = Math.max(viewedAuthors[key] || 0, weight)
}
offers.forEach((offer) => {
const decay = interactionDecay(offer.createdAt)
const key = String(offer.receiver)
offerAuthors[key] = (offerAuthors[key] || 0) + 12 * decay
bumpAuthor(offer.receiver, 8 * decay)
})
likedPosts.forEach((post) => {
bumpAuthor(post.user_id, 4)
bumpContent(post.type, 2)
})
comments.forEach((comment) => {
const decay = interactionDecay(comment.createdAt)
bumpAuthor(comment.user, 5 * decay)
})
interactions.forEach((item) => {
const decay = interactionDecay(item.createdAt)
const base =
ACTION_WEIGHTS[item.action] ?? ACTION_WEIGHTS.view
const weight = base * decay
if (item.contentType) bumpContent(item.contentType, weight * 0.5)
if (item.authorId) bumpAuthor(item.authorId, weight)
if (
item.targetType === 'post' &&
['view', 'watch_complete', 'skip', 'dwell'].includes(item.action)
) {
const viewWeight =
item.action === 'watch_complete'
? 1
: item.action === 'skip'
? 0.85
: item.action === 'dwell'
? 0.55
: 0.35
bumpViewedPost(item.targetId, viewWeight * decay)
if (item.authorId) bumpViewedAuthor(item.authorId, viewWeight * decay * 0.6)
}
})
return {
userExpertise: user?.expertise || null,
userSubExpertise: user?.sub_expertise || [],
contentTypeWeight,
authorAffinity,
offerAuthors,
viewedPosts,
viewedAuthors,
likedPostIds,
commentedPostIds: comments.map((c) => String(c.post)),
isColdStart: !hasEnoughUserSignals({
likedPostIds,
commentedPostIds: comments.map((c) => String(c.post)),
offerAuthors,
authorAffinity
})
}
}
function scorePost(post, author, signals, options = {}) {
const { reelsMode = false, seedPost = null, seedAuthor = null } = options
let score = Math.random() * 4
const postId = String(post._id)
const authorId = String(post.user_id)
const likesCount = post.likes ? post.likes.length : post.likesCount || 0
const commentsCount = post.commentsCount || 0
const ageHours =
(Date.now() - new Date(post.createdAt).getTime()) / (1000 * 60 * 60)
score += engagementVelocity(likesCount, commentsCount, ageHours)
if (signals.userExpertise && expertiseMatches(signals.userExpertise, author?.expertise)) {
score += 24
}
if (
signals.userSubExpertise?.length &&
author?.sub_expertise?.some((s) => signals.userSubExpertise.includes(s))
) {
score += 16
}
if (post.type && signals.contentTypeWeight[post.type]) {
score += signals.contentTypeWeight[post.type] * 2
}
score += signals.authorAffinity[authorId] || 0
score += signals.offerAuthors[authorId] || 0
if (signals.likedPostIds?.includes(postId)) score -= 35
if (signals.commentedPostIds?.includes(postId)) score -= 28
const viewedWeight = signals.viewedPosts?.[postId] || 0
if (viewedWeight > 0) score -= 8 + viewedWeight * 22
const authorFatigue = signals.viewedAuthors?.[authorId] || 0
if (authorFatigue > 0) score -= authorFatigue * 6
if (ageHours < 24) score += 6
else if (ageHours < 48) score += 4
else if (ageHours < 168) score += 2
if (reelsMode && post.type === 'video') score += 14
if (seedPost) {
if (String(seedPost.user_id) === authorId) score += 20
if (
seedAuthor?.expertise &&
expertiseMatches(seedAuthor.expertise, author?.expertise)
) {
score += 18
}
if (
seedAuthor?.sub_expertise?.length &&
author?.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
) {
score += 8
}
if (seedPost.type && post.type === seedPost.type) score += 6
}
return score
}
async function rankPostsForFeed({
posts,
usersById,
userId,
seedPostId,
reelsMode = false,
blendFresh = false
}) {
const signals = await buildUserExploreSignals(userId)
const coldStart = signals.isColdStart
let seedPost = null
let seedAuthor = null
if (seedPostId) {
seedPost = posts.find((p) => String(p._id) === String(seedPostId)) || null
if (!seedPost) {
seedPost = await PostModel.findById(seedPostId).lean()
}
if (seedPost) {
seedAuthor =
usersById[String(seedPost.user_id)] ||
(await UserModel.findById(seedPost.user_id)
.select(
'_id expertise sub_expertise user_name first_name last_name profile_image'
)
.lean())
if (seedAuthor && !usersById[String(seedPost.user_id)]) {
usersById[String(seedPost.user_id)] = seedAuthor
}
}
}
const pool = seedPost
? posts.filter((p) => String(p._id) !== String(seedPostId))
: posts
let ranked = []
if (coldStart && reelsMode && seedAuthor) {
ranked = rankColdStartReels(pool, usersById, seedAuthor, seedPost)
} else if (coldStart && reelsMode && seedPost) {
ranked = shuffleArray(pool, String(seedPostId).length * 131)
} else if (coldStart) {
ranked = shuffleArray(pool)
if (blendFresh) {
ranked = blendFreshIntoRanked(ranked, pool, 0.2)
}
} else {
const scored = pool
.map((post) => ({
post,
score: scorePost(post, usersById[String(post.user_id)] || {}, signals, {
reelsMode,
seedPost,
seedAuthor
})
}))
.sort((a, b) => b.score - a.score)
.map((item) => item.post)
ranked = diversifyByAuthor(scored, reelsMode ? 1 : 2)
if (blendFresh) {
ranked = blendFreshIntoRanked(ranked, pool)
}
}
if (seedPost) {
ranked = [seedPost, ...ranked]
}
return ranked
}
async function paginatePersonalizedExplore({
posts,
usersById,
page,
limit,
userId,
seedPostId,
reelsMode = false,
blendFresh = false
}) {
const pageNum = Math.max(1, parseInt(page, 10) || 1)
const limitNum = Math.max(1, parseInt(limit, 10) || 10)
const ranked = await rankPostsForFeed({
posts,
usersById,
userId,
seedPostId,
reelsMode,
blendFresh: blendFresh && !seedPostId && !reelsMode
})
const totalItems = ranked.length
const startIndex = (pageNum - 1) * limitNum
return {
posts: ranked.slice(startIndex, startIndex + limitNum),
totalItems
}
}
module.exports = {
buildUserExploreSignals,
paginatePersonalizedExplore,
rankPostsForFeed,
scorePost
}

20
utils/projectProfile.js Normal file
View File

@@ -0,0 +1,20 @@
function canCreateProject(user) {
return Boolean(
user?.user_name?.trim() &&
user?.first_name?.trim() &&
user?.last_name?.trim()
);
}
function respondProjectProfileIncomplete(res) {
return res.status(422).json({
error: true,
message:
"برای ثبت پروژه، تکمیل نام کاربری، نام و نام خانوادگی در تنظیمات الزامی است",
});
}
module.exports = {
canCreateProject,
respondProjectProfileIncomplete,
};