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,112 @@
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const UserFollowModel = require('../../../models/UserFollowModel')
const UserModel = require('../../../models/UserModel')
const { getFollowCounts, mapFollowUsers } = require('../../../utils/followHelpers')
function getOptionalUserId (req) {
try {
const token = req.header('Authorization')?.split(' ')[1]
if (!token) return null
const decoded = jwt.verify(token, process.env.APP_SECRET)
return decoded?.id || null
} catch {
return null
}
}
const getFollowStats = async (req, res) => {
try {
const userId = req.query.userId || req.user?._id
if (!userId || !mongoose.Types.ObjectId.isValid(String(userId))) {
return res.status(422).json({ message: 'شناسه کاربر الزامی است' })
}
const counts = await getFollowCounts(userId)
const user = await UserModel.findById(userId).select('blocked_users').lean()
const blockedCount = user?.blocked_users?.length || 0
return res.status(200).json({ ...counts, blockedCount })
} catch (err) {
console.error('getFollowStats:', err)
return res.status(500).json({ message: 'خطا در دریافت آمار' })
}
}
const getFollowers = async (req, res) => {
try {
const userId = req.query.userId
if (!userId || !mongoose.Types.ObjectId.isValid(String(userId))) {
return res.status(422).json({ message: 'شناسه کاربر الزامی است' })
}
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 total = await UserFollowModel.countDocuments({ followingId: userId })
const rows = await UserFollowModel.find({ followingId: userId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean()
const users = await mapFollowUsers(rows.map((r) => r.followerId), getOptionalUserId(req))
return res.status(200).json({
users,
total,
page,
totalPages: Math.ceil(total / limit) || 1
})
} catch (err) {
console.error('getFollowers:', err)
return res.status(500).json({ message: 'خطا در دریافت دنبال‌کنندگان' })
}
}
const getFollowing = async (req, res) => {
try {
const userId = req.query.userId
if (!userId || !mongoose.Types.ObjectId.isValid(String(userId))) {
return res.status(422).json({ message: 'شناسه کاربر الزامی است' })
}
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 total = await UserFollowModel.countDocuments({ followerId: userId })
const rows = await UserFollowModel.find({ followerId: userId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean()
const users = await mapFollowUsers(rows.map((r) => r.followingId), getOptionalUserId(req))
return res.status(200).json({
users,
total,
page,
totalPages: Math.ceil(total / limit) || 1
})
} catch (err) {
console.error('getFollowing:', err)
return res.status(500).json({ message: 'خطا در دریافت دنبال‌شوندگان' })
}
}
const getBlockedUsers = async (req, res) => {
try {
const userId = req.user._id
const user = await UserModel.findById(userId).select('blocked_users').lean()
const ids = user?.blocked_users || []
const users = await mapFollowUsers(ids)
return res.status(200).json({ users, total: users.length })
} catch (err) {
console.error('getBlockedUsers:', err)
return res.status(500).json({ message: 'خطا در دریافت لیست مسدود شده‌ها' })
}
}
module.exports = {
getFollowStats,
getFollowers,
getFollowing,
getBlockedUsers
}

View File

@@ -0,0 +1,130 @@
const UserModel = require('../../../models/UserModel')
const {
viewerBlockedUser,
userBlockedViewer,
getBlockerUserIds
} = require('../../../utils/blockVisibility')
const NEARBY_RADIUS_METERS = 5000
function haversineMeters(lat1, lon1, lat2, lon2) {
const toRad = (deg) => (deg * Math.PI) / 180
const dLat = toRad(lat2 - lat1)
const dLon = toRad(lon2 - lon1)
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
function parseCoord(value) {
const n = parseFloat(value)
return Number.isFinite(n) ? n : null
}
/**
* GET /users/nearby?lat=&lng=
* Requires auth + show_location + valid coords.
* Updates requester lat/lng, returns nearby users (no exact peer coords).
*/
const getNearbyUsers = async (req, res, next) => {
try {
const userId = String(req.user._id)
const lat = parseCoord(req.query.lat)
const lng = parseCoord(req.query.lng)
if (lat == null || lng == null) {
return res.status(422).json({
code: 'LOCATION_REQUIRED',
message: 'موقعیت مکانی شما هنوز دریافت نشده است'
})
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return res.status(422).json({
code: 'LOCATION_INVALID',
message: 'مختصات موقعیت نامعتبر است'
})
}
const me = await UserModel.findById(userId).select(
'show_location lat lng blocked_users account_status'
)
if (!me) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (me.show_location !== true) {
return res.status(403).json({
code: 'LOCATION_DISABLED',
message: 'برای دیدن کاربران نزدیک، نمایش موقعیت مکانی را در تنظیمات فعال کنید'
})
}
me.lat = String(lat)
me.lng = String(lng)
await me.save()
const blockerIds = await getBlockerUserIds(UserModel, userId)
const blockedByMe = new Set(
(me.blocked_users || []).map((id) => String(id))
)
const blockedMe = new Set((blockerIds || []).map((id) => String(id)))
const candidates = await UserModel.find({
_id: { $ne: userId },
show_location: true,
account_status: { $ne: 'deactivated' },
lat: { $nin: [null, ''] },
lng: { $nin: [null, ''] },
user_name: { $ne: null }
})
.select(
'user_name first_name last_name profile_image is_verified verify_badge is_Register lat lng blocked_users'
)
.lean()
const nearby = []
for (const user of candidates) {
const uid = String(user._id)
if (blockedByMe.has(uid) || blockedMe.has(uid)) continue
if (viewerBlockedUser(user, userId) || userBlockedViewer(user, userId)) {
continue
}
const peerLat = parseCoord(user.lat)
const peerLng = parseCoord(user.lng)
if (peerLat == null || peerLng == null) continue
const distanceMeters = haversineMeters(lat, lng, peerLat, peerLng)
if (distanceMeters > NEARBY_RADIUS_METERS) continue
nearby.push({
_id: uid,
user_name: user.user_name,
first_name: user.first_name,
last_name: user.last_name,
profile_image: user.profile_image,
is_verified: user.is_verified,
verify_badge: user.verify_badge,
is_Register: user.is_Register,
distanceMeters: Math.round(distanceMeters)
})
}
nearby.sort((a, b) => a.distanceMeters - b.distanceMeters)
res.json({
radiusMeters: NEARBY_RADIUS_METERS,
users: nearby
})
} catch (error) {
next(error)
}
}
module.exports = {
getNearbyUsers,
NEARBY_RADIUS_METERS
}

View File

@@ -0,0 +1,246 @@
const UserModel = require('../../../models/UserModel')
const StoryModel = require('../../../models/StoryModel')
const PartnerSearchAlertModel = require('../../../models/PartnerSearchAlertModel')
const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity')
const { buildAppearanceUserFilter, parseOptionalNum } = require('../../../utils/appearanceFilter')
const {
viewerBlockedUser,
userBlockedViewer,
getBlockerUserIds
} = require('../../../utils/blockVisibility')
function escapeRegex (value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** $expr clauses for an age range, computed from `birthday` — mirrors
* utils/partnerMatch.js's ageFromBirthday so live search and later alert
* matching agree on the same age. */
function ageRangeExprClauses (ageMin, ageMax) {
const clauses = []
const now = Date.now()
const YEAR_MS = 365.25 * 24 * 60 * 60 * 1000
if (ageMin != null) {
clauses.push({ $lte: ['$birthday', new Date(now - ageMin * YEAR_MS)] })
}
if (ageMax != null) {
clauses.push({ $gte: ['$birthday', new Date(now - (ageMax + 1) * YEAR_MS)] })
}
return clauses
}
/** Turns raw query params into the persisted alert-criteria shape consumed
* by utils/partnerMatch.js's matchesCriteria — used both for the immediate
* search and when a search is saved as an alert, so both always agree. */
async function resolveCriteriaFromQuery (query) {
const criteria = {}
const {
gender, ageMin, ageMax,
heightMin, heightMax, weightMin, weightMax, sizeMin, sizeMax,
hair_color, eye_color,
marital_status, residence_type, vehicle_type, personal_style,
income_range, job, country, province, city
} = query || {}
if (gender) criteria.gender = gender
const aMin = parseOptionalNum(ageMin); if (aMin != null) criteria.ageMin = aMin
const aMax = parseOptionalNum(ageMax); if (aMax != null) criteria.ageMax = aMax
const hMin = parseOptionalNum(heightMin); if (hMin != null) criteria.heightMin = hMin
const hMax = parseOptionalNum(heightMax); if (hMax != null) criteria.heightMax = hMax
const wMin = parseOptionalNum(weightMin); if (wMin != null) criteria.weightMin = wMin
const wMax = parseOptionalNum(weightMax); if (wMax != null) criteria.weightMax = wMax
const sMin = parseOptionalNum(sizeMin); if (sMin != null) criteria.sizeMin = sMin
const sMax = parseOptionalNum(sizeMax); if (sMax != null) criteria.sizeMax = sMax
if (hair_color) criteria.hair_color = hair_color
if (eye_color) criteria.eye_color = eye_color
if (marital_status) criteria.marital_status = marital_status
if (residence_type) criteria.residence_type = residence_type
if (vehicle_type) criteria.vehicle_type = vehicle_type
if (personal_style) criteria.personal_style = personal_style
if (income_range) criteria.income_range = income_range
if (job) criteria.job = String(job).trim()
if (country) {
const countryFind = await CountryModel.findOne({ id: country }, { _id: 0 })
if (countryFind) criteria.country_id = countryFind.id
}
if (province) {
const provinceFind = await ProvinceModel.findOne({ id: province }, { _id: 0 })
if (provinceFind) criteria.province_id = provinceFind.id
}
if (city) {
const cityFind = await CityModel.findOne({ id: city })
if (cityFind) criteria.city_id = cityFind.id
}
return criteria
}
/** Live Mongo filter equivalent of the same `criteria` shape — kept
* separate from matchesCriteria (which runs as a JS predicate against a
* single already-loaded document) since this one needs to run as an actual
* query across the whole users collection. */
function criteriaToMongoFilter (criteria) {
const filter = buildAppearanceUserFilter({
hair_color: criteria.hair_color,
eye_color: criteria.eye_color,
heightMin: criteria.heightMin,
heightMax: criteria.heightMax,
weightMin: criteria.weightMin,
weightMax: criteria.weightMax,
sizeMin: criteria.sizeMin,
sizeMax: criteria.sizeMax
})
if (criteria.gender) filter.gender = criteria.gender
if (criteria.marital_status) filter.marital_status = criteria.marital_status
if (criteria.residence_type) filter.residence_type = criteria.residence_type
if (criteria.vehicle_type) filter.vehicle_type = criteria.vehicle_type
if (criteria.personal_style) filter.personal_style = criteria.personal_style
if (criteria.income_range) filter.income_range = criteria.income_range
if (criteria.job) filter.job = { $regex: escapeRegex(criteria.job), $options: 'i' }
if (criteria.country_id) filter['country.id'] = criteria.country_id
if (criteria.province_id) filter['province.id'] = criteria.province_id
if (criteria.city_id) filter['city.id'] = criteria.city_id
const ageExprs = ageRangeExprClauses(criteria.ageMin, criteria.ageMax)
if (ageExprs.length) {
const ageExpr = ageExprs.length === 1 ? ageExprs[0] : { $and: ageExprs }
filter.$expr = filter.$expr ? { $and: [filter.$expr, ageExpr] } : ageExpr
}
return filter
}
const PARTNER_RESULT_FIELDS =
'_id user_name first_name last_name profile_image is_verified verify_badge is_Register ' +
'gender height weight size eye_color hair_color marital_status residence_type residence_area ' +
'vehicle_type vehicle_details personal_style job income_range moral_traits birthday country province city blocked_users'
/** GET /users/partner-search — filters as query params (see resolveCriteriaFromQuery).
* Personal-details fields are included in the response ONLY here, by design
* (never on the public profile endpoint). */
const searchPartners = async (req, res, next) => {
try {
const userId = String(req.user._id)
const criteria = await resolveCriteriaFromQuery(req.query)
const filter = criteriaToMongoFilter(criteria)
filter._id = { $ne: userId }
filter.account_status = { $ne: 'deactivated' }
filter.user_name = { $ne: null }
const candidates = await UserModel.find(filter)
.select(PARTNER_RESULT_FIELDS)
.lean()
const blockerIds = new Set((await getBlockerUserIds(UserModel, userId)).map(String))
const me = await UserModel.findById(userId).select('blocked_users')
const blockedByMe = new Set((me?.blocked_users || []).map(String))
const filtered = candidates.filter((user) => {
const uid = String(user._id)
if (blockedByMe.has(uid) || blockerIds.has(uid)) return false
if (viewerBlockedUser(user, userId) || userBlockedViewer(user, userId)) return false
return true
})
// استوری فعال نتایج — برای نمایش حلقه‌ی استوری دور آواتار توی لیست یافتن همکار
const resultIds = filtered.map((user) => user._id)
const activeStories = await StoryModel.find({
user_id: { $in: resultIds },
expires_at: { $gt: new Date() }
})
.select('user_id viewers')
.lean()
const storiesByAuthor = {}
activeStories.forEach((s) => {
const key = String(s.user_id)
if (!storiesByAuthor[key]) storiesByAuthor[key] = []
storiesByAuthor[key].push(s)
})
const hasStorySet = new Set(Object.keys(storiesByAuthor))
const unviewedStorySet = new Set(
Object.keys(storiesByAuthor).filter((authorId) =>
storiesByAuthor[authorId].some(
(s) => !s.viewers?.some((v) => String(v.user_id) === String(userId))
)
)
)
const results = filtered.map((user) => ({
...user,
blocked_users: undefined,
has_story: hasStorySet.has(String(user._id)),
has_unviewed_story: unviewedStorySet.has(String(user._id))
}))
res.json({ users: results, criteria })
} catch (error) {
next(error)
}
}
/** GET /partner-search/alerts — the requester's own active saved search, if any. */
const getMyPartnerAlert = async (req, res, next) => {
try {
const alert = await PartnerSearchAlertModel.findOne({
user_id: req.user._id,
active: true
}).lean()
res.json({ alert: alert || null })
} catch (error) {
next(error)
}
}
/** POST /partner-search/alerts — saves the current filter as a standing
* alert. Replaces any existing active alert (one active alert per user). */
const createPartnerAlert = async (req, res, next) => {
try {
const criteria = await resolveCriteriaFromQuery(req.body)
await PartnerSearchAlertModel.updateMany(
{ user_id: req.user._id, active: true },
{ $set: { active: false } }
)
const alert = await PartnerSearchAlertModel.create({
user_id: req.user._id,
criteria,
active: true
})
res.status(201).json({ alert })
} catch (error) {
next(error)
}
}
/** DELETE /partner-search/alerts/:id */
const deletePartnerAlert = async (req, res, next) => {
try {
const alert = await PartnerSearchAlertModel.findOne({
_id: req.params.id,
user_id: req.user._id
})
if (!alert) {
return res.status(404).json({ message: 'جستجوی ذخیره‌شده یافت نشد' })
}
alert.active = false
await alert.save()
res.json({ message: 'جستجوی ذخیره‌شده لغو شد' })
} catch (error) {
next(error)
}
}
module.exports = {
searchPartners,
getMyPartnerAlert,
createPartnerAlert,
deletePartnerAlert
}