This commit is contained in:
payacom
2026-09-03 16:06:02 +03:30
parent 3f416a7a60
commit 90c5f94b57
31 changed files with 3968 additions and 4283 deletions

View File

@@ -1,33 +1,33 @@
/* 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');
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 () => {};
let processPostVideoInBackground = async () => {}
try {
({ processPostVideoInBackground } = require('../../../utils/postVideoProcessing'));
({ processPostVideoInBackground } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[postController] postVideoProcessing unavailable:', error.message);
console.warn('[postController] postVideoProcessing unavailable:', error.message)
}
const createPostValidationRules = () => {
@@ -35,81 +35,81 @@ const createPostValidationRules = () => {
check('caption')
.optional({ nullable: true })
.isLength({ max: 2000 })
.withMessage('کپشن حداکثر ۲۰۰۰ کاراکتر'),
];
};
.withMessage('کپشن حداکثر ۲۰۰۰ کاراکتر')
]
}
const createPost = async (req, res) => {
try {
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 || ''),
});
userId: String(req.user?._id || req.user?.id || '')
})
const { caption } = req.body;
const files = req.files || [];
const userId = req.user?._id || req.user?.id;
const { caption } = req.body
const files = req.files || []
const userId = req.user?._id || req.user?.id
if (!userId) {
return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' });
return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' })
}
let taggedUserIds = [];
let taggedUserIds = []
if (req.body.tagged_user_ids) {
const raw = req.body.tagged_user_ids;
let parsed = [];
const raw = req.body.tagged_user_ids
let parsed = []
try {
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []);
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : [])
} catch {
parsed = [];
parsed = []
}
taggedUserIds = parsed
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.slice(0, 50);
.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);
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: 'هیچ فایلی آپلود نشده است' });
console.log('DEBUG: No files provided')
return res.status(400).json({ error: true, message: 'هیچ فایلی آپلود نشده است' })
}
const captionText =
caption != null && String(caption).trim()
? String(caption).trim().slice(0, 2000)
: null;
: null
const filePaths = files.map((file) => ({
path: file.path,
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image',
}));
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image'
}))
const postMediaType = filePaths.some((file) => file.type === 'video')
? 'video'
: 'image';
: 'image'
const moderation = resolvePostModerationStatus({ caption: captionText, filePaths });
const author = await UserModel.findById(userId)
.select('post_location_enabled lat lng verify_badge')
.lean()
const moderation = resolvePostModerationStatus({ caption: captionText, filePaths, verifyBadge: author?.verify_badge })
if (moderation.status === 'reject') {
return res.status(422).json({
error: true,
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.',
});
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.'
})
}
console.log('DEBUG: Saving post to MongoDB, files:', filePaths.length);
const author = await UserModel.findById(userId)
.select('post_location_enabled lat lng')
.lean();
console.log('DEBUG: Saving post to MongoDB, files:', filePaths.length)
const postPayload = {
user_id: userId,
@@ -117,26 +117,26 @@ const createPost = async (req, res) => {
files: filePaths,
type: postMediaType,
tagged_users: taggedUserIds,
status: moderation.status,
};
status: moderation.status
}
if (linkedCourseId) {
postPayload.linked_course_id = linkedCourseId;
postPayload.linked_course_id = linkedCourseId
}
if (linkedProjectId) {
postPayload.linked_project_id = linkedProjectId;
postPayload.linked_project_id = linkedProjectId
}
if (linkedBillboardId) {
postPayload.linked_billboard_id = linkedBillboardId;
postPayload.linked_billboard_id = linkedBillboardId
}
if (linkedShopId) {
postPayload.linked_shop_id = linkedShopId;
postPayload.linked_shop_id = linkedShopId
}
if (linkedShopProductId) {
postPayload.linked_shop_product_id = linkedShopProductId;
postPayload.linked_shop_product_id = linkedShopProductId
}
if (linkedBookingId) {
postPayload.linked_booking_id = linkedBookingId;
postPayload.linked_booking_id = linkedBookingId
}
if (
@@ -146,77 +146,77 @@ const createPost = async (req, res) => {
String(author.lat).trim() &&
String(author.lng).trim()
) {
postPayload.lat = String(author.lat);
postPayload.lng = String(author.lng);
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 });
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 };
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 });
const provinceFind = await ProvinceModel.findOne({ id: req.body.province_id }, { _id: 0 })
if (provinceFind) {
postPayload.province = provinceFind;
postPayload.province = provinceFind
}
}
if (req.body.city_id) {
const cityFind = await CityModel.findOne({ id: req.body.city_id });
const cityFind = await CityModel.findOne({ id: req.body.city_id })
if (cityFind) {
postPayload.city = cityFind;
postPayload.city = cityFind
}
}
const post = await savePostWithModerationStatus(PostModel, postPayload);
console.log('DEBUG: Post saved:', post._id);
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);
});
console.error('[notifyFollowers] background error:', err.message)
})
const firstVideo = filePaths.find((file) => file.type === 'video');
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);
});
console.error('[postVideoProcessing] background error:', err.message)
})
}
res.status(201).json({
message: 'پست ثبت شد و در حالت تایید موقت نمایش داده می‌شود',
postId: post._id,
status: post.status,
});
status: post.status
})
} catch (err) {
console.error('DEBUG: createPost error:', err.message);
console.error('DEBUG: createPost error:', err.message)
if (err?.name === 'ValidationError') {
return res.status(500).json({
error: true,
message: 'خطا در ثبت پست. مدل پست روی سرور به‌روز نیست.',
});
message: 'خطا در ثبت پست. مدل پست روی سرور به‌روز نیست.'
})
}
res.status(500).json({ error: true, message: `خطا در سرور: ${err.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/');
function toPublicFilePath (filePath) {
if (!filePath || typeof filePath !== 'string') return filePath
const s = filePath.replace(/\\/g, '/')
const storageIdx = s.indexOf('/storage/')
if (storageIdx >= 0) {
return s.slice(storageIdx + '/storage'.length);
return s.slice(storageIdx + '/storage'.length)
}
return s;
return s
}
const updatePost = async (req, res) => {
@@ -231,148 +231,145 @@ const updatePost = async (req, res) => {
}
}
if (!userId) {
return res.status(401).json({ error: true, message: 'دسترسی غیرمجاز' });
return res.status(401).json({ error: true, message: 'دسترسی غیرمجاز' })
}
const { postId, caption } = req.body;
const files = req.files || [];
const { postId, caption } = req.body
const files = req.files || []
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(400).json({ error: true, message: 'شناسه پست نامعتبر است' });
return res.status(400).json({ error: true, message: 'شناسه پست نامعتبر است' })
}
const post = await PostModel.findById(postId);
const post = await PostModel.findById(postId)
if (!post) {
return res.status(404).json({ error: true, message: 'پست یافت نشد' });
return res.status(404).json({ error: true, message: 'پست یافت نشد' })
}
if (String(post.user_id) !== String(userId)) {
return res.status(403).json({ error: true, message: 'اجازه ویرایش این پست را ندارید' });
return res.status(403).json({ error: true, message: 'اجازه ویرایش این پست را ندارید' })
}
let keepFiles = [];
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);
: JSON.parse(req.body.keep_files)
} catch {
keepFiles = [];
keepFiles = []
}
}
const existingByPath = new Map(
(post.files || []).map((f) => [toPublicFilePath(f.path), f])
);
)
const orderedExisting = [];
const orderedExisting = []
for (const item of keepFiles) {
const rawPath = typeof item === 'string' ? item : item?.path;
if (!rawPath) continue;
const key = toPublicFilePath(rawPath);
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);
(post.files || []).find((f) => toPublicFilePath(f.path) === key || f.path === rawPath)
if (found) {
orderedExisting.push({
path: found.path,
type: found.type === 'video' ? 'video' : 'image',
});
type: found.type === 'video' ? 'video' : 'image'
})
}
}
const newFilePaths = (files || []).map((file) => ({
path: file.path,
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image',
}));
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image'
}))
// اگر media_order ارسال شده: existing:index یا new:index
let finalFiles = [...orderedExisting, ...newFilePaths];
let finalFiles = [...orderedExisting, ...newFilePaths]
if (req.body.media_order) {
let order = [];
let order = []
try {
order = Array.isArray(req.body.media_order)
? req.body.media_order
: JSON.parse(req.body.media_order);
: JSON.parse(req.body.media_order)
} catch {
order = [];
order = []
}
if (Array.isArray(order) && order.length) {
const rebuilt = [];
const rebuilt = []
for (const token of order) {
const value = String(token || '');
const value = String(token || '')
if (value.startsWith('existing:')) {
const idx = Number(value.slice('existing:'.length));
if (orderedExisting[idx]) rebuilt.push(orderedExisting[idx]);
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]);
const idx = Number(value.slice('new:'.length))
if (newFilePaths[idx]) rebuilt.push(newFilePaths[idx])
}
}
if (rebuilt.length) finalFiles = rebuilt;
if (rebuilt.length) finalFiles = rebuilt
}
}
if (!finalFiles.length) {
return res.status(400).json({ error: true, message: 'حداقل یک فایل برای پست لازم است' });
return res.status(400).json({ error: true, message: 'حداقل یک فایل برای پست لازم است' })
}
if (caption != null) {
const captionText = String(caption).trim()
? String(caption).trim().slice(0, 2000)
: null;
: null
const moderation = resolvePostModerationStatus({
caption: captionText,
filePaths: finalFiles,
});
filePaths: finalFiles
})
if (moderation.status === 'reject') {
return res.status(422).json({
error: true,
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.',
});
message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.'
})
}
post.caption = captionText;
post.status = moderation.status;
post.caption = captionText
post.status = moderation.status
}
let taggedUserIds = post.tagged_users || [];
let taggedUserIds = post.tagged_users || []
if (req.body.tagged_user_ids !== undefined) {
const raw = req.body.tagged_user_ids;
let parsed = [];
const raw = req.body.tagged_user_ids
let parsed = []
try {
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []);
parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : [])
} catch {
parsed = [];
parsed = []
}
taggedUserIds = parsed
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.slice(0, 50);
post.tagged_users = taggedUserIds;
.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();
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');
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);
});
console.error('[postVideoProcessing] background error:', err.message)
})
}
return res.status(200).json({
message: 'پست با موفقیت ویرایش شد',
postId: post._id,
status: post.status,
});
status: post.status
})
} catch (err) {
console.error('DEBUG: updatePost error:', err.message);
return res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` });
console.error('DEBUG: updatePost error:', err.message)
return res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` })
}
};
}
const getUserPosts = async (req, res, next) => {
try {
@@ -468,7 +465,7 @@ const getUserPostsWeb = async (req, res, next) => {
posts: [],
postsCount: 0,
totalPages: 0,
totalItems: 0,
totalItems: 0
})
}
@@ -477,7 +474,7 @@ const getUserPostsWeb = async (req, res, next) => {
posts: [],
postsCount: 0,
totalPages: 0,
totalItems: 0,
totalItems: 0
})
}
@@ -492,7 +489,7 @@ const getUserPostsWeb = async (req, res, next) => {
postsCount: 0,
totalPages: 0,
totalItems: 0,
privateLocked: true,
privateLocked: true
})
}
}
@@ -524,8 +521,8 @@ const getUserPostsWeb = async (req, res, next) => {
$or: [
{ report_hidden: false },
{ report_hidden: { $exists: false } },
{ report_hidden: null },
],
{ report_hidden: null }
]
},
options
)
@@ -611,14 +608,14 @@ const getPostByIdWeb = async (req, res, next) => {
if (userReqId) {
const like = await LikeModel.findOne({
userId: userReqId,
postId: post._id,
postId: post._id
}).lean()
is_liked = !!like
}
const commentsCount = await CommentModel.countDocuments({
post: post._id,
status: commentStatusFilter(),
status: commentStatusFilter()
})
let enrichedPost = {
@@ -635,13 +632,13 @@ const getPostByIdWeb = async (req, res, next) => {
profile_image: user.profile_image || '',
likesCount: post.likes ? post.likes.length : 0,
is_liked,
commentsCount,
commentsCount
}
enrichedPost = await attachSinglePostEngagement(enrichedPost, userReqId)
return res.status(200).json({
post: enrichedPost,
post: enrichedPost
})
} catch (error) {
next(error)