This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

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

View File

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

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

View File

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

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