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,521 @@
/* eslint-disable camelcase */
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const moment = require('moment-jalaali')
const UserModel = require('../../../models/UserModel')
const PostModel = require('../../../models/PostModel')
const ExploreInteractionModel = require('../../../models/ExploreInteractionModel')
const UserFollowModel = require('../../../models/UserFollowModel')
const LikeModel = require('../../../models/LikeModel')
const CommentModel = require('../../../models/CommentModel')
const {
DEACTIVATION_LOCK_DAYS,
DEACTIVATION_PURGE_MONTHS,
MS_DAY,
isDeactivated,
createDeletionTombstone,
purgeUserData
} = require('../../../utils/accountLifecycle')
const { PUBLIC_POST_STATUSES } = require('../../../utils/moderationStatus')
const { APP_LANGUAGE_CODES } = require('../../../utils/appLanguages')
// middlewares/auth.js فقط توکن رو معتبرسنجی می‌کنه و next() صدا می‌زنه — req.user رو ست نمی‌کنه،
// پس باید توکن رو مستقیم دیکد کنیم (همون الگویی که notificationController.js استفاده می‌کنه)
function getUserIdFromToken (req) {
const authHeader = req.header('Authorization')
const token = authHeader?.split(' ')[1]
if (!token) return null
try {
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
return decodedToken.id
} catch {
return null
}
}
const deactivateAccount = async (req, res) => {
try {
const userId = req.user._id
const { user_name } = req.body
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (String(user.user_name).toLowerCase() !== String(user_name || '').trim().toLowerCase()) {
return res.status(422).json({ message: 'نام کاربری صحیح نیست' })
}
if (isDeactivated(user)) {
return res.status(400).json({ message: 'حساب کاربری قبلاً غیرفعال شده است' })
}
const now = new Date()
user.account_status = 'deactivated'
user.deactivated_at = now
user.reactivation_locked_until = new Date(now.getTime() + DEACTIVATION_LOCK_DAYS * MS_DAY)
user.scheduled_purge_at = new Date(now.getTime() + DEACTIVATION_PURGE_MONTHS * 30 * MS_DAY)
await user.save()
return res.status(200).json({
message: 'حساب کاربری غیرفعال شد',
reactivation_locked_until: user.reactivation_locked_until,
scheduled_purge_at: user.scheduled_purge_at
})
} catch (err) {
console.error('deactivateAccount:', err)
return res.status(500).json({ message: 'خطا در غیرفعال‌سازی حساب' })
}
}
const reactivateAccount = async (req, res) => {
try {
const userId = req.user._id
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (!isDeactivated(user)) {
return res.status(400).json({ message: 'حساب کاربری فعال است' })
}
if (user.reactivation_locked_until && new Date() < new Date(user.reactivation_locked_until)) {
return res.status(403).json({
message: 'تا پایان دوره ۷ روزه امکان فعال‌سازی مجدد وجود ندارد',
reactivation_locked_until: user.reactivation_locked_until
})
}
user.account_status = 'active'
user.deactivated_at = null
user.reactivation_locked_until = null
user.scheduled_purge_at = null
await user.save()
return res.status(200).json({ message: 'حساب کاربری فعال شد' })
} catch (err) {
console.error('reactivateAccount:', err)
return res.status(500).json({ message: 'خطا در فعال‌سازی حساب' })
}
}
const deleteAccount = async (req, res) => {
try {
const userId = req.user._id
const { user_name } = req.body
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (String(user.user_name).toLowerCase() !== String(user_name || '').trim().toLowerCase()) {
return res.status(422).json({ message: 'نام کاربری صحیح نیست' })
}
const { mobile, user_name: uname } = user
await createDeletionTombstone({ mobile, userName: uname })
await purgeUserData(userId)
await UserModel.deleteOne({ _id: userId })
return res.status(200).json({ message: 'حساب کاربری حذف شد' })
} catch (err) {
console.error('deleteAccount:', err)
return res.status(500).json({ message: 'خطا در حذف حساب' })
}
}
const getAccountStatus = async (req, res) => {
try {
const user = await UserModel.findById(req.user._id)
.select('account_status deactivated_at reactivation_locked_until scheduled_purge_at allow_save_posts ghost_mode show_location post_location_enabled is_private gift_enabled offer_payment_enabled preferred_language user_name blocked_by')
.lean()
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
const { blocked_by, ...userWithoutBlockedBy } = user
return res.status(200).json({
account: {
...userWithoutBlockedBy,
allow_save_posts: user.allow_save_posts !== false,
ghost_mode: Boolean(user.ghost_mode),
show_location: user.show_location === true,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
gift_enabled: user.gift_enabled !== false,
offer_payment_enabled: user.offer_payment_enabled !== false,
preferred_language: user.preferred_language || 'fa',
// «کاربرانی که شما را مسدود کرده‌اند» — blocked_by یه فیلد denormalized روی خودِ
// کاربره (هر بار block/unblock سینک می‌شه)، پس فقط طولش کافیه، نیازی به کوئری
// جدا روی کل کالکشن کاربرها نیست
blocked_by_count: Array.isArray(blocked_by) ? blocked_by.length : 0,
},
})
} catch (err) {
return res.status(500).json({ message: 'خطا در دریافت وضعیت حساب' })
}
}
const updatePrivacySettings = async (req, res) => {
try {
const user = await UserModel.findById(req.user._id)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (typeof req.body.allow_save_posts === 'boolean') {
user.allow_save_posts = req.body.allow_save_posts
}
if (typeof req.body.ghost_mode === 'boolean') {
user.ghost_mode = req.body.ghost_mode
}
if (typeof req.body.show_location === 'boolean') {
user.show_location = req.body.show_location
}
if (typeof req.body.post_location_enabled === 'boolean') {
user.post_location_enabled = req.body.post_location_enabled
}
if (typeof req.body.is_private === 'boolean') {
user.is_private = req.body.is_private
}
if (typeof req.body.gift_enabled === 'boolean') {
user.gift_enabled = req.body.gift_enabled
}
if (typeof req.body.offer_payment_enabled === 'boolean') {
user.offer_payment_enabled = req.body.offer_payment_enabled
}
if (APP_LANGUAGE_CODES.includes(req.body.preferred_language)) {
user.preferred_language = req.body.preferred_language
}
await user.save()
return res.status(200).json({
allow_save_posts: user.allow_save_posts !== false,
ghost_mode: Boolean(user.ghost_mode),
show_location: user.show_location === true,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
gift_enabled: user.gift_enabled !== false,
offer_payment_enabled: user.offer_payment_enabled !== false,
preferred_language: user.preferred_language || 'fa',
})
} catch (err) {
return res.status(500).json({ message: 'خطا در ذخیره تنظیمات' })
}
}
/**
* «ریست الگوریتم» — تمام سیگنال‌های رفتاریِ ذخیره‌شده‌ی این کاربر که فید شخصی‌سازی‌شده
* (utils/exploreAlgorithm.js) رو می‌سازن پاک می‌کنه: لاگ کامل تعامل‌هاش (بازدید/دویل/اسکیپ/
* بازدید پروفایل و...) که buildUserExploreSignals/buildUnifiedInterestScore ازش تغذیه
* می‌کنن، به‌علاوه‌ی ترتیب رتبه‌بندی‌شده‌ی کش‌شده‌ی درون‌حافظه‌ش (تا فید بعدی از صفر
* بازمحاسبه بشه، نه ادامه‌ی همون ترتیب قبلی). لایک/کامنت/دنبال‌کردن دست‌نخورده می‌مونن —
* اون‌ها محتوای صریح خودِ کاربرن، نه «حالت الگوریتم»
*/
const resetAlgorithm = async (req, res) => {
try {
const userId = req.user._id
await ExploreInteractionModel.deleteMany({ viewerId: userId })
const { resetUserFeedCache } = require('../../../utils/exploreAlgorithm')
resetUserFeedCache(String(userId))
return res.status(200).json({ message: 'الگوریتم بازنشانی شد' })
} catch (err) {
console.error('resetAlgorithm:', err)
return res.status(500).json({ message: 'خطا در بازنشانی الگوریتم' })
}
}
/** Caller's own country/province/city selection (the "Region" picker) — a denormalized
* snapshot, same pattern as the existing province/city fields, not a live FK join. */
const updateUserRegion = async (req, res) => {
try {
const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity')
const user = await UserModel.findById(req.user._id)
if (!user) return res.status(404).json({ message: 'کاربر یافت نشد' })
const { country_id, province_id, city_id } = req.body
const [country, province, city] = await Promise.all([
country_id ? CountryModel.findOne({ id: Number(country_id) }).lean() : null,
province_id ? ProvinceModel.findOne({ id: Number(province_id) }).lean() : null,
city_id ? CityModel.findOne({ id: Number(city_id) }).lean() : null
])
if (country_id && !country) return res.status(422).json({ message: 'کشور نامعتبر است' })
if (province_id && !province) return res.status(422).json({ message: 'استان نامعتبر است' })
if (city_id && !city) return res.status(422).json({ message: 'شهر نامعتبر است' })
user.country = country ? { id: country.id, name: country.name_fa, iso2: country.iso2, slug: country.slug } : null
user.province = province ? { id: province.id, name: province.name, slug: province.slug } : null
user.city = city ? { id: city.id, name: city.name, slug: city.slug } : null
await user.save()
return res.status(200).json({
country: user.country,
province: user.province,
city: user.city
})
} catch (err) {
return res.status(500).json({ message: 'خطا در ذخیره منطقه' })
}
}
const pinPost = async (req, res) => {
try {
const userId = getUserIdFromToken(req)
if (!userId) {
return res.status(401).json({ message: 'دسترسی غیرمجاز' })
}
const { postId } = req.body
if (!postId) {
return res.status(400).json({ message: 'شناسه پست الزامی است' })
}
const post = await PostModel.findById(postId).select('user_id status').lean()
if (
!post ||
String(post.user_id) !== String(userId) ||
!PUBLIC_POST_STATUSES.includes(post.status)
) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
const user = await UserModel.findById(userId)
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
const pinned = (user.pinned_post_ids || []).map(String)
// اگر قبلاً پین شده، آن‌پین کن (تاگل)
if (pinned.includes(String(postId))) {
user.pinned_post_ids = (user.pinned_post_ids || []).filter(
(id) => String(id) !== String(postId)
)
await user.save()
return res.status(200).json({
message: 'پین پست برداشته شد',
pinned_post_ids: user.pinned_post_ids,
unpinned: true,
})
}
if (pinned.length >= 3) {
return res.status(400).json({ message: 'حداکثر ۳ پست را می‌توانید پین کنید' })
}
user.pinned_post_ids = [postId, ...(user.pinned_post_ids || [])]
await user.save()
return res.status(200).json({
message: 'پست با موفقیت پین شد',
pinned_post_ids: user.pinned_post_ids,
unpinned: false,
})
} catch (err) {
console.error('pinPost:', err)
return res.status(500).json({ message: 'خطا در پین کردن پست' })
}
}
const toggleArchivePost = async (req, res) => {
try {
const userId = getUserIdFromToken(req)
if (!userId) {
return res.status(401).json({ message: 'دسترسی غیرمجاز' })
}
const { postId } = req.body
if (!postId) {
return res.status(400).json({ message: 'شناسه پست الزامی است' })
}
const post = await PostModel.findById(postId)
if (!post || String(post.user_id) !== String(userId)) {
return res.status(404).json({ message: 'پست یافت نشد' })
}
post.is_archived = !post.is_archived
post.archived_at = post.is_archived ? new Date() : null
await post.save()
return res.status(200).json({
message: post.is_archived ? 'پست بایگانی شد' : 'پست دوباره منتشر شد',
is_archived: post.is_archived,
})
} catch (err) {
console.error('toggleArchivePost:', err)
return res.status(500).json({ message: 'خطا در بایگانی پست' })
}
}
const getArchivedPosts = async (req, res) => {
try {
const userId = getUserIdFromToken(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) || 12))
const skip = (page - 1) * limit
const filter = { user_id: userId, is_archived: true }
const [posts, total] = await Promise.all([
PostModel.find(filter).sort({ archived_at: -1 }).skip(skip).limit(limit).lean(),
PostModel.countDocuments(filter),
])
return res.status(200).json({
posts,
total,
page,
totalPages: Math.ceil(total / limit) || 1,
})
} catch (err) {
console.error('getArchivedPosts:', err)
return res.status(500).json({ message: 'خطا در دریافت پست‌های بایگانی‌شده' })
}
}
const ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null }
const getAccountAnalytics = async (req, res) => {
try {
const userId = req.user._id
const range = ANALYTICS_RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '30d'
const days = ANALYTICS_RANGE_DAYS[range]
const since = days ? new Date(Date.now() - days * MS_DAY) : new Date(0)
const dayFormat = '%Y-%m-%d'
const myPostIds = await PostModel.find({ user_id: userId }).distinct('_id')
const [postsSeries, followersSeries, likesSeries, commentsSeries, totalPosts, totalFollowers, totalLikes, totalComments] = await Promise.all([
PostModel.aggregate([
{ $match: { user_id: new mongoose.Types.ObjectId(userId), createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
]),
UserFollowModel.aggregate([
{ $match: { followingId: new mongoose.Types.ObjectId(userId), createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
]),
myPostIds.length
? LikeModel.aggregate([
{ $match: { postId: { $in: myPostIds }, createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
])
: [],
myPostIds.length
? CommentModel.aggregate([
{ $match: { post: { $in: myPostIds }, comment_for: 'post', createdAt: { $gte: since } } },
{ $group: { _id: { $dateToString: { format: dayFormat, date: '$createdAt' } }, count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
])
: [],
PostModel.countDocuments({ user_id: userId }),
UserFollowModel.countDocuments({ followingId: userId }),
myPostIds.length ? LikeModel.countDocuments({ postId: { $in: myPostIds } }) : 0,
myPostIds.length ? CommentModel.countDocuments({ post: { $in: myPostIds }, comment_for: 'post' }) : 0,
])
const mergeMap = new Map()
const bump = (arr, key) => {
arr.forEach((item) => {
const entry = mergeMap.get(item._id) || { date: item._id, posts: 0, followers: 0, likes: 0, comments: 0 }
entry[key] = item.count
mergeMap.set(item._id, entry)
})
}
bump(postsSeries, 'posts')
bump(followersSeries, 'followers')
bump(likesSeries, 'likes')
bump(commentsSeries, 'comments')
const series = [...mergeMap.values()].sort((a, b) => (a.date < b.date ? -1 : 1))
return res.status(200).json({
range,
series,
totals: {
posts: totalPosts,
followers: totalFollowers,
likes: totalLikes,
comments: totalComments,
},
})
} catch (err) {
console.error('getAccountAnalytics:', err)
return res.status(500).json({ message: 'خطا در دریافت آنالیتیکس' })
}
}
const getProfileVisitors = async (req, res) => {
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 skip = (page - 1) * limit
const filter = {
targetType: 'profile',
action: 'profile_visit',
targetId: userId,
viewerId: { $ne: userId },
}
const resolveVisitDate = (visit) => {
if (visit.createdAt) return new Date(visit.createdAt)
if (visit.created_at) return new Date(visit.created_at)
if (visit._id && mongoose.Types.ObjectId.isValid(visit._id)) {
return new mongoose.Types.ObjectId(String(visit._id)).getTimestamp()
}
return null
}
const visits = await ExploreInteractionModel.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean()
const viewerIds = [...new Set(visits.map((v) => String(v.viewerId)))]
const viewers = await UserModel.find({ _id: { $in: viewerIds } })
.select('_id user_name first_name last_name profile_image account_status ghost_mode')
.lean()
const byId = Object.fromEntries(viewers.map((v) => [String(v._id), v]))
const list = visits
.map((v) => {
const u = byId[String(v.viewerId)] || {}
// حالت روح: بازدیدکننده‌ای که الان ghost_mode داره نباید توی لیست بازدیدکنندگان
// صاحب پروفایل دیده بشه — قبلاً این فیلد اصلاً select نمی‌شد، پس این چک هیچ‌وقت
// اجرا نمی‌شد و بازدیدهای کاربرهای روح‌حالت هم عادی نمایش داده می‌شدن
if (!u.user_name || u.account_status === 'deleted' || u.ghost_mode) return null
const visitDate = resolveVisitDate(v)
return {
_id: String(v.viewerId),
user_name: u.user_name || '',
first_name: u.first_name || '',
last_name: u.last_name || '',
profile_image: u.profile_image || '',
visited_at: visitDate ? visitDate.toISOString() : null,
visited_at_time: visitDate ? moment(visitDate).format('HH:mm') : null,
}
})
.filter(Boolean)
const total = await ExploreInteractionModel.countDocuments(filter)
return res.status(200).json({ visitors: list, total, page, totalPages: Math.ceil(total / limit) || 1 })
} catch (err) {
console.error('getProfileVisitors:', err)
return res.status(500).json({ message: 'خطا در دریافت بازدیدکنندگان' })
}
}
module.exports = {
deactivateAccount,
reactivateAccount,
deleteAccount,
getAccountStatus,
updatePrivacySettings,
resetAlgorithm,
updateUserRegion,
getProfileVisitors,
pinPost,
toggleArchivePost,
getArchivedPosts,
getAccountAnalytics,
}

View File

@@ -1,5 +1,7 @@
const UserModel = require('../../../models/UserModel')
const PostModel = require('../../../models/PostModel')
const jwt = require('jsonwebtoken')
const { getFollowCounts } = require('../../../utils/followHelpers')
// GET user by ID from URL
@@ -26,6 +28,7 @@ const getUserById = async (req, res, next) => {
// isRegister = false;
// }
// پروفایل عمومی دیگران — بدون داده حساس / بدون سند خام
const response = {
_id: user._id,
user_type: user.user_type,
@@ -34,7 +37,10 @@ const getUserById = async (req, res, next) => {
last_name: user.last_name,
user_name: user.user_name,
show_location: user.show_location,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
is_verified: user.is_verified,
verify_badge: user.verify_badge,
is_Register: user.is_Register,
user_score: user.user_score,
rate: user.rate,
@@ -45,21 +51,22 @@ const getUserById = async (req, res, next) => {
eye_color: user.eye_color,
hair_color: user.hair_color,
profile_image: user.profile_image,
lat: user.lat,
lng: user.lng,
address: user.address,
lat: user.show_location ? user.lat : null,
lng: user.show_location ? user.lng : null,
address: user.show_location ? user.address : null,
province: user.province,
city: user.city,
shaba: user.shaba,
national_code: user.national_code,
mobile: user.mobile,
cooperation_abroad: user.cooperation_abroad,
cooperation_type: user.cooperation_type,
conversation_projects: user.conversation_projects,
expertise: user.expertise,
sub_expertise: user.sub_expertise,
display_sub_expertise: user.display_sub_expertise,
auth_provider: ['google', 'instagram'].includes(user.auth_provider)
? user.auth_provider
: 'mobile',
e2ee_public_key: user.e2ee_public_key || null,
isRegister,
user,
services: service === "1" ? user.services : null
};
@@ -71,7 +78,9 @@ const getUserById = async (req, res, next) => {
const getProfile = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
const authHeader = req.header('Authorization')
if (!authHeader) return res.status(401).send('Access Denied')
const token = authHeader.split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const service = req.query.services
@@ -82,10 +91,9 @@ const getProfile = async (req, res, next) => {
let isRegister = true
if (!user.user_name ||
!user.first_name ||
!user.last_name ||
!user.user_type ||
!user.national_card_image ||
!user.password ||
!user.national_card_image ||
!user.national_code
) {
isRegister = false
@@ -93,15 +101,27 @@ const getProfile = async (req, res, next) => {
if (!user) {
return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' })
}
const postsCount = await PostModel.countDocuments({
user_id: user._id,
status: { $in: ['pending', 'accept'] }
})
const { followersCount, followingCount } = await getFollowCounts(user._id)
const blockedCount = user.blocked_users?.length || 0
const response = {
_id: user._id,
is_self: true,
user_type: user.user_type,
user_level: user.user_level,
first_name: user.first_name,
last_name: user.last_name,
user_name: user.user_name,
show_location: user.show_location,
post_location_enabled: user.post_location_enabled === true,
is_private: user.is_private === true,
is_verified: user.is_verified,
verify_badge: user.verify_badge,
is_Register: user.is_Register,
user_score: user.user_score,
rate: user.rate,
@@ -111,22 +131,47 @@ const getProfile = async (req, res, next) => {
size: user.size,
eye_color: user.eye_color,
hair_color: user.hair_color,
marital_status: user.marital_status,
residence_type: user.residence_type,
residence_area: user.residence_area,
vehicle_type: user.vehicle_type,
vehicle_details: user.vehicle_details,
personal_style: user.personal_style,
job: user.job,
income_range: user.income_range,
moral_traits: user.moral_traits,
personal_details_public: user.personal_details_public === true,
profile_image: user.profile_image,
lat: user.lat,
lng: user.lng,
address: user.address,
country: user.country,
province: user.province,
city: user.city,
shaba: user.shaba,
national_code: user.national_code,
birthday: user.birthday,
mobile: user.mobile,
email: user.email,
auth_provider: user.auth_provider || 'mobile',
cooperation_abroad: user.cooperation_abroad,
cooperation_type: user.cooperation_type,
conversation_projects: user.conversation_projects,
expertise: user.expertise,
sub_expertise: user.sub_expertise,
display_sub_expertise: user.display_sub_expertise,
isRegister,
services: service === '1' ? user.services : null
services: service === '1' ? user.services : null,
account_status: user.account_status || 'active',
deactivated_at: user.deactivated_at,
reactivation_locked_until: user.reactivation_locked_until,
scheduled_purge_at: user.scheduled_purge_at,
allow_save_posts: user.allow_save_posts !== false,
ghost_mode: Boolean(user.ghost_mode),
postsCount,
followersCount,
followingCount,
blockedCount
}
return res.status(200).json({ user: response })
} catch (error) {