Initial commit
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user