full
This commit is contained in:
112
controllers/application/users/followController.js
Normal file
112
controllers/application/users/followController.js
Normal 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
|
||||
}
|
||||
130
controllers/application/users/nearbyUsersController.js
Normal file
130
controllers/application/users/nearbyUsersController.js
Normal 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
|
||||
}
|
||||
246
controllers/application/users/partnerSearchController.js
Normal file
246
controllers/application/users/partnerSearchController.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user