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

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