full
This commit is contained in:
488
controllers/application/posts/engagementController.js
Normal file
488
controllers/application/posts/engagementController.js
Normal file
@@ -0,0 +1,488 @@
|
||||
/* eslint-disable camelcase */
|
||||
const jwt = require('jsonwebtoken')
|
||||
const mongoose = require('mongoose')
|
||||
const PostModel = require('../../../models/PostModel')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const PostBookmarkModel = require('../../../models/PostBookmarkModel')
|
||||
const PostSendModel = require('../../../models/PostSendModel')
|
||||
const PostShareModel = require('../../../models/PostShareModel')
|
||||
const UserFollowModel = require('../../../models/UserFollowModel')
|
||||
const MessageModel = require('../../../models/MessageModel')
|
||||
const LikeModel = require('../../../models/LikeModel')
|
||||
const CommentModel = require('../../../models/CommentModel')
|
||||
const { postStatusFilter, commentStatusFilter } = require('../../../utils/moderationStatus')
|
||||
const { isPostPubliclyVisible } = require('../../../utils/postModeration')
|
||||
const {
|
||||
attachPostEngagementFields,
|
||||
} = require('../../../utils/postEngagement')
|
||||
const {
|
||||
enrichMessage,
|
||||
emitChatEvents,
|
||||
} = require('../../../helpers/chatHelpers')
|
||||
const { viewerIsBlockedBy } = require('../../../utils/blockVisibility')
|
||||
const { isHiddenFromPublic } = require('../../../utils/accountLifecycle')
|
||||
|
||||
function getUserIdFromRequest(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
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function mapBookmarkPosts(posts, viewerId) {
|
||||
// باید همیشه آرایه برگردونه (نه یک آبجکت pagination-shape) — وگرنه getBookmarks که
|
||||
// بدون چک posts.length صداش میزنه، این آبجکت رو مستقیم توی فیلد posts پاسخ میذاره و
|
||||
// .map() سمت اپ روی یک object نه array کرش میکنه (دقیقاً باگ کرش تب ذخیرهشدهها)
|
||||
if (!posts.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
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 verify_badge'
|
||||
)
|
||||
.lean()
|
||||
|
||||
const decodedToken = viewerId ? { id: viewerId } : null
|
||||
let mapped = posts.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 || '',
|
||||
verify_badge: user.verify_badge || 'none',
|
||||
likesCount: post.likes ? post.likes.length : 0,
|
||||
is_liked: false,
|
||||
commentsCount: 0,
|
||||
}
|
||||
})
|
||||
|
||||
if (viewerId) {
|
||||
const likes = await LikeModel.find({
|
||||
userId: viewerId,
|
||||
postId: { $in: posts.map((p) => p._id) },
|
||||
}).lean()
|
||||
const likesMap = {}
|
||||
likes.forEach((l) => {
|
||||
likesMap[l.postId.toString()] = true
|
||||
})
|
||||
|
||||
const comments = await CommentModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
post: { $in: posts.map((p) => p._id) },
|
||||
status: commentStatusFilter(),
|
||||
},
|
||||
},
|
||||
{ $group: { _id: '$post', count: { $sum: 1 } } },
|
||||
])
|
||||
const commentsMap = {}
|
||||
comments.forEach((c) => {
|
||||
commentsMap[c._id.toString()] = c.count
|
||||
})
|
||||
|
||||
mapped = mapped.map((post) => ({
|
||||
...post,
|
||||
is_liked: !!likesMap[post._id.toString()],
|
||||
commentsCount: commentsMap[post._id.toString()] || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
mapped = await attachPostEngagementFields(mapped, viewerId)
|
||||
|
||||
return mapped
|
||||
}
|
||||
|
||||
const getMyLikedPosts = async (req, res, next) => {
|
||||
try {
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'توکن ارائه نشده است' })
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
|
||||
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 15))
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const likes = await LikeModel.find({ userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean()
|
||||
|
||||
const totalItems = await LikeModel.countDocuments({ userId })
|
||||
const postIds = likes.map((l) => l.postId)
|
||||
|
||||
const posts = postIds.length
|
||||
? await PostModel.find({
|
||||
_id: { $in: postIds },
|
||||
status: postStatusFilter(),
|
||||
is_archived: { $ne: true },
|
||||
}).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 enriched = posts.length ? await mapBookmarkPosts(posts, userId) : []
|
||||
|
||||
return res.status(200).json({
|
||||
posts: enriched,
|
||||
postsCount: enriched.length,
|
||||
totalPages: Math.ceil(totalItems / limit) || 0,
|
||||
totalItems,
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getMyCommentedPosts = async (req, res, next) => {
|
||||
try {
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'توکن ارائه نشده است' })
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
|
||||
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 15))
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const matchStage = {
|
||||
creator: new mongoose.Types.ObjectId(userId),
|
||||
comment_for: 'post',
|
||||
post: { $ne: null },
|
||||
status: commentStatusFilter(),
|
||||
}
|
||||
|
||||
const commentAgg = await CommentModel.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $group: { _id: '$post', lastCommentedAt: { $first: '$createdAt' } } },
|
||||
{ $sort: { lastCommentedAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
])
|
||||
|
||||
const totalItemsAgg = await CommentModel.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $group: { _id: '$post' } },
|
||||
{ $count: 'total' },
|
||||
])
|
||||
const totalItems = totalItemsAgg[0]?.total || 0
|
||||
const postIds = commentAgg.map((c) => c._id)
|
||||
|
||||
const posts = postIds.length
|
||||
? await PostModel.find({
|
||||
_id: { $in: postIds },
|
||||
status: postStatusFilter(),
|
||||
is_archived: { $ne: true },
|
||||
}).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 enriched = posts.length ? await mapBookmarkPosts(posts, userId) : []
|
||||
|
||||
return res.status(200).json({
|
||||
posts: enriched,
|
||||
postsCount: enriched.length,
|
||||
totalPages: Math.ceil(totalItems / limit) || 0,
|
||||
totalItems,
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBookmark = async (req, res, next) => {
|
||||
try {
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'توکن ارائه نشده است' })
|
||||
}
|
||||
|
||||
const { postId } = req.body
|
||||
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
|
||||
return res.status(400).json({ message: 'شناسه پست الزامی است' })
|
||||
}
|
||||
|
||||
const post = await PostModel.findById(postId)
|
||||
if (!post || !isPostPubliclyVisible(post)) {
|
||||
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
|
||||
}
|
||||
|
||||
const author = await UserModel.findById(post.user_id)
|
||||
.select('allow_save_posts account_status')
|
||||
.lean()
|
||||
if (author && isHiddenFromPublic(author)) {
|
||||
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
|
||||
}
|
||||
if (author && author.allow_save_posts === false) {
|
||||
return res.status(403).json({ message: 'صاحب پست اجازه ذخیره نداده است' })
|
||||
}
|
||||
|
||||
const existing = await PostBookmarkModel.findOne({ postId, userId })
|
||||
if (existing) {
|
||||
await existing.deleteOne()
|
||||
const savesCount = await PostBookmarkModel.countDocuments({ postId })
|
||||
return res.status(200).json({
|
||||
message: 'پست از علاقمندیها حذف شد',
|
||||
is_saved: false,
|
||||
savesCount,
|
||||
})
|
||||
}
|
||||
|
||||
await PostBookmarkModel.create({ postId, userId })
|
||||
const savesCount = await PostBookmarkModel.countDocuments({ postId })
|
||||
return res.status(200).json({
|
||||
message: 'پست ذخیره شد',
|
||||
is_saved: true,
|
||||
savesCount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('toggleBookmark:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getBookmarks = async (req, res, next) => {
|
||||
try {
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'توکن ارائه نشده است' })
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
|
||||
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 15))
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const bookmarks = await PostBookmarkModel.find({ userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean()
|
||||
|
||||
const totalItems = await PostBookmarkModel.countDocuments({ userId })
|
||||
const postIds = bookmarks.map((b) => b.postId)
|
||||
|
||||
const posts = postIds.length
|
||||
? await PostModel.find({ _id: { $in: postIds }, status: postStatusFilter(), is_archived: { $ne: true } }).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 enriched = await mapBookmarkPosts(posts, userId)
|
||||
|
||||
return res.status(200).json({
|
||||
posts: enriched,
|
||||
postsCount: enriched.length,
|
||||
totalPages: Math.ceil(totalItems / limit) || 0,
|
||||
totalItems,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('getBookmarks:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const sendPostToUsers = async (req, res, next) => {
|
||||
try {
|
||||
const senderId = getUserIdFromRequest(req)
|
||||
if (!senderId) {
|
||||
return res.status(401).json({ message: 'توکن ارائه نشده است' })
|
||||
}
|
||||
|
||||
const { postId, receiverIds } = req.body
|
||||
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
|
||||
return res.status(400).json({ message: 'شناسه پست الزامی است' })
|
||||
}
|
||||
|
||||
if (!Array.isArray(receiverIds) || !receiverIds.length) {
|
||||
return res.status(400).json({ message: 'حداقل یک گیرنده انتخاب کنید' })
|
||||
}
|
||||
|
||||
const uniqueReceivers = [
|
||||
...new Set(
|
||||
receiverIds
|
||||
.map(String)
|
||||
.filter((id) => mongoose.Types.ObjectId.isValid(id) && id !== String(senderId))
|
||||
),
|
||||
].slice(0, 50)
|
||||
|
||||
if (!uniqueReceivers.length) {
|
||||
return res.status(400).json({ message: 'گیرنده معتبر یافت نشد' })
|
||||
}
|
||||
|
||||
const post = await PostModel.findById(postId).lean()
|
||||
if (!post || !isPostPubliclyVisible(post)) {
|
||||
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
|
||||
}
|
||||
|
||||
const author = await UserModel.findById(post.user_id)
|
||||
.select('user_name first_name last_name blocked_users')
|
||||
.lean()
|
||||
|
||||
if (!author) {
|
||||
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' })
|
||||
}
|
||||
|
||||
const preview = post.files?.[0]?.path || ''
|
||||
const sharedPost = {
|
||||
postId: String(post._id),
|
||||
caption: (post.caption || '').slice(0, 200),
|
||||
type: post.type || 'image',
|
||||
preview,
|
||||
authorId: String(post.user_id),
|
||||
authorUserName: author.user_name || '',
|
||||
authorDisplayName:
|
||||
`${author.first_name || ''} ${author.last_name || ''}`.trim() ||
|
||||
author.user_name ||
|
||||
'کاربر',
|
||||
url: `https://modstagram.com/posts/${post._id}`,
|
||||
}
|
||||
|
||||
const content = JSON.stringify({ sharedPost })
|
||||
const io = req.app.get('io')
|
||||
|
||||
let sentCount = 0
|
||||
for (const receiverId of uniqueReceivers) {
|
||||
const receiver = await UserModel.findById(receiverId)
|
||||
.select('blocked_users')
|
||||
.lean()
|
||||
if (!receiver) continue
|
||||
if (viewerIsBlockedBy(receiver, senderId)) continue
|
||||
if (viewerIsBlockedBy(author, senderId)) continue
|
||||
|
||||
const newMessage = new MessageModel({
|
||||
senderId,
|
||||
receiverId,
|
||||
content,
|
||||
})
|
||||
await newMessage.save()
|
||||
await PostSendModel.create({ postId, senderId, receiverId })
|
||||
|
||||
if (io) {
|
||||
const formatted = await enrichMessage(newMessage)
|
||||
emitChatEvents(io, formatted, senderId, receiverId)
|
||||
}
|
||||
sentCount += 1
|
||||
}
|
||||
|
||||
if (!sentCount) {
|
||||
return res.status(400).json({ message: 'ارسال به هیچ گیرندهای انجام نشد' })
|
||||
}
|
||||
|
||||
const sendsCount = await PostSendModel.countDocuments({ postId })
|
||||
|
||||
return res.status(200).json({
|
||||
message: `پست به ${sentCount} نفر ارسال شد`,
|
||||
sendsCount,
|
||||
sentCount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('sendPostToUsers:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFollow = async (req, res, next) => {
|
||||
try {
|
||||
const followerId = getUserIdFromRequest(req)
|
||||
if (!followerId) {
|
||||
return res.status(401).json({ message: 'توکن ارائه نشده است' })
|
||||
}
|
||||
|
||||
const { userId: followingId } = req.body
|
||||
if (!followingId || !mongoose.Types.ObjectId.isValid(followingId)) {
|
||||
return res.status(400).json({ message: 'شناسه کاربر الزامی است' })
|
||||
}
|
||||
|
||||
if (String(followerId) === String(followingId)) {
|
||||
return res.status(400).json({ message: 'نمیتوانید خودتان را فالو کنید' })
|
||||
}
|
||||
|
||||
const target = await UserModel.findById(followingId).select('_id').lean()
|
||||
if (!target) {
|
||||
return res.status(404).json({ message: 'کاربر یافت نشد' })
|
||||
}
|
||||
|
||||
const existing = await UserFollowModel.findOne({ followerId, followingId })
|
||||
if (existing) {
|
||||
await existing.deleteOne()
|
||||
return res.status(200).json({
|
||||
message: 'آنفالو شد',
|
||||
is_following: false,
|
||||
})
|
||||
}
|
||||
|
||||
await UserFollowModel.create({ followerId, followingId })
|
||||
return res.status(200).json({
|
||||
message: 'فالو شد',
|
||||
is_following: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('toggleFollow:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const sharePost = async (req, res, next) => {
|
||||
try {
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'توکن ارائه نشده است' })
|
||||
}
|
||||
|
||||
const { postId } = req.body
|
||||
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
|
||||
return res.status(400).json({ message: 'شناسه پست الزامی است' })
|
||||
}
|
||||
|
||||
await PostShareModel.create({ postId, userId })
|
||||
const sharesCount = await PostShareModel.countDocuments({ postId })
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'اشتراکگذاری ثبت شد',
|
||||
sharesCount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('sharePost:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toggleBookmark,
|
||||
getBookmarks,
|
||||
sendPostToUsers,
|
||||
toggleFollow,
|
||||
getMyLikedPosts,
|
||||
getMyCommentedPosts,
|
||||
sharePost,
|
||||
}
|
||||
@@ -26,40 +26,58 @@ const toggleLike = async (req, res, next) => {
|
||||
return res.status(400).json({ message: 'شناسه پست الزامی است' });
|
||||
}
|
||||
|
||||
const post = await PostModel.findById(postId);
|
||||
const post = await PostModel.findById(postId).select('likes user_id');
|
||||
if (!post) {
|
||||
return res.status(404).json({ message: 'پست مورد نظر یافت نشد' });
|
||||
}
|
||||
|
||||
const isLiked = post.likes.includes(userId);
|
||||
const isLiked = post.likes.some((id) => String(id) === String(userId));
|
||||
if (isLiked) {
|
||||
// unlike
|
||||
post.likes.pull(userId);
|
||||
await post.save();
|
||||
await LikeModel.findOneAndDelete({ postId, userId });
|
||||
// unlike — عین $pull بهجای read-modify-write (post.likes.pull + save)، تا زیرِ
|
||||
// درخواستهای همزمان (دبلتپ) نه VersionError بده نه آپدیت گم بشه
|
||||
const updated = await PostModel.findOneAndUpdate(
|
||||
{ _id: postId, likes: userId },
|
||||
{ $pull: { likes: userId } },
|
||||
{ new: true },
|
||||
).select('likes');
|
||||
await LikeModel.deleteMany({ postId, userId });
|
||||
|
||||
const likesCount = updated?.likes.length ?? post.likes.length - 1;
|
||||
return res.status(200).json({
|
||||
message: 'لایک با موفقیت حذف شد',
|
||||
is_liked: false,
|
||||
likesCount: post.likes.length,
|
||||
likesCount,
|
||||
});
|
||||
} else {
|
||||
// like
|
||||
post.likes.push(userId);
|
||||
await post.save();
|
||||
await LikeModel.create({ postId, userId });
|
||||
// like — $addToSet خودش idempotent و atomic-ه (اگه یه درخواستِ موازیِ دیگه
|
||||
// زودتر همین کاربر رو اضافه کرده باشه، اینجا فقط چیزی عوض نمیشه، خطا نمیده)
|
||||
const updated = await PostModel.findOneAndUpdate(
|
||||
{ _id: postId },
|
||||
{ $addToSet: { likes: userId } },
|
||||
{ new: true },
|
||||
).select('likes user_id');
|
||||
// upsert بهجای create — چون LikeModel روی {postId,userId} ایندکسِ unique نداره
|
||||
// (دیتای فعلیِ production تکراری داره)، ولی این همچنان جلوی ایجادِ تکراریِ تازه
|
||||
// زیرِ همین درخواست رو میگیره
|
||||
await LikeModel.updateOne(
|
||||
{ postId, userId },
|
||||
{ $setOnInsert: { postId, userId } },
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
await createLikeNotification({
|
||||
ownerId: post.user_id,
|
||||
io: req.app.get('io'),
|
||||
ownerId: updated?.user_id ?? post.user_id,
|
||||
likerId: userId,
|
||||
entityId: post._id,
|
||||
type: 'post_like'
|
||||
type: 'post_like',
|
||||
});
|
||||
|
||||
const likesCount = updated?.likes.length ?? post.likes.length + 1;
|
||||
return res.status(200).json({
|
||||
message: 'پست با موفقیت لایک شد',
|
||||
is_liked: true,
|
||||
likesCount: post.likes.length,
|
||||
likesCount,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
242
controllers/application/posts/postAnalyticsController.js
Normal file
242
controllers/application/posts/postAnalyticsController.js
Normal file
@@ -0,0 +1,242 @@
|
||||
/* eslint-disable camelcase */
|
||||
const mongoose = require('mongoose')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const PostModel = require('../../../models/PostModel')
|
||||
const LikeModel = require('../../../models/LikeModel')
|
||||
const CommentModel = require('../../../models/CommentModel')
|
||||
const PostBookmarkModel = require('../../../models/PostBookmarkModel')
|
||||
const PostSendModel = require('../../../models/PostSendModel')
|
||||
const UserFollowModel = require('../../../models/UserFollowModel')
|
||||
const ExploreInteractionModel = require('../../../models/ExploreInteractionModel')
|
||||
|
||||
function bucketByName (rows, field) {
|
||||
const counts = new Map()
|
||||
for (const row of rows) {
|
||||
const name = row?.[field]?.name
|
||||
if (!name) continue
|
||||
counts.set(name, (counts.get(name) || 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
const getPostAnalytics = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
const token = authHeader?.split(' ')[1]
|
||||
let userId = null
|
||||
if (token) {
|
||||
try {
|
||||
userId = jwt.verify(token, process.env.APP_SECRET).id
|
||||
} catch {
|
||||
userId = null
|
||||
}
|
||||
}
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'دسترسی غیرمجاز' })
|
||||
}
|
||||
const { postId } = req.params
|
||||
|
||||
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
|
||||
return res.status(400).json({ message: 'شناسه پست نامعتبر است' })
|
||||
}
|
||||
|
||||
const post = await PostModel.findById(postId).lean()
|
||||
if (!post || String(post.user_id) !== String(userId)) {
|
||||
return res.status(403).json({ message: 'دسترسی به آمار این پست مجاز نیست' })
|
||||
}
|
||||
|
||||
const postObjectId = post._id
|
||||
|
||||
const [
|
||||
viewsCount,
|
||||
sharesCount,
|
||||
offersCount,
|
||||
likesCount,
|
||||
commentsCount,
|
||||
savesCount,
|
||||
sendsCount,
|
||||
viewRecords,
|
||||
dwellRecords,
|
||||
] = await Promise.all([
|
||||
ExploreInteractionModel.countDocuments({
|
||||
targetType: 'post',
|
||||
targetId: postObjectId,
|
||||
action: 'view',
|
||||
}),
|
||||
ExploreInteractionModel.countDocuments({
|
||||
targetType: 'post',
|
||||
targetId: postObjectId,
|
||||
action: 'share',
|
||||
}),
|
||||
ExploreInteractionModel.countDocuments({
|
||||
targetType: 'post',
|
||||
targetId: postObjectId,
|
||||
action: 'offer',
|
||||
}),
|
||||
LikeModel.countDocuments({ postId: postObjectId }),
|
||||
CommentModel.countDocuments({ post: postObjectId, status: 'accepted' }),
|
||||
PostBookmarkModel.countDocuments({ postId: postObjectId }),
|
||||
PostSendModel.countDocuments({ postId: postObjectId }),
|
||||
ExploreInteractionModel.find({
|
||||
targetType: 'post',
|
||||
targetId: postObjectId,
|
||||
action: 'view',
|
||||
})
|
||||
.select('viewerId viewerCountry viewerProvince viewerCity')
|
||||
.lean(),
|
||||
ExploreInteractionModel.find({
|
||||
targetType: 'post',
|
||||
targetId: postObjectId,
|
||||
action: { $in: ['dwell', 'watch_complete'] },
|
||||
dwellMs: { $ne: null },
|
||||
})
|
||||
.select('dwellMs')
|
||||
.lean(),
|
||||
])
|
||||
|
||||
const viewerIds = [...new Set(viewRecords.map((v) => String(v.viewerId)))]
|
||||
let followerViews = 0
|
||||
let nonFollowerViews = viewRecords.length
|
||||
|
||||
if (viewerIds.length) {
|
||||
const followers = await UserFollowModel.find({
|
||||
followingId: post.user_id,
|
||||
followerId: { $in: viewerIds },
|
||||
})
|
||||
.select('followerId')
|
||||
.lean()
|
||||
const followerSet = new Set(followers.map((f) => String(f.followerId)))
|
||||
followerViews = viewRecords.filter((v) =>
|
||||
followerSet.has(String(v.viewerId))
|
||||
).length
|
||||
nonFollowerViews = viewRecords.length - followerViews
|
||||
}
|
||||
|
||||
const totalWatchMs = dwellRecords.reduce(
|
||||
(sum, row) => sum + (row.dwellMs || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const followsFromPost = await ExploreInteractionModel.countDocuments({
|
||||
targetType: 'profile',
|
||||
authorId: post.user_id,
|
||||
action: 'profile_visit',
|
||||
createdAt: { $gte: post.createdAt },
|
||||
})
|
||||
|
||||
const totalAudienceViews = viewRecords.length || 1
|
||||
const followerPct = Math.round((followerViews / totalAudienceViews) * 100)
|
||||
const nonFollowerPct = Math.round(
|
||||
(nonFollowerViews / totalAudienceViews) * 100
|
||||
)
|
||||
|
||||
// فقط برای بازدیدکنندههایی که موقعیت مکانی پروفایلشون رو عمومی کردن — نه موقعیت لحظهای بازدید
|
||||
const viewsByCountry = bucketByName(viewRecords, 'viewerCountry')
|
||||
const viewsByProvince = bucketByName(viewRecords, 'viewerProvince')
|
||||
const viewsByCity = bucketByName(viewRecords, 'viewerCity')
|
||||
const locatedViewsCount = viewRecords.filter((v) => v.viewerProvince?.name).length
|
||||
|
||||
return res.status(200).json({
|
||||
post: {
|
||||
_id: post._id,
|
||||
type: post.type,
|
||||
files: post.files,
|
||||
caption: post.caption,
|
||||
createdAt: post.createdAt,
|
||||
},
|
||||
stats: {
|
||||
likesCount,
|
||||
commentsCount,
|
||||
sendsCount,
|
||||
sharesCount,
|
||||
savesCount,
|
||||
followsFromPost,
|
||||
offersCount,
|
||||
viewsCount,
|
||||
totalWatchMs,
|
||||
followerViews,
|
||||
nonFollowerViews,
|
||||
followerPct,
|
||||
nonFollowerPct,
|
||||
},
|
||||
geography: {
|
||||
locatedViewsCount,
|
||||
viewsByCountry,
|
||||
viewsByProvince,
|
||||
viewsByCity,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('getPostAnalytics:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller's own posts with a lightweight per-post stat snapshot — backs the posts-analytics hub list. */
|
||||
const getMyPostsAnalyticsList = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user._id
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
|
||||
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20))
|
||||
|
||||
const filter = {
|
||||
user_id: userId,
|
||||
status: { $in: ['pending', 'accepted'] },
|
||||
is_archived: { $ne: true }
|
||||
}
|
||||
|
||||
const [posts, totalItems] = await Promise.all([
|
||||
PostModel.find(filter)
|
||||
.select('type files caption createdAt likes')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.lean(),
|
||||
PostModel.countDocuments(filter)
|
||||
])
|
||||
|
||||
const postIds = posts.map((p) => p._id)
|
||||
|
||||
const [commentCounts, viewCounts] = postIds.length
|
||||
? await Promise.all([
|
||||
CommentModel.aggregate([
|
||||
{ $match: { post: { $in: postIds }, status: 'accepted' } },
|
||||
{ $group: { _id: '$post', count: { $sum: 1 } } }
|
||||
]),
|
||||
ExploreInteractionModel.aggregate([
|
||||
{ $match: { targetType: 'post', targetId: { $in: postIds }, action: 'view' } },
|
||||
{ $group: { _id: '$targetId', count: { $sum: 1 } } }
|
||||
])
|
||||
])
|
||||
: [[], []]
|
||||
|
||||
const commentMap = new Map(commentCounts.map((row) => [String(row._id), row.count]))
|
||||
const viewMap = new Map(viewCounts.map((row) => [String(row._id), row.count]))
|
||||
|
||||
const items = posts.map((post) => ({
|
||||
_id: post._id,
|
||||
type: post.type,
|
||||
files: post.files,
|
||||
caption: post.caption,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likes?.length || 0,
|
||||
commentsCount: commentMap.get(String(post._id)) || 0,
|
||||
viewsCount: viewMap.get(String(post._id)) || 0
|
||||
}))
|
||||
|
||||
return res.status(200).json({
|
||||
posts: items,
|
||||
page,
|
||||
limit,
|
||||
totalItems,
|
||||
hasMore: (page - 1) * limit + items.length < totalItems
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('getMyPostsAnalyticsList:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getPostAnalytics, getMyPostsAnalyticsList }
|
||||
@@ -1,65 +1,375 @@
|
||||
/* eslint-disable camelcase */
|
||||
const PostModel = require('../../../models/PostModel');
|
||||
const UserModel = require('../../../models/UserModel');
|
||||
const CourseModel = require('../../../models/CourseModel');
|
||||
const ProjectModel = require('../../../models/ProjectModel');
|
||||
const AdvertisingModel = require('../../../models/AdvertisingModel');
|
||||
const ShopModel = require('../../../models/ShopModel');
|
||||
const ShopProductListingModel = require('../../../models/ShopProductListingModel');
|
||||
const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity');
|
||||
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity');
|
||||
const { check, validationResult } = require('express-validator');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const path = require('path');
|
||||
const { default: mongoose } = require('mongoose');
|
||||
const { viewerIsBlockedBy } = require('../../../utils/blockVisibility');
|
||||
const { attachSinglePostEngagement } = require('../../../utils/postEngagement');
|
||||
const { isHiddenFromPublic } = require('../../../utils/accountLifecycle');
|
||||
const { viewerCanSeeUserContent } = require('../../../utils/privateProfile');
|
||||
const { isPostPubliclyVisible } = require('../../../utils/postModeration');
|
||||
const { postStatusFilter, PUBLIC_POST_STATUSES, commentStatusFilter } = require('../../../utils/moderationStatus');
|
||||
const { resolvePostModerationStatus } = require('../../../utils/contentModeration');
|
||||
const { savePostWithModerationStatus } = require('../../../utils/postStatusSave');
|
||||
const { createTagNotifications } = require('../../../utils/tagNotification');
|
||||
const { notifyFollowers } = require('../../../utils/notifyFollowers');
|
||||
|
||||
let processPostVideoInBackground = async () => {};
|
||||
try {
|
||||
({ processPostVideoInBackground } = require('../../../utils/postVideoProcessing'));
|
||||
} catch (error) {
|
||||
console.warn('[postController] postVideoProcessing unavailable:', error.message);
|
||||
}
|
||||
|
||||
const createPostValidationRules = () => {
|
||||
console.log(4);
|
||||
return [
|
||||
check('caption')
|
||||
.notEmpty().withMessage('توضیحات نمیتواند خالی باشد'),
|
||||
.optional({ nullable: true })
|
||||
.isLength({ max: 2000 })
|
||||
.withMessage('کپشن حداکثر ۲۰۰۰ کاراکتر'),
|
||||
];
|
||||
};
|
||||
|
||||
const createPost = async (req, res) => {
|
||||
try {
|
||||
console.log('DEBUG: createPost called');
|
||||
console.log('DEBUG: req.files:', req.files);
|
||||
console.log('DEBUG: req.body:', req.body);
|
||||
console.log('DEBUG: createPost called', {
|
||||
filesCount: Array.isArray(req.files) ? req.files.length : 0,
|
||||
hasCaption: Boolean(req.body?.caption),
|
||||
userId: String(req.user?._id || req.user?.id || ''),
|
||||
});
|
||||
|
||||
const { caption } = req.body;
|
||||
const files = req.files || [];
|
||||
const userId = req.user.id; // از میدلویر auth
|
||||
const userId = req.user?._id || req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' });
|
||||
}
|
||||
|
||||
let taggedUserIds = [];
|
||||
if (req.body.tagged_user_ids) {
|
||||
const raw = req.body.tagged_user_ids;
|
||||
let parsed = [];
|
||||
try {
|
||||
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
taggedUserIds = parsed
|
||||
.filter((id) => mongoose.Types.ObjectId.isValid(id))
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
// اعتبارسنجی همهی آیتمهای قابللینک (پکیج/پروژه/بیلبورد/فروشگاه/کالا/رزرو) از یک تابع
|
||||
// مشترک — همون شرط مالکیت+وضعیت که قبلاً اینجا پراکنده بود، حالا با استوری هم به اشتراک
|
||||
// گذاشته میشه (utils/resolveLinkedEntity.js)
|
||||
const linkedCourseId = await resolveLinkedEntity('pkg', req.body.linked_course_id, userId);
|
||||
const linkedProjectId = await resolveLinkedEntity('project', req.body.linked_project_id, userId);
|
||||
const linkedBillboardId = await resolveLinkedEntity('billboard', req.body.linked_billboard_id, userId);
|
||||
const linkedShopId = await resolveLinkedEntity('shop', req.body.linked_shop_id, userId);
|
||||
const linkedShopProductId = await resolveLinkedEntity('shopproduct', req.body.linked_shop_product_id, userId);
|
||||
const linkedBookingId = await resolveLinkedEntity('booking', req.body.linked_booking_id, userId);
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
console.log('DEBUG: No files provided');
|
||||
return res.status(400).json({ error: true, message: 'هیچ فایلی آپلود نشده است' });
|
||||
}
|
||||
|
||||
if (!caption) {
|
||||
console.log('DEBUG: No caption provided');
|
||||
return res.status(400).json({ error: true, message: 'کپشن الزامی است' });
|
||||
}
|
||||
const captionText =
|
||||
caption != null && String(caption).trim()
|
||||
? String(caption).trim().slice(0, 2000)
|
||||
: null;
|
||||
|
||||
const filePaths = files.map((file) => ({
|
||||
path: file.path,
|
||||
type: file.mimetype.startsWith('video/') ? 'video' : 'image',
|
||||
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image',
|
||||
}));
|
||||
|
||||
console.log('DEBUG: Saving post to MongoDB, filePaths:', filePaths);
|
||||
const post = new PostModel({
|
||||
const postMediaType = filePaths.some((file) => file.type === 'video')
|
||||
? 'video'
|
||||
: 'image';
|
||||
|
||||
const moderation = resolvePostModerationStatus({ caption: captionText, filePaths });
|
||||
if (moderation.status === 'reject') {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.',
|
||||
});
|
||||
}
|
||||
|
||||
console.log('DEBUG: Saving post to MongoDB, files:', filePaths.length);
|
||||
|
||||
const author = await UserModel.findById(userId)
|
||||
.select('post_location_enabled lat lng')
|
||||
.lean();
|
||||
|
||||
const postPayload = {
|
||||
user_id: userId,
|
||||
caption,
|
||||
caption: captionText,
|
||||
files: filePaths,
|
||||
type: files[0].mimetype.startsWith('video/') ? 'video' : 'image', // اضافه کردن فیلد type
|
||||
createdAt: new Date(),
|
||||
type: postMediaType,
|
||||
tagged_users: taggedUserIds,
|
||||
status: moderation.status,
|
||||
};
|
||||
|
||||
if (linkedCourseId) {
|
||||
postPayload.linked_course_id = linkedCourseId;
|
||||
}
|
||||
if (linkedProjectId) {
|
||||
postPayload.linked_project_id = linkedProjectId;
|
||||
}
|
||||
if (linkedBillboardId) {
|
||||
postPayload.linked_billboard_id = linkedBillboardId;
|
||||
}
|
||||
if (linkedShopId) {
|
||||
postPayload.linked_shop_id = linkedShopId;
|
||||
}
|
||||
if (linkedShopProductId) {
|
||||
postPayload.linked_shop_product_id = linkedShopProductId;
|
||||
}
|
||||
if (linkedBookingId) {
|
||||
postPayload.linked_booking_id = linkedBookingId;
|
||||
}
|
||||
|
||||
if (
|
||||
author?.post_location_enabled &&
|
||||
author.lat &&
|
||||
author.lng &&
|
||||
String(author.lat).trim() &&
|
||||
String(author.lng).trim()
|
||||
) {
|
||||
postPayload.lat = String(author.lat);
|
||||
postPayload.lng = String(author.lng);
|
||||
}
|
||||
|
||||
// کشور/شهر/استان پست — اختیاری، جدا از موقعیت پروفایل کاربر؛ کاربر خودش موقع ثبت پست انتخاب میکنه
|
||||
if (req.body.country_id) {
|
||||
const countryFind = await CountryModel.findOne({ id: req.body.country_id }, { _id: 0 });
|
||||
if (countryFind) {
|
||||
postPayload.country = { id: countryFind.id, name: countryFind.name_fa, iso2: countryFind.iso2, slug: countryFind.slug };
|
||||
}
|
||||
}
|
||||
if (req.body.province_id) {
|
||||
const provinceFind = await ProvinceModel.findOne({ id: req.body.province_id }, { _id: 0 });
|
||||
if (provinceFind) {
|
||||
postPayload.province = provinceFind;
|
||||
}
|
||||
}
|
||||
if (req.body.city_id) {
|
||||
const cityFind = await CityModel.findOne({ id: req.body.city_id });
|
||||
if (cityFind) {
|
||||
postPayload.city = cityFind;
|
||||
}
|
||||
}
|
||||
|
||||
const post = await savePostWithModerationStatus(PostModel, postPayload);
|
||||
console.log('DEBUG: Post saved:', post._id);
|
||||
|
||||
if (taggedUserIds.length > 0) {
|
||||
await createTagNotifications({
|
||||
taggerId: userId,
|
||||
taggedUserIds,
|
||||
postId: post._id
|
||||
});
|
||||
}
|
||||
|
||||
notifyFollowers(userId, post).catch((err) => {
|
||||
console.error('[notifyFollowers] background error:', err.message);
|
||||
});
|
||||
|
||||
await post.save();
|
||||
console.log('DEBUG: Post saved:', post._id);
|
||||
res.status(201).json({ message: 'پست با موفقیت ایجاد شد', postId: post._id });
|
||||
const firstVideo = filePaths.find((file) => file.type === 'video');
|
||||
if (firstVideo?.path) {
|
||||
processPostVideoInBackground(firstVideo.path, post._id).catch((err) => {
|
||||
console.error('[postVideoProcessing] background error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: 'پست ثبت شد و در حالت تایید موقت نمایش داده میشود',
|
||||
postId: post._id,
|
||||
status: post.status,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('DEBUG: createPost error:', err.message);
|
||||
if (err?.name === 'ValidationError') {
|
||||
return res.status(500).json({
|
||||
error: true,
|
||||
message: 'خطا در ثبت پست. مدل پست روی سرور بهروز نیست.',
|
||||
});
|
||||
}
|
||||
res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` });
|
||||
}
|
||||
};
|
||||
|
||||
function toPublicFilePath(filePath) {
|
||||
if (!filePath || typeof filePath !== 'string') return filePath;
|
||||
let s = filePath.replace(/\\/g, '/');
|
||||
const storageIdx = s.indexOf('/storage/');
|
||||
if (storageIdx >= 0) {
|
||||
return s.slice(storageIdx + '/storage'.length);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const updatePost = async (req, res) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.split(' ')[1]
|
||||
let userId = null
|
||||
if (token) {
|
||||
try {
|
||||
userId = jwt.verify(token, process.env.APP_SECRET).id
|
||||
} catch {
|
||||
userId = null
|
||||
}
|
||||
}
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: true, message: 'دسترسی غیرمجاز' });
|
||||
}
|
||||
const { postId, caption } = req.body;
|
||||
const files = req.files || [];
|
||||
|
||||
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
|
||||
return res.status(400).json({ error: true, message: 'شناسه پست نامعتبر است' });
|
||||
}
|
||||
|
||||
const post = await PostModel.findById(postId);
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: true, message: 'پست یافت نشد' });
|
||||
}
|
||||
|
||||
if (String(post.user_id) !== String(userId)) {
|
||||
return res.status(403).json({ error: true, message: 'اجازه ویرایش این پست را ندارید' });
|
||||
}
|
||||
|
||||
let keepFiles = [];
|
||||
if (req.body.keep_files) {
|
||||
try {
|
||||
keepFiles = Array.isArray(req.body.keep_files)
|
||||
? req.body.keep_files
|
||||
: JSON.parse(req.body.keep_files);
|
||||
} catch {
|
||||
keepFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
const existingByPath = new Map(
|
||||
(post.files || []).map((f) => [toPublicFilePath(f.path), f])
|
||||
);
|
||||
|
||||
const orderedExisting = [];
|
||||
for (const item of keepFiles) {
|
||||
const rawPath = typeof item === 'string' ? item : item?.path;
|
||||
if (!rawPath) continue;
|
||||
const key = toPublicFilePath(rawPath);
|
||||
const found = existingByPath.get(key) ||
|
||||
(post.files || []).find((f) => toPublicFilePath(f.path) === key || f.path === rawPath);
|
||||
if (found) {
|
||||
orderedExisting.push({
|
||||
path: found.path,
|
||||
type: found.type === 'video' ? 'video' : 'image',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newFilePaths = (files || []).map((file) => ({
|
||||
path: file.path,
|
||||
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image',
|
||||
}));
|
||||
|
||||
// اگر media_order ارسال شده: existing:index یا new:index
|
||||
let finalFiles = [...orderedExisting, ...newFilePaths];
|
||||
if (req.body.media_order) {
|
||||
let order = [];
|
||||
try {
|
||||
order = Array.isArray(req.body.media_order)
|
||||
? req.body.media_order
|
||||
: JSON.parse(req.body.media_order);
|
||||
} catch {
|
||||
order = [];
|
||||
}
|
||||
if (Array.isArray(order) && order.length) {
|
||||
const rebuilt = [];
|
||||
for (const token of order) {
|
||||
const value = String(token || '');
|
||||
if (value.startsWith('existing:')) {
|
||||
const idx = Number(value.slice('existing:'.length));
|
||||
if (orderedExisting[idx]) rebuilt.push(orderedExisting[idx]);
|
||||
} else if (value.startsWith('new:')) {
|
||||
const idx = Number(value.slice('new:'.length));
|
||||
if (newFilePaths[idx]) rebuilt.push(newFilePaths[idx]);
|
||||
}
|
||||
}
|
||||
if (rebuilt.length) finalFiles = rebuilt;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalFiles.length) {
|
||||
return res.status(400).json({ error: true, message: 'حداقل یک فایل برای پست لازم است' });
|
||||
}
|
||||
|
||||
if (caption != null) {
|
||||
const captionText = String(caption).trim()
|
||||
? String(caption).trim().slice(0, 2000)
|
||||
: null;
|
||||
const moderation = resolvePostModerationStatus({
|
||||
caption: captionText,
|
||||
filePaths: finalFiles,
|
||||
});
|
||||
if (moderation.status === 'reject') {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.',
|
||||
});
|
||||
}
|
||||
post.caption = captionText;
|
||||
post.status = moderation.status;
|
||||
}
|
||||
|
||||
let taggedUserIds = post.tagged_users || [];
|
||||
if (req.body.tagged_user_ids !== undefined) {
|
||||
const raw = req.body.tagged_user_ids;
|
||||
let parsed = [];
|
||||
try {
|
||||
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
taggedUserIds = parsed
|
||||
.filter((id) => mongoose.Types.ObjectId.isValid(id))
|
||||
.slice(0, 50);
|
||||
post.tagged_users = taggedUserIds;
|
||||
}
|
||||
|
||||
post.files = finalFiles;
|
||||
post.type = finalFiles.some((f) => f.type === 'video') ? 'video' : 'image';
|
||||
post.updatedAt = new Date();
|
||||
await post.save();
|
||||
|
||||
const firstVideo = finalFiles.find((file) => file.type === 'video');
|
||||
if (firstVideo?.path) {
|
||||
processPostVideoInBackground(firstVideo.path, post._id).catch((err) => {
|
||||
console.error('[postVideoProcessing] background error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'پست با موفقیت ویرایش شد',
|
||||
postId: post._id,
|
||||
status: post.status,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('DEBUG: updatePost error:', err.message);
|
||||
return res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -81,6 +391,19 @@ const getUserPosts = async (req, res, next) => {
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' })
|
||||
}
|
||||
if (user_id.toString() !== userReqId.toString() && isHiddenFromPublic(user)) {
|
||||
return res.status(200).json({
|
||||
data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0 }
|
||||
})
|
||||
}
|
||||
if (user_id.toString() !== userReqId.toString()) {
|
||||
const canView = await viewerCanSeeUserContent(userReqId, user)
|
||||
if (!canView) {
|
||||
return res.status(200).json({
|
||||
data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0, privateLocked: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
const response = {}
|
||||
const options = {
|
||||
page: req.query.page || 1, // صفحه پیشفرض ۱
|
||||
@@ -90,7 +413,7 @@ const getUserPosts = async (req, res, next) => {
|
||||
if (user_id.toString() === userReqId.toString()) {
|
||||
// کاربر خودش است، همه پستهایش را بررسی میکند
|
||||
const posts = await PostModel.paginate(
|
||||
{ user_id: user._id, status: { $in: ['pending', 'accept'] } }, // فیلتر
|
||||
{ user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } }, // فیلتر
|
||||
options // گزینههای پیجینیشن
|
||||
)
|
||||
response.posts = posts.docs
|
||||
@@ -100,7 +423,7 @@ const getUserPosts = async (req, res, next) => {
|
||||
} else {
|
||||
// دیگران، فقط پستهایی که وضعیتشان "accept" است را ببینند
|
||||
const posts = await PostModel.paginate(
|
||||
{ user_id: user._id, status: 'accept' }, // فیلتر
|
||||
{ user_id: user._id, status: postStatusFilter(), is_archived: { $ne: true } }, // فیلتر
|
||||
options // گزینههای پیجینیشن
|
||||
)
|
||||
response.posts = posts.docs
|
||||
@@ -149,6 +472,31 @@ const getUserPostsWeb = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
if ((!userReqId || user_id.toString() !== userReqId.toString()) && isHiddenFromPublic(user)) {
|
||||
return res.status(200).json({
|
||||
posts: [],
|
||||
postsCount: 0,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!userReqId ||
|
||||
user_id.toString() !== userReqId.toString()
|
||||
) {
|
||||
const canView = await viewerCanSeeUserContent(userReqId, user)
|
||||
if (!canView) {
|
||||
return res.status(200).json({
|
||||
posts: [],
|
||||
postsCount: 0,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
privateLocked: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const data = {}
|
||||
const options = {
|
||||
page: req.query.page || 1,
|
||||
@@ -157,19 +505,28 @@ const getUserPostsWeb = async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (userReqId && user_id.toString() === userReqId.toString()) {
|
||||
// اگر کاربر لاگین کرده و درخواست برای خودش است، همه پستهایش را ببیند
|
||||
const posts = await PostModel.paginate(
|
||||
{ user_id: user._id, status: { $in: ['pending', 'accept'] } },
|
||||
{ user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } },
|
||||
options
|
||||
)
|
||||
data.posts = posts.docs
|
||||
data.postsCount = posts.totalDocs
|
||||
data.posts = posts.docs.filter(
|
||||
(post) => post.status === 'pending' || isPostPubliclyVisible(post)
|
||||
)
|
||||
data.postsCount = data.posts.length
|
||||
data.totalPages = posts.totalPages
|
||||
data.totalItems = posts.totalDocs
|
||||
data.totalItems = data.posts.length
|
||||
} else {
|
||||
// برای بقیه، فقط پستهایی که وضعیتشان "accept" است نمایش داده شود
|
||||
const posts = await PostModel.paginate(
|
||||
{ user_id: user._id, status: 'accept' },
|
||||
{
|
||||
user_id: user._id,
|
||||
status: postStatusFilter(),
|
||||
is_archived: { $ne: true },
|
||||
$or: [
|
||||
{ report_hidden: false },
|
||||
{ report_hidden: { $exists: false } },
|
||||
{ report_hidden: null },
|
||||
],
|
||||
},
|
||||
options
|
||||
)
|
||||
data.posts = posts.docs
|
||||
@@ -178,6 +535,22 @@ const getUserPostsWeb = async (req, res, next) => {
|
||||
data.totalItems = posts.totalDocs
|
||||
}
|
||||
|
||||
const pinnedIds = (user.pinned_post_ids || []).map(String)
|
||||
if (pinnedIds.length && data.posts?.length) {
|
||||
const pinOrder = new Map(pinnedIds.map((id, idx) => [id, idx]))
|
||||
data.posts.sort((a, b) => {
|
||||
const aPin = pinOrder.get(String(a._id))
|
||||
const bPin = pinOrder.get(String(b._id))
|
||||
if (aPin !== undefined && bPin !== undefined) return aPin - bPin
|
||||
if (aPin !== undefined) return -1
|
||||
if (bPin !== undefined) return 1
|
||||
return new Date(b.createdAt) - new Date(a.createdAt)
|
||||
})
|
||||
data.posts.forEach((p) => {
|
||||
p.is_pinned = pinOrder.has(String(p._id))
|
||||
})
|
||||
}
|
||||
|
||||
return res.status(200).json(data)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
@@ -205,13 +578,16 @@ const getPostByIdWeb = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const post = await PostModel.findById(postId).lean()
|
||||
if (!post || post.status !== 'accept') {
|
||||
if (!post || !isPostPubliclyVisible(post)) {
|
||||
return res.status(404).json({ message: 'پست یافت نشد' })
|
||||
}
|
||||
if (post.is_archived && String(post.user_id) !== String(userReqId)) {
|
||||
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'
|
||||
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image blocked_users is_private account_status'
|
||||
)
|
||||
.lean()
|
||||
|
||||
@@ -223,6 +599,11 @@ const getPostByIdWeb = async (req, res, next) => {
|
||||
return res.status(404).json({ message: 'پست یافت نشد' })
|
||||
}
|
||||
|
||||
const canView = await viewerCanSeeUserContent(userReqId, user)
|
||||
if (!canView) {
|
||||
return res.status(404).json({ message: 'پست یافت نشد' })
|
||||
}
|
||||
|
||||
const LikeModel = require('../../../models/LikeModel')
|
||||
const CommentModel = require('../../../models/CommentModel')
|
||||
|
||||
@@ -237,26 +618,30 @@ const getPostByIdWeb = async (req, res, next) => {
|
||||
|
||||
const commentsCount = await CommentModel.countDocuments({
|
||||
post: post._id,
|
||||
status: 'accepted',
|
||||
status: commentStatusFilter(),
|
||||
})
|
||||
|
||||
let enrichedPost = {
|
||||
...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,
|
||||
}
|
||||
|
||||
enrichedPost = await attachSinglePostEngagement(enrichedPost, userReqId)
|
||||
|
||||
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,
|
||||
},
|
||||
post: enrichedPost,
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
@@ -266,6 +651,7 @@ const getPostByIdWeb = async (req, res, next) => {
|
||||
module.exports = {
|
||||
createPostValidationRules,
|
||||
createPost,
|
||||
updatePost,
|
||||
getUserPosts,
|
||||
getUserPostsWeb,
|
||||
getPostByIdWeb
|
||||
|
||||
50
controllers/application/posts/reportController.js
Normal file
50
controllers/application/posts/reportController.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable camelcase */
|
||||
const PostReportModel = require('../../../models/PostReportModel')
|
||||
const PostModel = require('../../../models/PostModel')
|
||||
const { default: mongoose } = require('mongoose')
|
||||
|
||||
const reportPost = async (req, res) => {
|
||||
try {
|
||||
const reporterId = req.user._id
|
||||
const { postId, reason, text } = req.body
|
||||
|
||||
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
|
||||
return res.status(400).json({ message: 'شناسه پست نامعتبر است' })
|
||||
}
|
||||
if (!reason || !String(reason).trim()) {
|
||||
return res.status(400).json({ message: 'دلیل گزارش الزامی است' })
|
||||
}
|
||||
|
||||
const post = await PostModel.findById(postId).select('_id user_id status').lean()
|
||||
if (!post || post.status !== 'accept') {
|
||||
return res.status(404).json({ message: 'پست یافت نشد' })
|
||||
}
|
||||
if (String(post.user_id) === String(reporterId)) {
|
||||
return res.status(400).json({ message: 'امکان گزارش پست خودتان وجود ندارد' })
|
||||
}
|
||||
|
||||
const existing = await PostReportModel.findOne({
|
||||
post_id: postId,
|
||||
reporter_id: reporterId,
|
||||
}).lean()
|
||||
if (existing) {
|
||||
return res.status(400).json({ message: 'شما قبلاً این پست را گزارش کردهاید' })
|
||||
}
|
||||
|
||||
await PostReportModel.create({
|
||||
post_id: postId,
|
||||
reporter_id: reporterId,
|
||||
reason: String(reason).trim().slice(0, 200),
|
||||
text: String(text || '').trim().slice(0, 2000),
|
||||
})
|
||||
|
||||
return res.status(201).json({ message: 'گزارش ثبت شد' })
|
||||
} catch (err) {
|
||||
console.error('reportPost:', err)
|
||||
return res.status(500).json({ message: 'خطا در ثبت گزارش' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reportPost,
|
||||
}
|
||||
Reference in New Issue
Block a user