const UserFollowModel = require('../models/UserFollowModel') const NEAR_ME_RADIUS_METERS = 5000 const PostBookmarkModel = require('../models/PostBookmarkModel') const PostModel = require('../models/PostModel') const UserModel = require('../models/UserModel') const LikeModel = require('../models/LikeModel') const ExploreInteractionModel = require('../models/ExploreInteractionModel') const { getBlockerUserIds } = require('./blockVisibility') const { filterPostsByDistance } = require('./nearbyPosts') const { postStatusFilter } = require('./moderationStatus') const { buildUnifiedInterestScore } = require('./exploreAlgorithm') async function getSuggestedFollowUsers(userId, limit = 15) { const viewer = await UserModel.findById(userId) .select('expertise blocked_users') .lean() const following = await UserFollowModel.find({ followerId: userId }) .select('followingId') .lean() const excludeIds = new Set( following.map((f) => String(f.followingId)).concat(String(userId)) ) const blockerIds = new Set( (await getBlockerUserIds(UserModel, userId)).map(String) ) const [interactions, likedPosts, interestWeights] = await Promise.all([ ExploreInteractionModel.find({ viewerId: userId }) .sort({ createdAt: -1 }) .limit(40) .select('targetId authorId action') .lean(), LikeModel.find({ userId }) .sort({ createdAt: -1 }) .limit(30) .select('postId') .lean(), // شخصی‌سازی متقابل: علایقی که کاربر توی فروشگاه/آموزش/بیلبورد نشون داده هم اینجا اثر بذاره buildUnifiedInterestScore(userId), ]) const interactedAuthorIds = new Set() interactions.forEach((row) => { if (row.authorId) interactedAuthorIds.add(String(row.authorId)) }) const likedPostIds = likedPosts.map((l) => l.postId).filter(Boolean) if (likedPostIds.length) { const likedPostsData = await PostModel.find({ _id: { $in: likedPostIds } }) .select('user_id') .lean() likedPostsData.forEach((p) => interactedAuthorIds.add(String(p.user_id))) } let preferredExpertises = new Set() if (viewer?.expertise) preferredExpertises.add(viewer.expertise) if (interactedAuthorIds.size) { const authors = await UserModel.find({ _id: { $in: [...interactedAuthorIds] }, }) .select('expertise') .lean() authors.forEach((a) => { if (a.expertise) preferredExpertises.add(a.expertise) }) } const candidates = await UserModel.find({ _id: { $nin: [...excludeIds] }, user_name: { $nin: [null, ''] }, }) .select( '_id user_name first_name last_name expertise profile_image user_level' ) .limit(120) .lean() const filtered = candidates.filter( (u) => !blockerIds.has(String(u._id)) && !excludeIds.has(String(u._id)) ) if (!filtered.length) return [] const candidateIds = filtered.map((u) => u._id) const recentPosts = await PostModel.find({ user_id: { $in: candidateIds }, status: postStatusFilter(), is_archived: { $ne: true }, }) .sort({ createdAt: -1 }) .lean() const postByUser = {} for (const p of recentPosts) { const uid = String(p.user_id) if (!postByUser[uid]) postByUser[uid] = p } const scored = filtered .filter((u) => postByUser[String(u._id)]) .map((u) => { let score = Math.random() * 2 if (preferredExpertises.has(u.expertise)) score += 8 if (interactedAuthorIds.has(String(u._id))) score += 12 if (u.expertise && interestWeights[u.expertise]) { score += Math.min(20, interestWeights[u.expertise]) } const post = postByUser[String(u._id)] score += (post.likes?.length || 0) * 0.15 return { user: u, score, previewPost: postByUser[String(u._id)] } }) .sort((a, b) => b.score - a.score) .slice(0, limit) return scored.map(({ user, previewPost }) => ({ _id: user._id, user_name: user.user_name, first_name: user.first_name, last_name: user.last_name, expertise: user.expertise, user_level: user.user_level, profile_image: user.profile_image, previewPost: { _id: previewPost._id, type: previewPost.type, // مدل واقعی مدیا رو با post_images/post_video برمی‌گردونه، نه همیشه files — // بدون این دو تا، فرانت‌اند (RN/وب) نمی‌تونست فایلِ پیش‌نمایش رو پیدا کنه و // کارت کاملاً خالی می‌موند post_images: previewPost.post_images, post_video: previewPost.post_video, files: previewPost.files, caption: previewPost.caption, }, is_following: false, })) } async function getFollowingReelsFeed({ userId, page, limit, postFilter, mapPostsWithUsers, }) { const follows = await UserFollowModel.find({ followerId: userId }) .select('followingId') .lean() const followingIds = follows.map((f) => f.followingId) if (!followingIds.length) { const suggestedUsers = await getSuggestedFollowUsers(userId) return { posts: [], totalPages: 0, totalItems: 0, feedMeta: { emptyFollowing: true, suggestedUsers }, } } const suggestedUsers = await getSuggestedFollowUsers(userId) let posts = await PostModel.find({ ...postFilter, status: postStatusFilter(), user_id: { $in: followingIds }, }) .sort({ createdAt: -1 }) .lean() 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 is_verified user_score rate blocked_users' ) .lean() posts = await mapPostsWithUsers(posts, users, { id: userId }) const totalItems = posts.length const pageNum = parseInt(page, 10) || 1 const limitNum = parseInt(limit, 10) || 10 const startIndex = (pageNum - 1) * limitNum const paginatedPosts = posts.slice(startIndex, startIndex + limitNum) return { posts: paginatedPosts, totalPages: Math.ceil(totalItems / limitNum) || 1, totalItems, feedMeta: { emptyFollowing: false, suggestedUsers }, } } async function getSavedReelsFeed({ userId, page, limit, postFilter, mapPostsWithUsers, }) { const pageNum = Math.max(1, parseInt(page, 10) || 1) const limitNum = Math.min(30, Math.max(1, parseInt(limit, 10) || 10)) const skip = (pageNum - 1) * limitNum const totalItems = await PostBookmarkModel.countDocuments({ userId }) const bookmarks = await PostBookmarkModel.find({ userId }) .sort({ createdAt: -1 }) .skip(skip) .limit(limitNum) .lean() const postIds = bookmarks.map((b) => b.postId) if (!postIds.length) { return { posts: [], totalPages: Math.ceil(totalItems / limitNum) || 0, totalItems, feedMeta: { emptySaved: true }, } } let posts = await PostModel.find({ _id: { $in: postIds }, status: postStatusFilter(), ...postFilter, }).lean() const orderMap = new Map(postIds.map((id, idx) => [String(id), idx])) posts.sort( (a, b) => (orderMap.get(String(a._id)) ?? 0) - (orderMap.get(String(b._id)) ?? 0) ) const userIds = [...new Set(posts.map((p) => 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 is_verified user_score rate blocked_users' ) .lean() posts = await mapPostsWithUsers(posts, users, { id: userId }) return { posts, totalPages: Math.ceil(totalItems / limitNum) || 1, totalItems, feedMeta: { emptySaved: totalItems === 0 }, } } async function getNearMeReelsFeed({ lat, lng, page, limit, postFilter, mapPostsWithUsers, decodedToken, }) { const parsedLat = parseFloat(lat) const parsedLng = parseFloat(lng) if (!Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) { return { posts: [], totalPages: 0, totalItems: 0, feedMeta: { needsLocation: true }, } } let posts = await PostModel.find({ ...postFilter, status: postStatusFilter(), lat: { $nin: [null, ''] }, lng: { $nin: [null, ''] }, }) .sort({ createdAt: -1 }) .lean() posts = filterPostsByDistance( posts, parsedLat, parsedLng, NEAR_ME_RADIUS_METERS ) if (!posts.length) { return { posts: [], totalPages: 0, totalItems: 0, feedMeta: { emptyNearMe: true }, } } const userIds = [...new Set(posts.map((p) => 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 is_verified user_score rate lat lng blocked_users' ) .lean() posts = await mapPostsWithUsers(posts, users, decodedToken) const totalItems = posts.length const pageNum = parseInt(page, 10) || 1 const limitNum = parseInt(limit, 10) || 10 const startIndex = (pageNum - 1) * limitNum const paginatedPosts = posts.slice(startIndex, startIndex + limitNum) return { posts: paginatedPosts, totalPages: Math.ceil(totalItems / limitNum) || 1, totalItems, feedMeta: { emptyNearMe: totalItems === 0 }, } } module.exports = { getSuggestedFollowUsers, getFollowingReelsFeed, getSavedReelsFeed, getNearMeReelsFeed, }