4.1.3
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
const mongoose = require('mongoose')
|
||||
const crypto = require('crypto')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const DeviceLinkSessionModel = require('../../../models/DeviceLinkSessionModel')
|
||||
|
||||
@@ -152,11 +153,98 @@ const pollDeviceLink = async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* رمزِ خودکارِ per-account برای رمزنگاری/بازگشاییِ خودکارِ بکآپِ کلیدِ E2EE — بهجای
|
||||
* PIN دستیِ کاربر. با یه درخواستِ احرازهویتشده به همین اکانت (از هر دستگاهی) قابل
|
||||
* دریافته، پس بازیابیِ هویت روی دستگاهِ جدید بدون هیچ دخالتِ کاربری انجام میشه.
|
||||
* اولینبار که خواسته بشه lazily ساخته میشه؛ با یه آپدیتِ شرطی (فقط اگه هنوز null
|
||||
* باشه) از race بینِ دو دستگاهی که همزمان اولینبار درخواستش میکنن جلوگیری میشه.
|
||||
*/
|
||||
const getOrCreateAutoBackupSecret = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user._id
|
||||
const existing = await UserModel.findById(userId).select('e2ee_auto_backup_secret').lean()
|
||||
if (existing?.e2ee_auto_backup_secret) {
|
||||
return res.json({ secret: existing.e2ee_auto_backup_secret })
|
||||
}
|
||||
|
||||
const candidate = crypto.randomBytes(32).toString('base64')
|
||||
const updated = await UserModel.findOneAndUpdate(
|
||||
{ _id: userId, e2ee_auto_backup_secret: null },
|
||||
{ $set: { e2ee_auto_backup_secret: candidate } },
|
||||
{ new: true }
|
||||
).select('e2ee_auto_backup_secret').lean()
|
||||
|
||||
// اگه updated خالی برگشت یعنی یه درخواستِ همزمانِ دیگه (دستگاهِ دیگه) بین
|
||||
// خوندنِ بالا و همین آپدیت، خودش رمز رو ساخته بود — بخونش تا هر دو دستگاه دقیقاً
|
||||
// یه رمزِ یکسان بگیرن، نه دوتا رمزِ متفاوت
|
||||
const secret = updated?.e2ee_auto_backup_secret ||
|
||||
(await UserModel.findById(userId).select('e2ee_auto_backup_secret').lean())?.e2ee_auto_backup_secret
|
||||
|
||||
res.json({ secret })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ذخیرهی بکآپِ کلیدِ خصوصیِ رمزنگاریشده با PIN/رمزِ انتخابیِ کاربر. سرور فقط
|
||||
* ciphertext/salt/iv رو میبینه — نه خودِ PIN و نه کلیدِ خام. کاربر میتونه صدا بزنه
|
||||
* تا بکآپ رو بسازه یا (با یه ciphertext جدید) رمزش رو عوض کنه.
|
||||
*/
|
||||
const putKeyBackup = async (req, res, next) => {
|
||||
try {
|
||||
const { salt, iv, ciphertext, iterations } = req.body
|
||||
if (
|
||||
!salt || typeof salt !== 'string' ||
|
||||
!iv || typeof iv !== 'string' ||
|
||||
!ciphertext || typeof ciphertext !== 'string' ||
|
||||
typeof iterations !== 'number' || iterations < 100000
|
||||
) {
|
||||
return res.status(422).json({ error: 'invalid backup payload' })
|
||||
}
|
||||
if (ciphertext.length > 8192 || salt.length > 512 || iv.length > 512) {
|
||||
return res.status(422).json({ error: 'backup payload too large' })
|
||||
}
|
||||
|
||||
await UserModel.findByIdAndUpdate(req.user._id, {
|
||||
e2ee_key_backup: { salt, iv, ciphertext, iterations }
|
||||
})
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** فقط خودِ کاربر میتونه بکآپِ خودش رو بخونه — برای بازیابی روی یه دستگاه جدید. */
|
||||
const getKeyBackup = async (req, res, next) => {
|
||||
try {
|
||||
const user = await UserModel.findById(req.user._id).select('e2ee_key_backup').lean()
|
||||
res.json({ backup: user?.e2ee_key_backup || null })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteKeyBackup = async (req, res, next) => {
|
||||
try {
|
||||
await UserModel.findByIdAndUpdate(req.user._id, { e2ee_key_backup: null })
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
putMyPublicKey,
|
||||
getPublicKeyByUserId,
|
||||
startDeviceLink,
|
||||
getDeviceLinkSession,
|
||||
approveDeviceLink,
|
||||
pollDeviceLink
|
||||
pollDeviceLink,
|
||||
getOrCreateAutoBackupSecret,
|
||||
putKeyBackup,
|
||||
getKeyBackup,
|
||||
deleteKeyBackup
|
||||
}
|
||||
|
||||
59
controllers/application/analytics/analyticsController.js
Normal file
59
controllers/application/analytics/analyticsController.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
const AnalyticsEvent = require('../../../models/AnalyticsEventModel')
|
||||
|
||||
const MAX_BATCH_SIZE = 50
|
||||
const VALID_TYPES = new Set(['session_start', 'page_view', 'click'])
|
||||
const VALID_PLATFORMS = new Set(['web', 'android', 'ios'])
|
||||
|
||||
function decodeUserId(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 || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ورودیِ خام و کوتاه (بدون توکن الزامی — کاربر مهمون هم ردیابی میشه)؛ از هر دو کلاینت
|
||||
// وب (sendBeacon موقع بستن تب) و اپلیکیشن اندروید/iOS بهصورت دستهای ارسال میشه
|
||||
const trackEvents = async (req, res, next) => {
|
||||
try {
|
||||
const userId = decodeUserId(req)
|
||||
const events = Array.isArray(req.body?.events) ? req.body.events : []
|
||||
if (!events.length) return res.status(204).end()
|
||||
|
||||
const docs = events.slice(0, MAX_BATCH_SIZE).reduce((acc, e) => {
|
||||
if (!e || !VALID_TYPES.has(e.type) || !VALID_PLATFORMS.has(e.platform)) {
|
||||
return acc
|
||||
}
|
||||
if (!e.sessionId || typeof e.sessionId !== 'string') return acc
|
||||
|
||||
acc.push({
|
||||
userId,
|
||||
sessionId: e.sessionId,
|
||||
type: e.type,
|
||||
path: typeof e.path === 'string' ? e.path.slice(0, 500) : null,
|
||||
durationMs:
|
||||
typeof e.durationMs === 'number' && e.durationMs >= 0
|
||||
? Math.min(e.durationMs, 24 * 60 * 60 * 1000)
|
||||
: null,
|
||||
label: typeof e.label === 'string' ? e.label.slice(0, 200) : null,
|
||||
platform: e.platform,
|
||||
device: typeof e.device === 'string' ? e.device.slice(0, 100) : null,
|
||||
browser: typeof e.browser === 'string' ? e.browser.slice(0, 100) : null,
|
||||
os: typeof e.os === 'string' ? e.os.slice(0, 100) : null
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
if (docs.length) await AnalyticsEvent.insertMany(docs, { ordered: false })
|
||||
|
||||
res.status(204).end()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { trackEvents }
|
||||
@@ -645,7 +645,33 @@ const putRoomE2eeKeys = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof e2ee_enabled === 'boolean') {
|
||||
// فعالسازیِ اولیهی E2EE روم باید atomic باشه — بدون این گارد، اگه دو عضو تقریباً
|
||||
// همزمان (هرکدوم با یه کلیدِ تصادفیِ متفاوت) اولینبار بخوان روم رو فعال کنن، هر دو
|
||||
// findById/save رو با موفقیت رد میکردن و ذخیرهی دومی wrap های اولی رو override
|
||||
// میکرد — نتیجه یه ناهمخوانیِ دائمیِ کلید بین اعضا بود؛ دقیقاً همون باگی که باعث
|
||||
// شد E2EE رومها موقتاً بهطور کامل غیرفعال بشه بهجای رفعِ ریشهای
|
||||
if (e2ee_enabled === true && !room.e2ee_enabled) {
|
||||
const claimed = await ChatRoomModel.findOneAndUpdate(
|
||||
{ _id: roomId, e2ee_enabled: { $ne: true } },
|
||||
{ $set: { e2ee_enabled: true } },
|
||||
{ new: true }
|
||||
)
|
||||
if (!claimed) {
|
||||
// یکی دیگه همزمان برنده شد — این درخواست نباید wrapped_keys خودش (که با
|
||||
// کلیدِ بازنده ساخته شده) رو ثبت کنه؛ کلاینت باید myWrappedKey تازه (متعلق به
|
||||
// کلیدِ برنده) رو بخونه و کلیدِ محلیِ خودش رو دور بریزه
|
||||
const fresh = await ChatRoomModel.findById(roomId)
|
||||
const freshWraps = Array.isArray(fresh?.e2ee_wrapped_keys) ? fresh.e2ee_wrapped_keys : []
|
||||
const myFreshWrap = freshWraps.find((w) => String(w.userId) === userId)
|
||||
return res.status(409).json({
|
||||
error: 'room e2ee already enabled by another member',
|
||||
code: 'E2EE_ALREADY_ENABLED',
|
||||
e2ee_enabled: true,
|
||||
myWrappedKey: myFreshWrap?.wrappedKey || null
|
||||
})
|
||||
}
|
||||
room.e2ee_enabled = true
|
||||
} else if (typeof e2ee_enabled === 'boolean') {
|
||||
room.e2ee_enabled = e2ee_enabled
|
||||
}
|
||||
|
||||
|
||||
@@ -980,7 +980,7 @@ const handleOfferPaymentCallback = async (req, res) => {
|
||||
const offerPriceToman = amount / 10
|
||||
const receiverShare = Math.round(offerPriceToman * OFFER_COMMISSION_RATE)
|
||||
|
||||
// سهم گیرنده فقط با پاسخگویی ظرف ۲۴ ساعت به اولین پیام دایرکت واریز میشود
|
||||
// سهم گیرنده فقط با پاسخگویی ظرف ۱۲ ساعت به اولین پیام دایرکت واریز میشود
|
||||
// (نگاه کنید به trackOfferMessageForCommission که موقع ارسال پیام صدا زده میشود)
|
||||
const offer = await OfferModel.create({
|
||||
sender: userId,
|
||||
@@ -989,10 +989,18 @@ const handleOfferPaymentCallback = async (req, res) => {
|
||||
pendingCommissionAmount: receiverShare
|
||||
})
|
||||
|
||||
// صفحه/دیپلینکِ موفقیت برای دکمهی «ارسال پیام» به شناسه (و نامکاربریِ) گیرنده
|
||||
// نیاز داره — قبلاً فقط offerId فرستاده میشد و صفحهی وب دنبال یک پارامتر userId
|
||||
// (که هیچوقت ست نمیشد) میگشت، پس همیشه «کاربر یافت نشد» میداد
|
||||
const receiverUser = await UserModel.findById(receiverId).select('user_name')
|
||||
const receiverUsername = receiverUser?.user_name || ''
|
||||
|
||||
return isAndroid
|
||||
? res.redirect(`modstagram://SuccessPay?type=offer&offerId=${offer._id}`)
|
||||
? res.redirect(
|
||||
`modstagram://SuccessPay?type=offer&offerId=${offer._id}&userId=${receiverId}&username=${encodeURIComponent(receiverUsername)}`
|
||||
)
|
||||
: res.redirect(
|
||||
`${process.env.APP_SITE}/offer/payment/success?offerId=${offer._id}`
|
||||
`${process.env.APP_SITE}/offer/payment/success?offerId=${offer._id}&userId=${receiverId}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -139,8 +139,18 @@ const getProfile = async (req, res, next) => {
|
||||
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,
|
||||
relationship_goal: user.relationship_goal,
|
||||
seeking_gender: user.seeking_gender,
|
||||
desired_age_range: user.desired_age_range,
|
||||
personality_traits: user.personality_traits,
|
||||
lifestyle: user.lifestyle,
|
||||
interests: user.interests,
|
||||
values: user.values,
|
||||
relationship_view: user.relationship_view,
|
||||
geo_preference: user.geo_preference,
|
||||
children_status: user.children_status,
|
||||
smoking_status: user.smoking_status,
|
||||
profile_image: user.profile_image,
|
||||
lat: user.lat,
|
||||
lng: user.lng,
|
||||
|
||||
@@ -13,8 +13,10 @@ const { filterStoryUserIdsByPrivateProfile } = require('../../../utils/privatePr
|
||||
const { sendStoryReactionMessage } = require('../../../utils/storyChatMessage')
|
||||
const { enrichMessage, emitChatEvents } = require('../../../helpers/chatHelpers')
|
||||
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
|
||||
const { STORY_PHOTO_FILTER_NAMES, applyStoryPhotoFilter } = require('../../../utils/storyPhotoFilters')
|
||||
|
||||
const LINK_OVERLAY_KINDS = ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking']
|
||||
const OVERLAY_FONT_FAMILIES = ['light', 'regular', 'medium', 'bold', 'black']
|
||||
|
||||
const STORY_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -71,12 +73,81 @@ async function sanitizeOverlays (raw, userId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (o.type === 'mention') {
|
||||
if (!o.mention_user_id || !mongoose.Types.ObjectId.isValid(o.mention_user_id)) continue
|
||||
// برخلاف لینک، مالکیت لازم نیست (منشنکردن یعنی تگکردن کاربرِ دیگه) — فقط باید
|
||||
// کاربر واقعاً وجود داشته باشه؛ username از خودِ دیتابیس گرفته میشه، نه از کلاینت
|
||||
// (که ممکنه دستکاریشده/قدیمی باشه)
|
||||
const mentioned = await UserModel.findById(o.mention_user_id).select('_id user_name').lean()
|
||||
if (!mentioned) continue
|
||||
result.push({
|
||||
...base,
|
||||
type: 'mention',
|
||||
text: '',
|
||||
font_size: clamp01(o.font_size, 0.06) || 0.06,
|
||||
color: '#ffffff',
|
||||
background: '',
|
||||
align: 'center',
|
||||
mention_user_id: String(mentioned._id),
|
||||
mention_username: mentioned.user_name
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (o.type === 'hashtag') {
|
||||
const tag = typeof o.text === 'string' ? o.text.trim().replace(/^#/, '').slice(0, 60) : ''
|
||||
if (!tag) continue
|
||||
result.push({
|
||||
...base,
|
||||
type: 'hashtag',
|
||||
text: tag,
|
||||
font_size: clamp01(o.font_size, 0.06) || 0.06,
|
||||
color: '#ffffff',
|
||||
background: '',
|
||||
align: 'center'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (o.type === 'location') {
|
||||
const place = typeof o.text === 'string' ? o.text.trim().slice(0, 100) : ''
|
||||
if (!place) continue
|
||||
result.push({
|
||||
...base,
|
||||
type: 'location',
|
||||
text: place,
|
||||
font_size: clamp01(o.font_size, 0.06) || 0.06,
|
||||
color: '#ffffff',
|
||||
background: '',
|
||||
align: 'center'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (o.type === 'sticker') {
|
||||
// فقط رشتهی خودِ ایموجی ذخیره میشه — یه ایموجی با اجزای ترکیبی (پرچم/تنپوست/ZWJ)
|
||||
// میتونه چند واحدِ UTF-16 باشه، برای همین سقف کمی بازتره تا کاراکترهای عادی
|
||||
const emoji = typeof o.text === 'string' ? o.text.trim().slice(0, 16) : ''
|
||||
if (!emoji) continue
|
||||
result.push({
|
||||
...base,
|
||||
type: 'sticker',
|
||||
text: emoji,
|
||||
font_size: clamp01(o.font_size, 0.09) || 0.09,
|
||||
color: '#ffffff',
|
||||
background: '',
|
||||
align: 'center'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof o.text !== 'string' || !o.text.trim().length) continue
|
||||
result.push({
|
||||
...base,
|
||||
type: 'text',
|
||||
text: String(o.text).slice(0, 300),
|
||||
font_size: clamp01(o.font_size, 0.06) || 0.06,
|
||||
font_family: OVERLAY_FONT_FAMILIES.includes(o.font_family) ? o.font_family : 'bold',
|
||||
color: typeof o.color === 'string' ? o.color.slice(0, 32) : '#ffffff',
|
||||
background: typeof o.background === 'string' ? o.background.slice(0, 32) : '',
|
||||
align: ['left', 'center', 'right'].includes(o.align) ? o.align : 'center',
|
||||
@@ -100,6 +171,32 @@ async function resolveViewerId (req) {
|
||||
}
|
||||
}
|
||||
|
||||
// پیشنمایش/اعمال فیلترِ رنگونور (وارم/کول/سیاهوسفید/زنده/محو/قدیمی) روی عکسِ گرفتهشده
|
||||
// از دوربین، قبل از رفتن به مرحلهی ادیتور اورلی — سمتِ سرور با sharp (رایگان، بدون
|
||||
// نیاز به کتابخونهی بومیِ اندروید/iOS که ریسکِ بیلد داشت)
|
||||
const applyPhotoFilter = async (req, res) => {
|
||||
try {
|
||||
const { file, filter } = req.body
|
||||
if (!file?.data) {
|
||||
return res.status(400).json({ error: true, message: 'فایل عکس الزامی است' })
|
||||
}
|
||||
if (!STORY_PHOTO_FILTER_NAMES.includes(filter)) {
|
||||
return res.status(400).json({ error: true, message: 'فیلتر نامعتبر است' })
|
||||
}
|
||||
const inputBuffer = Buffer.from(file.data.split(',')[1] || file.data, 'base64')
|
||||
const outputBuffer = await applyStoryPhotoFilter(inputBuffer, filter)
|
||||
return res.status(200).json({
|
||||
file: {
|
||||
data: `data:image/jpeg;base64,${outputBuffer.toString('base64')}`,
|
||||
type: 'image/jpeg'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('applyPhotoFilter:', err)
|
||||
return res.status(500).json({ error: true, message: 'خطا در اعمال فیلتر' })
|
||||
}
|
||||
}
|
||||
|
||||
const createStoryBase64 = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id
|
||||
@@ -632,6 +729,7 @@ const sendStoryToUsers = async (req, res, next) => {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyPhotoFilter,
|
||||
createStoryBase64,
|
||||
createStoryMultipart,
|
||||
createStoryFromPost,
|
||||
|
||||
@@ -1583,25 +1583,27 @@ const getPostsWeb = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// پروفایل یک کاربر خاص بهصورت ریلز: همهی پستهای pool از یک نویسندهن، پس رتبهبندی
|
||||
// شخصیسازیشده (paginatePersonalizedExplore) بیمعنیه — diversifyByAuthor توش با
|
||||
// maxPerPage=1 فقط اولین پست رو نگه میداره و بقیهی پستهای همون یک نویسنده رو با
|
||||
// ترتیبِ امتیازدهیشده (نه زمانی) میریزه ته لیست، یعنی «پست بعدی» دیگه پست
|
||||
// بعدیِ واقعیِ همون کاربر نیست. اینجا فقط seed post میره اول، بقیه دقیقاً با همون
|
||||
// ترتیب زمانی/پینشدهی posts (که بالاتر مرتب شده) میمونن
|
||||
if (feedMode === 'reels' && _id && seedPostId && posts.length > 1) {
|
||||
const { posts: paginatedPosts, totalItems, isColdStart } =
|
||||
await paginatePersonalizedExplore({
|
||||
posts,
|
||||
usersById,
|
||||
page,
|
||||
limit,
|
||||
userId: decodedToken?.id,
|
||||
seedPostId,
|
||||
reelsMode: true,
|
||||
blendFresh: false,
|
||||
cacheScope: `reels-profile:${_id}`
|
||||
})
|
||||
const seedIndex = posts.findIndex((p) => String(p._id) === String(seedPostId))
|
||||
const orderedPosts = seedIndex > 0
|
||||
? [posts[seedIndex], ...posts.slice(0, seedIndex), ...posts.slice(seedIndex + 1)]
|
||||
: posts
|
||||
|
||||
const startIndex = (page - 1) * limit
|
||||
const endIndex = page * limit
|
||||
const totalItems = orderedPosts.length
|
||||
const paginatedPosts = orderedPosts.slice(startIndex, endIndex)
|
||||
|
||||
return res.status(200).json({
|
||||
posts: paginatedPosts,
|
||||
totalPages: Math.ceil(totalItems / limit) || 1,
|
||||
totalItems,
|
||||
feedMeta: { isColdStart: !!isColdStart }
|
||||
totalItems
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1919,7 +1921,9 @@ const getSingleUserWeb = async (req, res, next) => {
|
||||
eye_color: user.eye_color,
|
||||
hair_color: user.hair_color,
|
||||
// «مشخصات فردی» فقط وقتی خودِ کاربر با تیک «نمایش مشخصات من برای عموم» موافقت کرده
|
||||
// برای بقیه نمایش داده میشه — برای خودِ صاحب پروفایل صرفنظر از این تیک همیشه برمیگرده
|
||||
// برای بقیه نمایش داده میشه — برای خودِ صاحب پروفایل صرفنظر از این تیک همیشه برمیگرده.
|
||||
// فیلدهای ترجیحاتِ پارتنریابی (relationship_goal تا smoking_status) بعداً به فرمِ
|
||||
// ویرایشِ مشخصاتِ فردی اضافه شدن ولی اینجا جا مونده بودن — الان همراهِ بقیه برمیگردن
|
||||
...(user.personal_details_public === true || isSelf
|
||||
? {
|
||||
marital_status: user.marital_status,
|
||||
@@ -1930,7 +1934,15 @@ const getSingleUserWeb = async (req, res, next) => {
|
||||
personal_style: user.personal_style,
|
||||
job: user.job,
|
||||
income_range: user.income_range,
|
||||
moral_traits: user.moral_traits
|
||||
relationship_goal: user.relationship_goal,
|
||||
personality_traits: user.personality_traits,
|
||||
lifestyle: user.lifestyle,
|
||||
interests: user.interests,
|
||||
values: user.values,
|
||||
relationship_view: user.relationship_view,
|
||||
geo_preference: user.geo_preference,
|
||||
children_status: user.children_status,
|
||||
smoking_status: user.smoking_status
|
||||
}
|
||||
: {}),
|
||||
personal_details_public: user.personal_details_public === true,
|
||||
|
||||
@@ -83,16 +83,18 @@ const getNearbyUsers = async (req, res, next) => {
|
||||
)
|
||||
const blockedMe = new Set((blockerIds || []).map((id) => String(id)))
|
||||
|
||||
// فقط کاربرانی که «نمایش لوکیشن به دیگران» رو فعال کردن و دسترسی موقعیت مکانی
|
||||
// رو به اپ دادهن (یعنی lat/lng واقعی دارن) باید توی رادار/لیست/نقشه دیده بشن
|
||||
const candidates = await UserModel.find({
|
||||
_id: { $ne: userId },
|
||||
show_location: true,
|
||||
account_status: { $ne: 'deactivated' },
|
||||
show_location: true,
|
||||
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'
|
||||
'user_name first_name last_name profile_image is_verified verify_badge is_Register show_location lat lng blocked_users'
|
||||
)
|
||||
.lean()
|
||||
|
||||
@@ -120,6 +122,9 @@ const getNearbyUsers = async (req, res, next) => {
|
||||
is_verified: user.is_verified,
|
||||
verify_badge: user.verify_badge,
|
||||
is_Register: user.is_Register,
|
||||
// فقط با تیکِ «نمایش لوکیشن به دیگران» باید روی نقشهی واقعی قرار بگیرن — بدونش
|
||||
// فقط توی رادار نشون داده میشن (تصمیم روی فرانتاند، اینجا فقط پرچمش رو میفرستیم)
|
||||
show_location: user.show_location === true,
|
||||
distanceMeters: Math.round(distanceMeters)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ function escapeRegex (value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** Multi-select query params (relationship_goal, personality_traits, lifestyle,
|
||||
* interests, values, children_status) are sent as a single comma-separated
|
||||
* string, e.g. `?personality_traits=kind,loyal` — this is the one convention
|
||||
* the RN/web clients must use for every array-type dating-profile filter.
|
||||
* Empty/blank items are dropped; returns [] for an empty/absent param. */
|
||||
function parseCsvParam (value) {
|
||||
if (value === undefined || value === null || value === '') return []
|
||||
const raw = Array.isArray(value) ? value.join(',') : String(value)
|
||||
return raw.split(',').map((v) => v.trim()).filter((v) => v.length > 0)
|
||||
}
|
||||
|
||||
/** $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. */
|
||||
@@ -31,7 +42,10 @@ function ageRangeExprClauses (ageMin, ageMax) {
|
||||
|
||||
/** 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. */
|
||||
* search and when a search is saved as an alert, so both always agree.
|
||||
* Multi-select dating-profile filters (relationship_goal, personality_traits,
|
||||
* lifestyle, interests, values) use a single comma-separated query param,
|
||||
* e.g. `?personality_traits=kind,loyal` — see parseCsvParam. */
|
||||
async function resolveCriteriaFromQuery (query) {
|
||||
const criteria = {}
|
||||
const {
|
||||
@@ -39,7 +53,10 @@ async function resolveCriteriaFromQuery (query) {
|
||||
heightMin, heightMax, weightMin, weightMax, sizeMin, sizeMax,
|
||||
hair_color, eye_color,
|
||||
marital_status, residence_type, vehicle_type, personal_style,
|
||||
income_range, job, country, province, city
|
||||
income_range, job, country, province, city,
|
||||
relationship_goal, seeking_gender, personality_traits,
|
||||
lifestyle, interests, values, relationship_view, geo_preference,
|
||||
children_status, smoking_status
|
||||
} = query || {}
|
||||
|
||||
if (gender) criteria.gender = gender
|
||||
@@ -60,6 +77,26 @@ async function resolveCriteriaFromQuery (query) {
|
||||
if (income_range) criteria.income_range = income_range
|
||||
if (job) criteria.job = String(job).trim()
|
||||
|
||||
// desired_age_range is a stored preference field only (echoed back via
|
||||
// PARTNER_RESULT_FIELDS) — it is NOT translated into a search filter here;
|
||||
// clients bucket it into ageMin/ageMax themselves before calling search.
|
||||
const relationshipGoalList = parseCsvParam(relationship_goal)
|
||||
if (relationshipGoalList.length) criteria.relationship_goal = relationshipGoalList
|
||||
if (seeking_gender) criteria.seeking_gender = seeking_gender
|
||||
const personalityTraitsList = parseCsvParam(personality_traits)
|
||||
if (personalityTraitsList.length) criteria.personality_traits = personalityTraitsList
|
||||
const lifestyleList = parseCsvParam(lifestyle)
|
||||
if (lifestyleList.length) criteria.lifestyle = lifestyleList
|
||||
const interestsList = parseCsvParam(interests)
|
||||
if (interestsList.length) criteria.interests = interestsList
|
||||
const valuesList = parseCsvParam(values)
|
||||
if (valuesList.length) criteria.values = valuesList
|
||||
if (relationship_view) criteria.relationship_view = relationship_view
|
||||
if (geo_preference) criteria.geo_preference = geo_preference
|
||||
const childrenStatusList = parseCsvParam(children_status)
|
||||
if (childrenStatusList.length) criteria.children_status = childrenStatusList
|
||||
if (smoking_status) criteria.smoking_status = smoking_status
|
||||
|
||||
if (country) {
|
||||
const countryFind = await CountryModel.findOne({ id: country }, { _id: 0 })
|
||||
if (countryFind) criteria.country_id = countryFind.id
|
||||
@@ -99,6 +136,22 @@ function criteriaToMongoFilter (criteria) {
|
||||
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' }
|
||||
|
||||
// Multi-select dating-profile fields: candidate matches if their stored
|
||||
// array overlaps ANY of the searcher's selected values ($in semantics).
|
||||
if (criteria.relationship_goal?.length) filter.relationship_goal = { $in: criteria.relationship_goal }
|
||||
if (criteria.personality_traits?.length) filter.personality_traits = { $in: criteria.personality_traits }
|
||||
if (criteria.lifestyle?.length) filter.lifestyle = { $in: criteria.lifestyle }
|
||||
if (criteria.interests?.length) filter.interests = { $in: criteria.interests }
|
||||
if (criteria.values?.length) filter.values = { $in: criteria.values }
|
||||
|
||||
// Single-select dating-profile fields: exact match.
|
||||
if (criteria.seeking_gender) filter.seeking_gender = criteria.seeking_gender
|
||||
if (criteria.relationship_view) filter.relationship_view = criteria.relationship_view
|
||||
if (criteria.geo_preference) filter.geo_preference = criteria.geo_preference
|
||||
if (criteria.children_status?.length) filter.children_status = { $in: criteria.children_status }
|
||||
if (criteria.smoking_status) filter.smoking_status = criteria.smoking_status
|
||||
|
||||
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
|
||||
@@ -115,7 +168,9 @@ function criteriaToMongoFilter (criteria) {
|
||||
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'
|
||||
'vehicle_type vehicle_details personal_style job income_range birthday country province city blocked_users ' +
|
||||
'relationship_goal seeking_gender desired_age_range personality_traits lifestyle interests values ' +
|
||||
'relationship_view geo_preference children_status smoking_status'
|
||||
|
||||
/** GET /users/partner-search — filters as query params (see resolveCriteriaFromQuery).
|
||||
* Personal-details fields are included in the response ONLY here, by design
|
||||
|
||||
@@ -53,8 +53,11 @@ const setAddress = async (req, res, next) => {
|
||||
if (country) {
|
||||
user.country = { id: country.id, name: country.name_fa || country.name, iso2: country.iso2, slug: country.slug }
|
||||
}
|
||||
user.province = province
|
||||
user.city = city
|
||||
// مثل country بالا — نام فارسی (name_fa) رو ترجیح بده، وگرنه استان/شهر با نام
|
||||
// انگلیسیِ خام (name) ذخیره میشد؛ قبلاً کل داکیومنت خام province/city (که name_fa
|
||||
// جدا داشت ولی field نمایشیِ name همچنان انگلیسی بود) مستقیم ذخیره میشد
|
||||
user.province = { id: province.id, name: province.name_fa || province.name, slug: province.slug }
|
||||
user.city = { id: city.id, name: city.name_fa || city.name, slug: city.slug }
|
||||
user.address = address
|
||||
user.lat = lat
|
||||
user.lng = lng
|
||||
@@ -125,8 +128,11 @@ const updateAddress = async (req, res, next) => {
|
||||
if (country) {
|
||||
user.country = { id: country.id, name: country.name_fa || country.name, iso2: country.iso2, slug: country.slug }
|
||||
}
|
||||
user.province = province
|
||||
user.city = city
|
||||
// مثل country بالا — نام فارسی (name_fa) رو ترجیح بده، وگرنه استان/شهر با نام
|
||||
// انگلیسیِ خام (name) ذخیره میشد؛ قبلاً کل داکیومنت خام province/city (که name_fa
|
||||
// جدا داشت ولی field نمایشیِ name همچنان انگلیسی بود) مستقیم ذخیره میشد
|
||||
user.province = { id: province.id, name: province.name_fa || province.name, slug: province.slug }
|
||||
user.city = { id: city.id, name: city.name_fa || city.name, slug: city.slug }
|
||||
user.address = address
|
||||
user.lat = lat
|
||||
user.lng = lng
|
||||
|
||||
@@ -41,6 +41,49 @@ const setGender = async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setGender
|
||||
// نسخهی PATCH — عیناً همون منطقِ setGender، فقط طبق قراردادِ بقیهی فیلدهای
|
||||
// «مشخصات فردی» (نگاه کنید personalDetailsController.js: set=POST برای ثبتنامِ
|
||||
// اولیه، update=PATCH برای ویرایش از تنظیمات) که settings/edit/gender/page.tsx
|
||||
// باید صداش بزنه، نه POST که مخصوص ویزارد ثبتنامه
|
||||
const updateGender = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization').split(' ')[1]
|
||||
if (!token) return res.status(401).send('Access Denied')
|
||||
const { gender } = req.body
|
||||
if (!gender) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'اطلاعات ورودی اشتباه است'
|
||||
})
|
||||
}
|
||||
if (!['male', 'female'].includes(gender)) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message: 'جنسیت معتبر نیست'
|
||||
})
|
||||
}
|
||||
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
|
||||
const user = await UserModel.findById(decodedToken.id)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'کاربری یافت نشد'
|
||||
})
|
||||
}
|
||||
|
||||
user.gender = gender.toLowerCase()
|
||||
await user.save()
|
||||
|
||||
return res.json({
|
||||
message: 'جنسیت با موفقیت بهروزرسانی شد',
|
||||
gender: user.gender
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setGender,
|
||||
updateGender
|
||||
}
|
||||
|
||||
@@ -2,11 +2,118 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
const MARITAL_STATUS_VALUES = ['single', 'divorced']
|
||||
const MARITAL_STATUS_VALUES = ['single', 'divorced', 'widowed', 'not_in_relationship', 'prefer_not_to_say']
|
||||
const RESIDENCE_TYPE_VALUES = ['owner', 'tenant']
|
||||
const VEHICLE_TYPE_VALUES = ['none', 'car', 'motorcycle']
|
||||
const PERSONAL_STYLE_VALUES = ['formal', 'sporty']
|
||||
const INCOME_RANGE_VALUES = ['under_5m', '5m_10m', '10m_20m', 'over_20m']
|
||||
const PERSONAL_STYLE_VALUES = ['casual', 'classic', 'minimal', 'formal', 'street', 'romantic']
|
||||
const INCOME_RANGE_VALUES = [
|
||||
'under_40m', '40m_50m', '50m_60m', '60m_70m', '70m_80m',
|
||||
'80m_90m', '90m_100m', '100m_150m', '150m_200m', 'over_200m'
|
||||
]
|
||||
|
||||
const RELATIONSHIP_GOAL_VALUES = [
|
||||
'friendship',
|
||||
'serious_partner',
|
||||
'marriage_intro',
|
||||
'emotional_relationship',
|
||||
'travel_event_companion',
|
||||
'social_networking',
|
||||
'not_sure'
|
||||
]
|
||||
const SEEKING_GENDER_VALUES = ['female', 'male', 'both', 'not_important']
|
||||
|
||||
const DESIRED_AGE_RANGE_VALUES = ['18_24', '25_30', '31_35', '36_40', '41_50', 'over_50', 'not_important']
|
||||
|
||||
const PERSONALITY_TRAITS_VALUES = [
|
||||
'kind',
|
||||
'loyal',
|
||||
'funny',
|
||||
'calm',
|
||||
'energetic',
|
||||
'responsible',
|
||||
'ambitious',
|
||||
'emotional',
|
||||
'logical',
|
||||
'patient',
|
||||
'honest',
|
||||
'romantic',
|
||||
'independent',
|
||||
'family_oriented',
|
||||
'adventurous',
|
||||
'introvert',
|
||||
'extrovert'
|
||||
]
|
||||
|
||||
const LIFESTYLE_VALUES = [
|
||||
'active_healthy',
|
||||
'homebody_calm',
|
||||
'social_outgoing',
|
||||
'busy_career',
|
||||
'traveler',
|
||||
'night_owl',
|
||||
'early_riser',
|
||||
'minimalist',
|
||||
'luxury_lover'
|
||||
]
|
||||
|
||||
const INTERESTS_VALUES = [
|
||||
'movies_series',
|
||||
'music',
|
||||
'books_reading',
|
||||
'sports',
|
||||
'cooking',
|
||||
'photography',
|
||||
'travel',
|
||||
'nature',
|
||||
'gaming',
|
||||
'art_painting',
|
||||
'fashion',
|
||||
'pets',
|
||||
'dancing',
|
||||
'language_learning'
|
||||
]
|
||||
|
||||
const VALUES_VALUES = [
|
||||
'family',
|
||||
'career_growth',
|
||||
'religion_spirituality',
|
||||
'personal_freedom',
|
||||
'financial_stability',
|
||||
'health_fitness',
|
||||
'personal_growth',
|
||||
'excitement_new_experiences'
|
||||
]
|
||||
|
||||
const RELATIONSHIP_VIEW_VALUES = [
|
||||
'traditional',
|
||||
'modern',
|
||||
'long_term',
|
||||
'friendship_first',
|
||||
'marriage_soon',
|
||||
'friendship_only_for_now'
|
||||
]
|
||||
|
||||
const GEO_PREFERENCE_VALUES = [
|
||||
'same_city_only',
|
||||
'nearby_cities',
|
||||
'whole_province',
|
||||
'whole_iran',
|
||||
'even_abroad',
|
||||
'online_is_fine'
|
||||
]
|
||||
|
||||
const CHILDREN_STATUS_VALUES = ['no_children', 'has_children', 'open_to_children', 'not_now', 'prefer_not_to_say']
|
||||
|
||||
const SMOKING_STATUS_VALUES = ['none', 'sometimes', 'yes', 'doesnt_matter', 'prefer_not_to_say']
|
||||
|
||||
/** Validates a multi-select field: must be an array and every item must be
|
||||
* in `allowedValues` (no cap on how many may be selected). Returns a
|
||||
* Persian error message on failure, or null on success. */
|
||||
function validateMultiSelect (value, allowedValues, errorMessage) {
|
||||
if (!Array.isArray(value)) return errorMessage
|
||||
if (!value.every((item) => allowedValues.includes(item))) return errorMessage
|
||||
return null
|
||||
}
|
||||
|
||||
/** All fields are optional profile-enrichment fields — only the ones present
|
||||
* in the request body are validated/applied, unlike sizes/cooperation-type
|
||||
@@ -54,12 +161,96 @@ function applyPersonalDetails (user, body) {
|
||||
}
|
||||
user.income_range = body.income_range
|
||||
}
|
||||
if (body.moral_traits !== undefined) {
|
||||
user.moral_traits = body.moral_traits
|
||||
}
|
||||
if (body.personal_details_public !== undefined) {
|
||||
user.personal_details_public = Boolean(body.personal_details_public)
|
||||
}
|
||||
if (body.relationship_goal !== undefined) {
|
||||
const error = validateMultiSelect(
|
||||
body.relationship_goal, RELATIONSHIP_GOAL_VALUES,
|
||||
'هدف از آشنایی نامعتبر است'
|
||||
)
|
||||
if (error) return error
|
||||
user.relationship_goal = body.relationship_goal
|
||||
}
|
||||
if (body.seeking_gender !== undefined) {
|
||||
if (body.seeking_gender !== null && !SEEKING_GENDER_VALUES.includes(body.seeking_gender)) {
|
||||
return 'جنسیت مورد نظر نامعتبر است'
|
||||
}
|
||||
user.seeking_gender = body.seeking_gender
|
||||
}
|
||||
if (body.desired_age_range !== undefined) {
|
||||
if (body.desired_age_range !== null && !DESIRED_AGE_RANGE_VALUES.includes(body.desired_age_range)) {
|
||||
return 'بازه سنی مورد نظر نامعتبر است'
|
||||
}
|
||||
user.desired_age_range = body.desired_age_range
|
||||
}
|
||||
if (body.personality_traits !== undefined) {
|
||||
const error = validateMultiSelect(
|
||||
body.personality_traits, PERSONALITY_TRAITS_VALUES,
|
||||
'ویژگیهای شخصیتی نامعتبر است'
|
||||
)
|
||||
if (error) return error
|
||||
user.personality_traits = body.personality_traits
|
||||
}
|
||||
if (body.lifestyle !== undefined) {
|
||||
const error = validateMultiSelect(
|
||||
body.lifestyle, LIFESTYLE_VALUES,
|
||||
'سبک زندگی نامعتبر است'
|
||||
)
|
||||
if (error) return error
|
||||
user.lifestyle = body.lifestyle
|
||||
}
|
||||
if (body.interests !== undefined) {
|
||||
const error = validateMultiSelect(
|
||||
body.interests, INTERESTS_VALUES,
|
||||
'علایق نامعتبر است'
|
||||
)
|
||||
if (error) return error
|
||||
user.interests = body.interests
|
||||
}
|
||||
if (body.values !== undefined) {
|
||||
const error = validateMultiSelect(
|
||||
body.values, VALUES_VALUES,
|
||||
'ارزشها نامعتبر است'
|
||||
)
|
||||
if (error) return error
|
||||
user.values = body.values
|
||||
}
|
||||
if (body.relationship_view !== undefined) {
|
||||
if (body.relationship_view !== null && !RELATIONSHIP_VIEW_VALUES.includes(body.relationship_view)) {
|
||||
return 'دیدگاه نسبت به رابطه نامعتبر است'
|
||||
}
|
||||
user.relationship_view = body.relationship_view
|
||||
}
|
||||
if (body.geo_preference !== undefined) {
|
||||
if (body.geo_preference !== null && !GEO_PREFERENCE_VALUES.includes(body.geo_preference)) {
|
||||
return 'محدوده جغرافیایی مورد نظر نامعتبر است'
|
||||
}
|
||||
user.geo_preference = body.geo_preference
|
||||
}
|
||||
if (body.children_status !== undefined) {
|
||||
const error = validateMultiSelect(
|
||||
body.children_status, CHILDREN_STATUS_VALUES,
|
||||
'وضعیت فرزند نامعتبر است'
|
||||
)
|
||||
if (error) return error
|
||||
user.children_status = body.children_status
|
||||
}
|
||||
if (body.smoking_status !== undefined) {
|
||||
if (body.smoking_status !== null && !SMOKING_STATUS_VALUES.includes(body.smoking_status)) {
|
||||
return 'وضعیت مصرف دخانیات نامعتبر است'
|
||||
}
|
||||
user.smoking_status = body.smoking_status
|
||||
}
|
||||
// فیلد سبکوزن برای ثبت تاریخ تولد از همین اندپوینت (بدون نیاز به شبا/کد ملی که
|
||||
// POST /verify/auth الزامی میکنه) — مثلاً وقتی کاربر فقط سن رو توی «مشخصات فردی»
|
||||
// اضافه میکنه، نه کل احراز هویت بانکی/ملی
|
||||
if (body.birthday !== undefined) {
|
||||
if (body.birthday !== null && Number.isNaN(Date.parse(body.birthday))) {
|
||||
return 'تاریخ تولد نامعتبر است'
|
||||
}
|
||||
user.birthday = body.birthday
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -123,8 +314,19 @@ const updatePersonalDetails = async (req, res, next) => {
|
||||
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
|
||||
personal_details_public: user.personal_details_public === true,
|
||||
relationship_goal: user.relationship_goal,
|
||||
seeking_gender: user.seeking_gender,
|
||||
desired_age_range: user.desired_age_range,
|
||||
personality_traits: user.personality_traits,
|
||||
lifestyle: user.lifestyle,
|
||||
interests: user.interests,
|
||||
values: user.values,
|
||||
relationship_view: user.relationship_view,
|
||||
geo_preference: user.geo_preference,
|
||||
children_status: user.children_status,
|
||||
smoking_status: user.smoking_status,
|
||||
birthday: user.birthday
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
|
||||
107
controllers/panel/adminUsers/adminUsersController.js
Normal file
107
controllers/panel/adminUsers/adminUsersController.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const bcrypt = require('bcryptjs')
|
||||
const AdminModel = require('../../../models/AdminModel')
|
||||
const { PANEL_PERMISSIONS, PANEL_PERMISSION_KEYS } = require('../../../utils/panelPermissions')
|
||||
|
||||
function sanitizeAdmin(admin) {
|
||||
return {
|
||||
_id: admin._id,
|
||||
user_name: admin.user_name,
|
||||
first_name: admin.first_name,
|
||||
last_name: admin.last_name,
|
||||
role: admin.role,
|
||||
permissions: admin.permissions || [],
|
||||
createdAt: admin.createdAt,
|
||||
last_online: admin.last_online
|
||||
}
|
||||
}
|
||||
|
||||
const listAdmins = async (req, res, next) => {
|
||||
try {
|
||||
const admins = await AdminModel.find({}).sort({ createdAt: -1 })
|
||||
res.status(200).json({
|
||||
admins: admins.map(sanitizeAdmin),
|
||||
availablePermissions: PANEL_PERMISSIONS
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const createAdmin = async (req, res, next) => {
|
||||
try {
|
||||
const { user_name, password, first_name, last_name, permissions } = req.body
|
||||
if (!user_name || !password) {
|
||||
return res.status(422).json({ error: true, message: 'نام کاربری و رمز عبور الزامی است' })
|
||||
}
|
||||
|
||||
const userLow = String(user_name).toLowerCase().trim()
|
||||
const existing = await AdminModel.findOne({ user_name: userLow })
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: true, message: 'این نام کاربری قبلاً استفاده شده است' })
|
||||
}
|
||||
|
||||
const validPermissions = Array.isArray(permissions)
|
||||
? permissions.filter((p) => PANEL_PERMISSION_KEYS.has(p))
|
||||
: []
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
const admin = await AdminModel.create({
|
||||
user_name: userLow,
|
||||
password: hashedPassword,
|
||||
first_name: first_name || null,
|
||||
last_name: last_name || null,
|
||||
role: 'staff',
|
||||
permissions: validPermissions,
|
||||
createdBy: req.admin._id
|
||||
})
|
||||
|
||||
res.status(201).json({ admin: sanitizeAdmin(admin) })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAdminPermissions = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { permissions } = req.body
|
||||
|
||||
const admin = await AdminModel.findById(id)
|
||||
if (!admin) return res.status(404).json({ error: true, message: 'کاربر پیدا نشد' })
|
||||
if (admin.role === 'super_admin') {
|
||||
return res.status(400).json({ error: true, message: 'دسترسی سوپرادمین قابل تغییر نیست' })
|
||||
}
|
||||
|
||||
const validPermissions = Array.isArray(permissions)
|
||||
? permissions.filter((p) => PANEL_PERMISSION_KEYS.has(p))
|
||||
: []
|
||||
|
||||
admin.permissions = validPermissions
|
||||
await admin.save()
|
||||
|
||||
res.status(200).json({ admin: sanitizeAdmin(admin) })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAdmin = async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const admin = await AdminModel.findById(id)
|
||||
if (!admin) return res.status(404).json({ error: true, message: 'کاربر پیدا نشد' })
|
||||
if (admin.role === 'super_admin') {
|
||||
return res.status(400).json({ error: true, message: 'حذف سوپرادمین مجاز نیست' })
|
||||
}
|
||||
if (String(admin._id) === String(req.admin._id)) {
|
||||
return res.status(400).json({ error: true, message: 'نمیتوانید خودتان را حذف کنید' })
|
||||
}
|
||||
|
||||
await AdminModel.deleteOne({ _id: id })
|
||||
res.status(200).json({ message: 'کاربر پنل حذف شد' })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listAdmins, createAdmin, updateAdminPermissions, deleteAdmin }
|
||||
247
controllers/panel/analytics/analyticsPanelController.js
Normal file
247
controllers/panel/analytics/analyticsPanelController.js
Normal file
@@ -0,0 +1,247 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const AnalyticsEvent = require('../../../models/AnalyticsEventModel')
|
||||
|
||||
function parseDays(req) {
|
||||
const days = parseInt(req.query.days, 10)
|
||||
if (!days || days <= 0) return 30
|
||||
return Math.min(days, 365)
|
||||
}
|
||||
|
||||
// آمار جغرافیایی/جنسیت مستقیم از خودِ پروفایل کاربرا میاد (چیزی که موقع ثبتنام/ویرایش
|
||||
// پروفایل قبلاً ذخیره میشه) — نیازی به ردیابی جداگانه نداره
|
||||
async function getDemographics(req, res, next) {
|
||||
try {
|
||||
const [countries, provinces, cities, genders] = await Promise.all([
|
||||
UserModel.aggregate([
|
||||
{ $match: { 'country.name': { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$country.name', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
]),
|
||||
UserModel.aggregate([
|
||||
{ $match: { 'province.name': { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$province.name', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
]),
|
||||
UserModel.aggregate([
|
||||
{ $match: { 'city.name': { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$city.name', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
]),
|
||||
UserModel.aggregate([
|
||||
{ $group: { _id: '$gender', count: { $sum: 1 } } }
|
||||
])
|
||||
])
|
||||
|
||||
const genderMap = { male: 0, female: 0, unspecified: 0 }
|
||||
genders.forEach((g) => {
|
||||
if (g._id === 'male') genderMap.male = g.count
|
||||
else if (g._id === 'female') genderMap.female = g.count
|
||||
else genderMap.unspecified += g.count
|
||||
})
|
||||
|
||||
res.status(200).json({
|
||||
countries: countries.map((c) => ({ name: c._id, count: c.count })),
|
||||
provinces: provinces.map((p) => ({ name: p._id, count: p.count })),
|
||||
cities: cities.map((c) => ({ name: c._id, count: c.count })),
|
||||
gender: genderMap
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// دستگاه/مرورگر/سیستمعامل/پلتفرم — از رویدادهای خام AnalyticsEvent (فقط بازهی
|
||||
// اخیر، برای پرفورمنس)
|
||||
async function getDeviceStats(req, res, next) {
|
||||
try {
|
||||
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
|
||||
const match = { createdAt: { $gte: since } }
|
||||
|
||||
const [devices, browsers, os, platforms] = await Promise.all([
|
||||
AnalyticsEvent.aggregate([
|
||||
{ $match: { ...match, device: { $ne: null } } },
|
||||
{ $group: { _id: '$device', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 15 }
|
||||
]),
|
||||
AnalyticsEvent.aggregate([
|
||||
{ $match: { ...match, browser: { $ne: null } } },
|
||||
{ $group: { _id: '$browser', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 15 }
|
||||
]),
|
||||
AnalyticsEvent.aggregate([
|
||||
{ $match: { ...match, os: { $ne: null } } },
|
||||
{ $group: { _id: '$os', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 15 }
|
||||
]),
|
||||
AnalyticsEvent.aggregate([
|
||||
{ $match: { ...match, type: 'session_start' } },
|
||||
{ $group: { _id: '$platform', count: { $sum: 1 } } }
|
||||
])
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
devices: devices.map((d) => ({ name: d._id, count: d.count })),
|
||||
browsers: browsers.map((b) => ({ name: b._id, count: b.count })),
|
||||
os: os.map((o) => ({ name: o._id, count: o.count })),
|
||||
platforms: platforms.reduce(
|
||||
(acc, p) => ({ ...acc, [p._id]: p.count }),
|
||||
{ web: 0, android: 0, ios: 0 }
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// پربازدیدترین صفحات + میانگین زمان موندگاری روی هرکدوم
|
||||
async function getPageStats(req, res, next) {
|
||||
try {
|
||||
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
|
||||
|
||||
const pages = await AnalyticsEvent.aggregate([
|
||||
{ $match: { type: 'page_view', createdAt: { $gte: since }, path: { $ne: null } } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$path',
|
||||
views: { $sum: 1 },
|
||||
avgDurationMs: { $avg: '$durationMs' }
|
||||
}
|
||||
},
|
||||
{ $sort: { views: -1 } },
|
||||
{ $limit: 30 }
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
pages: pages.map((p) => ({
|
||||
path: p._id,
|
||||
views: p.views,
|
||||
avgDurationMs: Math.round(p.avgDurationMs || 0)
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// پرکلیکترین عناصر (label ارسالشده از کلاینت — data-analytics-id یا متن دکمه)
|
||||
async function getClickStats(req, res, next) {
|
||||
try {
|
||||
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
|
||||
|
||||
const clicks = await AnalyticsEvent.aggregate([
|
||||
{ $match: { type: 'click', createdAt: { $gte: since }, label: { $ne: null } } },
|
||||
{ $group: { _id: { label: '$label', path: '$path' }, count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 30 }
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
clicks: clicks.map((c) => ({
|
||||
label: c._id.label,
|
||||
path: c._id.path,
|
||||
count: c.count
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// نرخ بازگشت — بر اساس last_online کاربر نسبت به createdAt؛ کاربری که last_onlineش
|
||||
// خیلی نزدیک به createdAtشه (یا اصلاً last_online نداره) یعنی بعد از ثبتنام دیگه
|
||||
// برنگشته
|
||||
async function getRetentionStats(req, res, next) {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const day7 = new Date(now - 7 * 24 * 60 * 60 * 1000)
|
||||
const day30 = new Date(now - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [totalUsers, activeLast7Days, activeLast30Days, neverReturned] =
|
||||
await Promise.all([
|
||||
UserModel.countDocuments({}),
|
||||
UserModel.countDocuments({ last_online: { $gte: day7 } }),
|
||||
UserModel.countDocuments({ last_online: { $gte: day30 } }),
|
||||
UserModel.countDocuments({
|
||||
$or: [
|
||||
{ last_online: null },
|
||||
{ $expr: { $lte: [{ $subtract: ['$last_online', '$createdAt'] }, 60000] } }
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
totalUsers,
|
||||
activeLast7Days,
|
||||
activeLast30Days,
|
||||
neverReturned,
|
||||
returningRate:
|
||||
totalUsers > 0
|
||||
? Math.round(((totalUsers - neverReturned) / totalUsers) * 100)
|
||||
: 0
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// خلاصهی کلی نشستها — تعداد نشست، میانگین صفحاتِ هر نشست، میانگین زمانِ هر نشست
|
||||
// (مجموع durationMsِ page_viewهای همون sessionId)
|
||||
async function getSessionOverview(req, res, next) {
|
||||
try {
|
||||
const since = new Date(Date.now() - parseDays(req) * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [sessionCountAgg, sessionDurationAgg] = await Promise.all([
|
||||
AnalyticsEvent.aggregate([
|
||||
{ $match: { type: 'session_start', createdAt: { $gte: since } } },
|
||||
{ $group: { _id: null, count: { $sum: 1 } } }
|
||||
]),
|
||||
AnalyticsEvent.aggregate([
|
||||
{
|
||||
$match: {
|
||||
type: 'page_view',
|
||||
createdAt: { $gte: since },
|
||||
durationMs: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$sessionId',
|
||||
pages: { $sum: 1 },
|
||||
totalDurationMs: { $sum: '$durationMs' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
avgPagesPerSession: { $avg: '$pages' },
|
||||
avgSessionDurationMs: { $avg: '$totalDurationMs' },
|
||||
sessionsWithDuration: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
])
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
totalSessions: sessionCountAgg[0]?.count || 0,
|
||||
avgPagesPerSession: Math.round(sessionDurationAgg[0]?.avgPagesPerSession || 0),
|
||||
avgSessionDurationMs: Math.round(sessionDurationAgg[0]?.avgSessionDurationMs || 0)
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDemographics,
|
||||
getDeviceStats,
|
||||
getPageStats,
|
||||
getClickStats,
|
||||
getRetentionStats,
|
||||
getSessionOverview
|
||||
}
|
||||
39
controllers/panel/auditLog/auditLogController.js
Normal file
39
controllers/panel/auditLog/auditLogController.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const AuditLog = require('../../../models/AuditLogModel')
|
||||
const AdminModel = require('../../../models/AdminModel')
|
||||
|
||||
const listAuditLog = async (req, res, next) => {
|
||||
try {
|
||||
const page = Math.max(parseInt(req.query.page, 10) || 1, 1)
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 30, 100)
|
||||
const filter = {}
|
||||
if (req.query.adminId) filter.adminId = req.query.adminId
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
AuditLog.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.lean(),
|
||||
AuditLog.countDocuments(filter)
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
logs,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
totalItems: total
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const listAdminsForFilter = async (req, res, next) => {
|
||||
try {
|
||||
const admins = await AdminModel.find({}).select('user_name first_name last_name').lean()
|
||||
res.status(200).json({ admins })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listAuditLog, listAdminsForFilter }
|
||||
@@ -42,7 +42,9 @@ const adminLogin = async (req, res, next) => {
|
||||
return res.json({
|
||||
message: 'کد تایید صحیح بود',
|
||||
token,
|
||||
id: user._id
|
||||
id: user._id,
|
||||
role: user.role || 'super_admin',
|
||||
permissions: user.permissions || []
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
|
||||
73
controllers/panel/moderation/overallStatsController.js
Normal file
73
controllers/panel/moderation/overallStatsController.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const PostModel = require('../../../models/PostModel')
|
||||
const CommentModel = require('../../../models/CommentModel')
|
||||
const AdvertisingCommentModel = require('../../../models/AdvertisingCommentModel')
|
||||
const CourseCommentModel = require('../../../models/CourseComentModel')
|
||||
const AdvertisingModel = require('../../../models/AdvertisingModel')
|
||||
const ProjectModel = require('../../../models/ProjectModel')
|
||||
const CourseModel = require('../../../models/CourseModel')
|
||||
const AcademyContentModel = require('../../../models/AcademyContentModel')
|
||||
const ShopModel = require('../../../models/ShopModel')
|
||||
const ShopProductListingModel = require('../../../models/ShopProductListingModel')
|
||||
const TicketModel = require('../../../models/TicketModel')
|
||||
const StoryModel = require('../../../models/StoryModel')
|
||||
|
||||
// عین moderationStatsController ولی برخلاف اون (که فقط موارد در انتظار/تایید موقت رو
|
||||
// میشمره)، اینجا مجموع کلِ هر مدل از ابتدای راهاندازی سایته — برای بخش «آمار کلی»
|
||||
// بالای داشبورد
|
||||
const getOverallStats = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.split(' ')[1]
|
||||
if (!token) return res.status(401).send('Access Denied')
|
||||
|
||||
const [
|
||||
users,
|
||||
posts,
|
||||
comments,
|
||||
vitrineComments,
|
||||
academyComments,
|
||||
billboards,
|
||||
projects,
|
||||
courses,
|
||||
academyVideos,
|
||||
shops,
|
||||
shopProducts,
|
||||
tickets,
|
||||
stories,
|
||||
] = await Promise.all([
|
||||
UserModel.countDocuments({}),
|
||||
PostModel.countDocuments({}),
|
||||
CommentModel.countDocuments({}),
|
||||
AdvertisingCommentModel.countDocuments({}),
|
||||
CourseCommentModel.countDocuments({}),
|
||||
AdvertisingModel.countDocuments({}),
|
||||
ProjectModel.countDocuments({}),
|
||||
CourseModel.countDocuments({}),
|
||||
AcademyContentModel.countDocuments({}),
|
||||
ShopModel.countDocuments({}),
|
||||
ShopProductListingModel.countDocuments({}),
|
||||
TicketModel.countDocuments({}),
|
||||
StoryModel.countDocuments({}),
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
users,
|
||||
posts,
|
||||
comments,
|
||||
vitrineComments,
|
||||
academyComments,
|
||||
billboards,
|
||||
projects,
|
||||
courses,
|
||||
academyVideos,
|
||||
shops,
|
||||
shopProducts,
|
||||
tickets,
|
||||
stories,
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getOverallStats }
|
||||
2478
data/geo/cities.json
2478
data/geo/cities.json
File diff suppressed because it is too large
Load Diff
1
index.js
1
index.js
@@ -637,6 +637,7 @@ io.on('connection', (socket) => {
|
||||
// Free Project
|
||||
require('./services/AdvertisingCron')
|
||||
require('./services/ProjectCron')
|
||||
require('./services/OfferReplyCron').setIo(io)
|
||||
require('./services/OrderLifecycleCron').setIo(io)
|
||||
require('./services/BookingLifecycleCron').setIo(io)
|
||||
require('./services/BookingRepeatExpiryCron').setIo(io)
|
||||
|
||||
35
middlewares/auditLogger.js
Normal file
35
middlewares/auditLogger.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const AuditLog = require('../models/AuditLogModel')
|
||||
|
||||
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'token', 'otp'])
|
||||
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
|
||||
function sanitizeBody(body) {
|
||||
if (!body || typeof body !== 'object') return null
|
||||
const clone = {}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
clone[key] = SENSITIVE_KEYS.has(key) ? '[hidden]' : value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// باید بعد از adminAuth بیاد (به req.admin نیاز داره). فقط متدهای نوشتاری رو ثبت
|
||||
// میکنه — GET یعنی فقط دیدن، نه یه «کار انجامشده» که نیاز به تاریخچه داشته باشه
|
||||
module.exports = function auditLogger(req, res, next) {
|
||||
if (!WRITE_METHODS.has(req.method)) return next()
|
||||
|
||||
res.on('finish', () => {
|
||||
if (!req.admin) return
|
||||
AuditLog.create({
|
||||
adminId: req.admin._id,
|
||||
adminUserName: req.admin.user_name,
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
body: sanitizeBody(req.body)
|
||||
}).catch((err) => {
|
||||
console.error('[auditLogger] failed to write audit log:', err.message)
|
||||
})
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
19
middlewares/requirePermission.js
Normal file
19
middlewares/requirePermission.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// باید بعد از adminAuth بیاد (به req.admin نیاز داره). super_admin همیشه رد میشه؛
|
||||
// staff فقط اگه key توی permissions خودش باشه
|
||||
module.exports = function requirePermission(key) {
|
||||
return (req, res, next) => {
|
||||
const admin = req.admin
|
||||
if (!admin) {
|
||||
return res.status(401).send({ status: 'error', code: 401, message: 'Access Denied' })
|
||||
}
|
||||
if (admin.role === 'super_admin') return next()
|
||||
if (Array.isArray(admin.permissions) && admin.permissions.includes(key)) {
|
||||
return next()
|
||||
}
|
||||
return res.status(403).send({
|
||||
status: 'error',
|
||||
code: 403,
|
||||
message: 'شما دسترسی لازم برای این بخش را ندارید'
|
||||
})
|
||||
}
|
||||
}
|
||||
13
middlewares/requireSuperAdmin.js
Normal file
13
middlewares/requireSuperAdmin.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// مدیریت کاربران پنل (ساخت/حذف/تغییرِ دسترسیِ ادمینهای دیگه) همیشه فقط برای
|
||||
// super_admin مجازه — حتی اگه یهجوری کلید 'user-management' به یه staff داده بشه،
|
||||
// این جلوی افزایشِ خودسرانهی دسترسی (privilege escalation) رو میگیره
|
||||
module.exports = function requireSuperAdmin(req, res, next) {
|
||||
if (req.admin?.role !== 'super_admin') {
|
||||
return res.status(403).send({
|
||||
status: 'error',
|
||||
code: 403,
|
||||
message: 'فقط سوپرادمین به این بخش دسترسی دارد'
|
||||
})
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -71,6 +71,23 @@ const adminSchema = new mongoose.Schema({
|
||||
last_online: {
|
||||
type: Date, // ذخیره تاریخ و زمان آخرین بازدید
|
||||
default: Date.now // تنظیم تاریخ به تاریخ فعلی
|
||||
},
|
||||
// super_admin: به همهچیز دسترسی داره (بدون نیاز به لیست permissions). staff: فقط به
|
||||
// مواردی که توی permissions هست. پیشفرض super_admin تا ادمینهای از قبل موجود بعد
|
||||
// از این تغییر دسترسیشون رو از دست ندن
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['super_admin', 'staff'],
|
||||
default: 'super_admin'
|
||||
},
|
||||
permissions: {
|
||||
type: [String],
|
||||
default: []
|
||||
},
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
66
models/AnalyticsEventModel.js
Normal file
66
models/AnalyticsEventModel.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
// رویدادهای خام رفتار کاربر (بازدید صفحه، کلیک، شروع/پایان نشست) — برای بخش
|
||||
// «آنالیتیکس» پنل. هر رکورد سبک نگه داشته میشه چون حجم بالایی داره؛ تجمیع/گزارشگیری
|
||||
// توی کنترلرهای panel/analytics انجام میشه، نه اینجا
|
||||
const AnalyticsEventSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
default: null,
|
||||
index: true
|
||||
},
|
||||
sessionId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['session_start', 'page_view', 'click'],
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
// page_view/click: مسیر صفحهای که رویداد توش رخ داده
|
||||
path: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
// فقط page_view: چند میلیثانیه کاربر روی این صفحه مونده (موقع رفتن به صفحهی
|
||||
// بعدی یا بستن تب محاسبه و ارسال میشه)
|
||||
durationMs: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// فقط click: شناسهی قابلخوندنِ عنصر کلیکشده (data-analytics-id یا برچسب/متن آن)
|
||||
label: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
enum: ['web', 'android', 'ios'],
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
device: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
browser: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
os: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
AnalyticsEventSchema.index({ createdAt: -1 })
|
||||
AnalyticsEventSchema.index({ type: 1, createdAt: -1 })
|
||||
|
||||
module.exports = mongoose.model('AnalyticsEvent', AnalyticsEventSchema)
|
||||
44
models/AuditLogModel.js
Normal file
44
models/AuditLogModel.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
// تاریخچهی سیستم — هر عملِ نوشتاری (POST/PUT/PATCH/DELETE) که یه ادمین توی پنل انجام
|
||||
// میده خودکار اینجا ثبت میشه (middlewares/auditLogger.js)، بدون نیاز به لاگ دستی
|
||||
// توی تکتکِ کنترلرها
|
||||
const AuditLogSchema = new mongoose.Schema(
|
||||
{
|
||||
adminId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
adminUserName: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
method: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
statusCode: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// خلاصهی بدنهی درخواست (پسورد/توکن حذف میشه) — برای اینکه معلوم باشه دقیقاً
|
||||
// چه تغییری داده شده
|
||||
body: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
AuditLogSchema.index({ createdAt: -1 })
|
||||
AuditLogSchema.index({ adminId: 1, createdAt: -1 })
|
||||
|
||||
module.exports = mongoose.model('AuditLog', AuditLogSchema)
|
||||
@@ -23,7 +23,7 @@ const offerSchema = new mongoose.Schema({
|
||||
ref: 'Payment',
|
||||
default: null
|
||||
},
|
||||
// سهم ۳۰٪ گیرنده که فقط با پاسخگویی ظرف ۲۴ ساعت به اولین پیام دایرکت واریز میشود
|
||||
// سهم ۳۰٪ گیرنده که فقط با پاسخگویی ظرف ۱۲ ساعت به اولین پیام دایرکت واریز میشود —
|
||||
pendingCommissionAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
@@ -43,6 +43,13 @@ const offerSchema = new mongoose.Schema({
|
||||
commissionCredited: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// وقتی گیرنده ظرف مهلت مقرر به اولین پیام دایرکت پاسخ نداد، سهم ۳۰٪ بهجای گیرنده
|
||||
// به کیف پول فرستنده (کسی که پرداخت کرده) برمیگردد — این فلگ از بازگشتِ دوباره
|
||||
// در اجراهای بعدیِ cron جلوگیری میکند
|
||||
commissionRefunded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -5,11 +5,21 @@ const timestamp = require('mongoose-timestamp')
|
||||
// type مشخص میکنه اورلی متنیه یا لینکِ یکی از موارد قابللینک (پکیج/پروژه/بیلبورد/فروشگاه/
|
||||
// کالا/رزرو، همونهایی که توی ثبت پست هم قابللینکان)
|
||||
const overlaySchema = new mongoose.Schema({
|
||||
type: { type: String, enum: ['text', 'link'], default: 'text' },
|
||||
type: { type: String, enum: ['text', 'link', 'mention', 'hashtag', 'location', 'sticker'], default: 'text' },
|
||||
// text: متن آزاد (type:'text') | متن تگ بدون # (type:'hashtag') | نام مکان (type:'location') |
|
||||
// خودِ ایموجی (type:'sticker')
|
||||
text: { type: String, default: '' },
|
||||
x: { type: Number, default: 0.5 },
|
||||
y: { type: Number, default: 0.5 },
|
||||
font_size: { type: Number, default: 0.06 },
|
||||
// فقط برای type:'text' — کدوم واریانت فونتِ برندِ اپ (Vazir/IRANSansX) استفاده بشه؛
|
||||
// چون اپ فعلاً فقط یک خانوادهفونت با وزنهای مختلف داره (نه چند تایپفیس واقعیِ جدا مثل
|
||||
// اینستاگرام)، این همون گزینههای واقعاً موجودیه
|
||||
font_family: {
|
||||
type: String,
|
||||
enum: ['light', 'regular', 'medium', 'bold', 'black'],
|
||||
default: 'bold'
|
||||
},
|
||||
color: { type: String, default: '#ffffff' },
|
||||
background: { type: String, default: '' },
|
||||
rotation: { type: Number, default: 0 },
|
||||
@@ -17,7 +27,10 @@ const overlaySchema = new mongoose.Schema({
|
||||
// فقط برای type:'link'
|
||||
link_kind: { type: String, default: null },
|
||||
link_id: { type: String, default: null },
|
||||
link_title: { type: String, default: null }
|
||||
link_title: { type: String, default: null },
|
||||
// فقط برای type:'mention'
|
||||
mention_user_id: { type: String, default: null },
|
||||
mention_username: { type: String, default: null }
|
||||
}, { _id: false })
|
||||
|
||||
const storySchema = new mongoose.Schema({
|
||||
|
||||
@@ -258,7 +258,159 @@ const userSchema = new mongoose.Schema({
|
||||
},
|
||||
marital_status: {
|
||||
type: String,
|
||||
enum: ['single', 'divorced'],
|
||||
enum: ['single', 'divorced', 'widowed', 'not_in_relationship', 'prefer_not_to_say'],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
relationship_goal: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'friendship',
|
||||
'serious_partner',
|
||||
'marriage_intro',
|
||||
'emotional_relationship',
|
||||
'travel_event_companion',
|
||||
'social_networking',
|
||||
'not_sure'
|
||||
]
|
||||
}],
|
||||
required: false,
|
||||
default: []
|
||||
},
|
||||
seeking_gender: {
|
||||
type: String,
|
||||
enum: ['female', 'male', 'both', 'not_important'],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
desired_age_range: {
|
||||
type: String,
|
||||
enum: ['18_24', '25_30', '31_35', '36_40', '41_50', 'over_50', 'not_important'],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
personality_traits: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'kind',
|
||||
'loyal',
|
||||
'funny',
|
||||
'calm',
|
||||
'energetic',
|
||||
'responsible',
|
||||
'ambitious',
|
||||
'emotional',
|
||||
'logical',
|
||||
'patient',
|
||||
'honest',
|
||||
'romantic',
|
||||
'independent',
|
||||
'family_oriented',
|
||||
'adventurous',
|
||||
'introvert',
|
||||
'extrovert'
|
||||
]
|
||||
}],
|
||||
required: false,
|
||||
default: []
|
||||
},
|
||||
lifestyle: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'active_healthy',
|
||||
'homebody_calm',
|
||||
'social_outgoing',
|
||||
'busy_career',
|
||||
'traveler',
|
||||
'night_owl',
|
||||
'early_riser',
|
||||
'minimalist',
|
||||
'luxury_lover'
|
||||
]
|
||||
}],
|
||||
required: false,
|
||||
default: []
|
||||
},
|
||||
interests: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'movies_series',
|
||||
'music',
|
||||
'books_reading',
|
||||
'sports',
|
||||
'cooking',
|
||||
'photography',
|
||||
'travel',
|
||||
'nature',
|
||||
'gaming',
|
||||
'art_painting',
|
||||
'fashion',
|
||||
'pets',
|
||||
'dancing',
|
||||
'language_learning'
|
||||
]
|
||||
}],
|
||||
required: false,
|
||||
default: []
|
||||
},
|
||||
values: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'family',
|
||||
'career_growth',
|
||||
'religion_spirituality',
|
||||
'personal_freedom',
|
||||
'financial_stability',
|
||||
'health_fitness',
|
||||
'personal_growth',
|
||||
'excitement_new_experiences'
|
||||
]
|
||||
}],
|
||||
required: false,
|
||||
default: []
|
||||
},
|
||||
relationship_view: {
|
||||
type: String,
|
||||
enum: [
|
||||
'traditional',
|
||||
'modern',
|
||||
'long_term',
|
||||
'friendship_first',
|
||||
'marriage_soon',
|
||||
'friendship_only_for_now'
|
||||
],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
geo_preference: {
|
||||
type: String,
|
||||
enum: [
|
||||
'same_city_only',
|
||||
'nearby_cities',
|
||||
'whole_province',
|
||||
'whole_iran',
|
||||
'even_abroad',
|
||||
'online_is_fine'
|
||||
],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
children_status: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: ['no_children', 'has_children', 'open_to_children', 'not_now', 'prefer_not_to_say']
|
||||
}],
|
||||
required: false,
|
||||
default: []
|
||||
},
|
||||
smoking_status: {
|
||||
type: String,
|
||||
enum: ['none', 'sometimes', 'yes', 'doesnt_matter', 'prefer_not_to_say'],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
@@ -292,7 +444,7 @@ const userSchema = new mongoose.Schema({
|
||||
},
|
||||
personal_style: {
|
||||
type: String,
|
||||
enum: ['formal', 'sporty'],
|
||||
enum: ['casual', 'classic', 'minimal', 'formal', 'street', 'romantic'],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
@@ -306,18 +458,13 @@ const userSchema = new mongoose.Schema({
|
||||
},
|
||||
income_range: {
|
||||
type: String,
|
||||
enum: ['under_5m', '5m_10m', '10m_20m', 'over_20m'],
|
||||
enum: [
|
||||
'under_40m', '40m_50m', '50m_60m', '60m_70m', '70m_80m',
|
||||
'80m_90m', '90m_100m', '100m_150m', '150m_200m', 'over_200m'
|
||||
],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
moral_traits: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
minLength: 1,
|
||||
maxLength: 1024,
|
||||
default: null
|
||||
},
|
||||
personal_details_public: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -618,6 +765,30 @@ const userSchema = new mongoose.Schema({
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
// بکآپ کلید خصوصیِ E2EE رمزنگاریشده با رمز/PIN انتخابیِ کاربر (سرور هیچوقت PIN یا
|
||||
// کلید خام رو نمیبینه، فقط این بلاکِ رمزنگاریشده رو ذخیره میکنه) — برای بازیابیِ
|
||||
// هویتِ رمزنگاری وقتی هیچ دستگاه دیگهای لینکشده در دسترس نیست
|
||||
e2ee_key_backup: {
|
||||
type: {
|
||||
salt: String,
|
||||
iv: String,
|
||||
ciphertext: String,
|
||||
iterations: Number
|
||||
},
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
// یک رشتهی تصادفیِ ۳۲بایتیِ per-account که با درخواستِ احرازهویتشده به همین
|
||||
// اکانت، از هر دستگاهی (وب/اندروید) قابلدریافته — بهجای PIN دستی، بهعنوانِ
|
||||
// رمزِ رمزنگاریِ e2ee_key_backup استفاده میشه تا بازیابیِ هویتِ E2EE روی دستگاهِ
|
||||
// جدید کاملاً خودکار و بدون دخالتِ کاربر انجام بشه. توجه: این یعنی تضمینِ
|
||||
// end-to-end دیگه واقعی نیست — هرکسی که به این فیلد (یا سرور) دسترسی پیدا کنه
|
||||
// میتونه بکآپ رو باز کنه؛ این تصمیم آگاهانه به نفعِ هیچوقتگمنشدنِ پیامها گرفته شده
|
||||
e2ee_auto_backup_secret: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
pinned_post_ids: {
|
||||
type: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
|
||||
581
package-lock.json
generated
581
package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"nodemon": "^3.1.0",
|
||||
"rate-limit-redis": "^4.3.1",
|
||||
"redis": "^6.2.1",
|
||||
"sharp": "^0.35.4",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
@@ -67,6 +68,16 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
||||
@@ -545,6 +556,506 @@
|
||||
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -2347,6 +2858,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
@@ -6734,6 +7254,67 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"nodemon": "^3.1.0",
|
||||
"rate-limit-redis": "^4.3.1",
|
||||
"redis": "^6.2.1",
|
||||
"sharp": "^0.35.4",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,11 @@ const {
|
||||
startDeviceLink,
|
||||
getDeviceLinkSession,
|
||||
approveDeviceLink,
|
||||
pollDeviceLink
|
||||
pollDeviceLink,
|
||||
getOrCreateAutoBackupSecret,
|
||||
putKeyBackup,
|
||||
getKeyBackup,
|
||||
deleteKeyBackup
|
||||
} = require('../../../controllers/application/account/e2eeController')
|
||||
|
||||
const router = express.Router()
|
||||
@@ -44,5 +48,9 @@ router.post('/e2ee/device-link', auth, blockCheck, startDeviceLink)
|
||||
router.get('/e2ee/device-link/:linkId', auth, blockCheck, getDeviceLinkSession)
|
||||
router.post('/e2ee/device-link/:linkId/approve', auth, blockCheck, approveDeviceLink)
|
||||
router.get('/e2ee/device-link/:linkId/poll', auth, blockCheck, pollDeviceLink)
|
||||
router.get('/e2ee/auto-backup-secret', auth, blockCheck, getOrCreateAutoBackupSecret)
|
||||
router.put('/e2ee/key-backup', auth, blockCheck, putKeyBackup)
|
||||
router.get('/e2ee/key-backup', auth, blockCheck, getKeyBackup)
|
||||
router.delete('/e2ee/key-backup', auth, blockCheck, deleteKeyBackup)
|
||||
|
||||
module.exports = router
|
||||
|
||||
7
routes/application/analytics/index.js
Normal file
7
routes/application/analytics/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const express = require('express')
|
||||
const { trackEvents } = require('../../../controllers/application/analytics/analyticsController')
|
||||
|
||||
const router = express.Router()
|
||||
router.post('/track', trackEvents)
|
||||
|
||||
module.exports = router
|
||||
@@ -6,6 +6,7 @@ const auth = require('../../../middlewares/auth')
|
||||
const blockCheck = require('../../../middlewares/blockCheck')
|
||||
const isHalfRegister = require('../../../middlewares/isHalfRegister')
|
||||
const {
|
||||
applyPhotoFilter,
|
||||
createStoryBase64,
|
||||
createStoryMultipart,
|
||||
createStoryFromPost,
|
||||
@@ -43,6 +44,7 @@ const storyUpload = multer({
|
||||
}).single('story_file')
|
||||
|
||||
router.get('/feed', getStoriesFeed)
|
||||
router.post('/apply-filter', auth, blockCheck, isHalfRegister, applyPhotoFilter)
|
||||
router.post('/create-base64', auth, blockCheck, isHalfRegister, createStoryBase64)
|
||||
// عین /posts/create چندبخشی — برای فایلهای حجیم (مخصوصاً ویدیو) که تبدیل به base64 توی
|
||||
// حافظهی JS موبایل باعث خطای OOM میشد؛ فایل مستقیم از دیسک استریم میشه
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express')
|
||||
const { setProfileImage, updateProfileImage } = require('../../../controllers/application/verify/setProfileImageController')
|
||||
const { setExpertise, updateExpertise } = require('../../../controllers/application/verify/expertiseController')
|
||||
const { setGender } = require('../../../controllers/application/verify/genderController')
|
||||
const { setGender, updateGender } = require('../../../controllers/application/verify/genderController')
|
||||
const { setSizes, updateSizes } = require('../../../controllers/application/verify/sizesController')
|
||||
const { setColors, updateColors } = require('../../../controllers/application/verify/colorsController')
|
||||
const { setCooperationType, updateCooperationType } = require('../../../controllers/application/verify/cooperationTypeController')
|
||||
@@ -21,6 +21,7 @@ router.patch('/profile_image', [blockCheck], [auth], updateProfileImage)
|
||||
router.post('/expertise', setExpertise)
|
||||
router.patch('/expertise', [blockCheck], [auth], updateExpertise)
|
||||
router.post('/gender', setGender)
|
||||
router.patch('/gender', [blockCheck], [auth], updateGender)
|
||||
router.post('/sizes', setSizes)
|
||||
router.patch('/sizes', [blockCheck], [auth], updateSizes)
|
||||
router.post('/services', setServices)
|
||||
|
||||
@@ -35,6 +35,7 @@ const accountRouter = require('./application/account')
|
||||
const giftsRouter = require('./application/gifts')
|
||||
const chatRoomsRouter = require('./application/chat-rooms')
|
||||
const suggestionsRouter = require('./application/suggestions')
|
||||
const analyticsRouter = require('./application/analytics')
|
||||
|
||||
const projectsPanelRouter = require('./panel/projects')
|
||||
const postsPanelRouter = require('./panel/posts')
|
||||
@@ -58,10 +59,15 @@ const moderationPanelRouter = require('./panel/moderation')
|
||||
const academyContentPanelRouter = require('./panel/academy-content')
|
||||
const licensesPanelRouter = require('./panel/licenses')
|
||||
const notificationsPanelRouter = require('./panel/notifications')
|
||||
const analyticsPanelRouter = require('./panel/analytics')
|
||||
const adminUsersPanelRouter = require('./panel/adminUsers')
|
||||
const auditLogPanelRouter = require('./panel/auditLog')
|
||||
|
||||
const auth = require('../middlewares/auth')
|
||||
const blockCheck = require('../middlewares/blockCheck')
|
||||
const adminAuth = require('../middlewares/adminAuth')
|
||||
const requirePermission = require('../middlewares/requirePermission')
|
||||
const auditLogger = require('../middlewares/auditLogger')
|
||||
module.exports = (app) => {
|
||||
// App
|
||||
app.use('/api/v1/register', registerRouter)
|
||||
@@ -95,6 +101,7 @@ module.exports = (app) => {
|
||||
app.use('/api/v1/financial', [auth], financialRouter)
|
||||
app.use('/api/v1/tickets', [auth], ticketsRouter)
|
||||
app.use('/api/v1/suggestions', [auth], suggestionsRouter)
|
||||
app.use('/api/v1/analytics', analyticsRouter)
|
||||
app.use('/api/v1/offers', offerRouter)
|
||||
app.use('/api/v1/advertising', advertisingRouter)
|
||||
app.use('/api/v1/settings', settingsRouter)
|
||||
@@ -103,26 +110,32 @@ module.exports = (app) => {
|
||||
app.use('/api/v1/stories', storiesRouter)
|
||||
|
||||
// Panel
|
||||
// فقط متدهای نوشتاری (POST/PUT/PATCH/DELETE) رو خودکار توی AuditLog ثبت میکنه —
|
||||
// برای همهی زیرمسیرهای پنل یکجا، بدون نیاز به افزودنش به تکتکِ app.use زیر
|
||||
app.use('/api/v1/panel', auditLogger)
|
||||
app.use('/api/v1/panel/login', loginPanelRouter)
|
||||
app.use('/api/v1/panel/users', [adminAuth], usersPanelRouter)
|
||||
app.use('/api/v1/panel/projects', [adminAuth], projectsPanelRouter)
|
||||
app.use('/api/v1/panel/posts', [adminAuth], postsPanelRouter)
|
||||
app.use('/api/v1/panel/expertise', [adminAuth], expertisePanelRouter)
|
||||
app.use('/api/v1/panel/shop-categories', [adminAuth], shopCategoriesPanelRouter)
|
||||
app.use('/api/v1/panel/marketplace-shops', [adminAuth], marketplaceShopsPanelRouter)
|
||||
app.use('/api/v1/panel/wallet-requests', [adminAuth], walletRequestsPanelRouter)
|
||||
app.use('/api/v1/panel/tickets', [adminAuth], ticketsPanelRouter)
|
||||
app.use('/api/v1/panel/financial', [adminAuth], financialPanelRouter)
|
||||
app.use('/api/v1/panel/comments', [adminAuth], commentsPanelRouter)
|
||||
app.use('/api/v1/panel/advertising', [adminAuth], advertisingPanelRouter)
|
||||
app.use('/api/v1/panel/shops', [adminAuth], shopsPanelRouter)
|
||||
app.use('/api/v1/panel/settings', [adminAuth], settingsPanelRouter)
|
||||
app.use('/api/v1/panel/version', [adminAuth], versionPanelRouter)
|
||||
app.use('/api/v1/panel/stories', [adminAuth], storiesPanelRouter)
|
||||
app.use('/api/v1/panel/reports', [adminAuth], reportsPanelRouter)
|
||||
app.use('/api/v1/panel/suggestions', [adminAuth], suggestionsPanelRouter)
|
||||
app.use('/api/v1/panel/moderation', [adminAuth], moderationPanelRouter)
|
||||
app.use('/api/v1/panel/academy-content', [adminAuth], academyContentPanelRouter)
|
||||
app.use('/api/v1/panel/licenses', [adminAuth], licensesPanelRouter)
|
||||
app.use('/api/v1/panel/notifications', [adminAuth], notificationsPanelRouter)
|
||||
app.use('/api/v1/panel/users', [adminAuth, requirePermission('users')], usersPanelRouter)
|
||||
app.use('/api/v1/panel/projects', [adminAuth, requirePermission('projects')], projectsPanelRouter)
|
||||
app.use('/api/v1/panel/posts', [adminAuth, requirePermission('posts')], postsPanelRouter)
|
||||
app.use('/api/v1/panel/expertise', [adminAuth, requirePermission('expertise')], expertisePanelRouter)
|
||||
app.use('/api/v1/panel/shop-categories', [adminAuth, requirePermission('shop-categories')], shopCategoriesPanelRouter)
|
||||
app.use('/api/v1/panel/marketplace-shops', [adminAuth, requirePermission('marketplace-shops')], marketplaceShopsPanelRouter)
|
||||
app.use('/api/v1/panel/wallet-requests', [adminAuth, requirePermission('wallet-requests')], walletRequestsPanelRouter)
|
||||
app.use('/api/v1/panel/tickets', [adminAuth, requirePermission('tickets')], ticketsPanelRouter)
|
||||
app.use('/api/v1/panel/financial', [adminAuth, requirePermission('financial')], financialPanelRouter)
|
||||
app.use('/api/v1/panel/comments', [adminAuth, requirePermission('comments')], commentsPanelRouter)
|
||||
app.use('/api/v1/panel/advertising', [adminAuth, requirePermission('advertising')], advertisingPanelRouter)
|
||||
app.use('/api/v1/panel/shops', [adminAuth, requirePermission('shops')], shopsPanelRouter)
|
||||
app.use('/api/v1/panel/settings', [adminAuth, requirePermission('settings')], settingsPanelRouter)
|
||||
app.use('/api/v1/panel/version', [adminAuth, requirePermission('version')], versionPanelRouter)
|
||||
app.use('/api/v1/panel/stories', [adminAuth, requirePermission('stories')], storiesPanelRouter)
|
||||
app.use('/api/v1/panel/reports', [adminAuth, requirePermission('reports')], reportsPanelRouter)
|
||||
app.use('/api/v1/panel/suggestions', [adminAuth, requirePermission('suggestions')], suggestionsPanelRouter)
|
||||
app.use('/api/v1/panel/moderation', [adminAuth, requirePermission('moderation')], moderationPanelRouter)
|
||||
app.use('/api/v1/panel/analytics', [adminAuth, requirePermission('analytics')], analyticsPanelRouter)
|
||||
app.use('/api/v1/panel/academy-content', [adminAuth, requirePermission('academy-content')], academyContentPanelRouter)
|
||||
app.use('/api/v1/panel/licenses', [adminAuth, requirePermission('licenses')], licensesPanelRouter)
|
||||
app.use('/api/v1/panel/notifications', [adminAuth, requirePermission('notifications')], notificationsPanelRouter)
|
||||
app.use('/api/v1/panel/admin-users', [adminAuth], adminUsersPanelRouter)
|
||||
app.use('/api/v1/panel/audit-log', [adminAuth], auditLogPanelRouter)
|
||||
}
|
||||
|
||||
16
routes/panel/adminUsers/index.js
Normal file
16
routes/panel/adminUsers/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const express = require('express')
|
||||
const {
|
||||
listAdmins,
|
||||
createAdmin,
|
||||
updateAdminPermissions,
|
||||
deleteAdmin
|
||||
} = require('../../../controllers/panel/adminUsers/adminUsersController')
|
||||
const requireSuperAdmin = require('../../../middlewares/requireSuperAdmin')
|
||||
|
||||
const router = express.Router()
|
||||
router.get('/', [requireSuperAdmin], listAdmins)
|
||||
router.post('/', [requireSuperAdmin], createAdmin)
|
||||
router.put('/:id/permissions', [requireSuperAdmin], updateAdminPermissions)
|
||||
router.delete('/:id', [requireSuperAdmin], deleteAdmin)
|
||||
|
||||
module.exports = router
|
||||
19
routes/panel/analytics/index.js
Normal file
19
routes/panel/analytics/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const express = require('express')
|
||||
const {
|
||||
getDemographics,
|
||||
getDeviceStats,
|
||||
getPageStats,
|
||||
getClickStats,
|
||||
getRetentionStats,
|
||||
getSessionOverview
|
||||
} = require('../../../controllers/panel/analytics/analyticsPanelController')
|
||||
|
||||
const router = express.Router()
|
||||
router.get('/demographics', getDemographics)
|
||||
router.get('/devices', getDeviceStats)
|
||||
router.get('/pages', getPageStats)
|
||||
router.get('/clicks', getClickStats)
|
||||
router.get('/retention', getRetentionStats)
|
||||
router.get('/sessions', getSessionOverview)
|
||||
|
||||
module.exports = router
|
||||
9
routes/panel/auditLog/index.js
Normal file
9
routes/panel/auditLog/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const express = require('express')
|
||||
const { listAuditLog, listAdminsForFilter } = require('../../../controllers/panel/auditLog/auditLogController')
|
||||
const requireSuperAdmin = require('../../../middlewares/requireSuperAdmin')
|
||||
|
||||
const router = express.Router()
|
||||
router.get('/', [requireSuperAdmin], listAuditLog)
|
||||
router.get('/admins', [requireSuperAdmin], listAdminsForFilter)
|
||||
|
||||
module.exports = router
|
||||
@@ -2,8 +2,12 @@ const express = require('express')
|
||||
const {
|
||||
getModerationStats,
|
||||
} = require('../../../controllers/panel/moderation/moderationStatsController')
|
||||
const {
|
||||
getOverallStats,
|
||||
} = require('../../../controllers/panel/moderation/overallStatsController')
|
||||
|
||||
const router = express.Router()
|
||||
router.get('/stats', getModerationStats)
|
||||
router.get('/stats/overall', getOverallStats)
|
||||
|
||||
module.exports = router
|
||||
|
||||
24
services/OfferReplyCron.js
Normal file
24
services/OfferReplyCron.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const cron = require('node-cron')
|
||||
const { refundExpiredOfferCommissions } = require('../utils/offerReplyGate')
|
||||
|
||||
let ioInstance = null
|
||||
|
||||
/** Called once from index.js right after the socket.io server is created — cron jobs have no `req`. */
|
||||
function setIo (io) {
|
||||
ioInstance = io
|
||||
}
|
||||
|
||||
// هر ۱۵ دقیقه یکبار — چون مهلت ۱۲ ساعته است، بازهی کوتاهتر از cron ماهانهی
|
||||
// پروژهها (ProjectCron.js) لازم است تا بازگشتِ وجه با تأخیرِ زیاد انجام نشود
|
||||
cron.schedule('*/15 * * * *', async () => {
|
||||
try {
|
||||
const refundedCount = await refundExpiredOfferCommissions(ioInstance)
|
||||
if (refundedCount > 0) {
|
||||
console.log(`[OfferReplyCron] refunded ${refundedCount} expired offer commission(s)`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OfferReplyCron] error:', error)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = { setIo }
|
||||
@@ -16,21 +16,24 @@ async function ensureGeoData(force = false) {
|
||||
const cities = JSON.parse(fs.readFileSync(path.join(dataDir, 'cities.json'), 'utf8'))
|
||||
|
||||
const provincesWithFaCount = provinces.filter((p) => p.name_fa).length
|
||||
const citiesWithFaCount = cities.filter((c) => c.name_fa).length
|
||||
|
||||
const [countryCount, provinceCount, cityCount, provinceFaCount] = await Promise.all([
|
||||
const [countryCount, provinceCount, cityCount, provinceFaCount, cityFaCount] = await Promise.all([
|
||||
CountryModel.countDocuments(),
|
||||
ProvinceModel.countDocuments(),
|
||||
CityModel.countDocuments(),
|
||||
ProvinceModel.countDocuments({ name_fa: { $exists: true, $ne: null } })
|
||||
ProvinceModel.countDocuments({ name_fa: { $exists: true, $ne: null } }),
|
||||
CityModel.countDocuments({ name_fa: { $exists: true, $ne: null } })
|
||||
])
|
||||
const inSync =
|
||||
countryCount === countries.length &&
|
||||
provinceCount === provinces.length &&
|
||||
cityCount === cities.length &&
|
||||
provinceFaCount === provincesWithFaCount
|
||||
provinceFaCount === provincesWithFaCount &&
|
||||
cityFaCount === citiesWithFaCount
|
||||
if (!force && inSync) return
|
||||
|
||||
console.log(`[boot] Geo reference data out of sync (countries: ${countryCount}/${countries.length}, provinces: ${provinceCount}/${provinces.length}, cities: ${cityCount}/${cities.length}) — reseeding...`)
|
||||
console.log(`[boot] Geo reference data out of sync (countries: ${countryCount}/${countries.length}, provinces: ${provinceCount}/${provinces.length}, cities: ${cityCount}/${cities.length}, city name_fa: ${cityFaCount}/${citiesWithFaCount}) — reseeding...`)
|
||||
|
||||
await CountryModel.deleteMany({})
|
||||
await ProvinceModel.deleteMany({})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
const OfferModel = require('../models/OfferModel')
|
||||
const WalletTransactionModel = require('../models/WalletTransactionModel')
|
||||
const { notifyUser } = require('./notifyUser')
|
||||
|
||||
const OFFER_REPLY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
const OFFER_REPLY_WINDOW_MS = 12 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Called whenever a chat message is sent. If there's a pending paid Offer
|
||||
* between these two users, tracks the requester's first direct message and
|
||||
* the receiver's reply — the receiver's 30% commission is only credited if
|
||||
* they reply within 24 hours of that first message.
|
||||
* they reply within 12 hours of that first message.
|
||||
*/
|
||||
async function trackOfferMessageForCommission (io, senderId, receiverId) {
|
||||
try {
|
||||
@@ -33,7 +34,7 @@ async function trackOfferMessageForCommission (io, senderId, receiverId) {
|
||||
return
|
||||
}
|
||||
|
||||
// پیام از طرف گیرنده — کسی که باید ظرف ۲۴ ساعت پاسخ بدهد
|
||||
// پیام از طرف گیرنده — کسی که باید ظرف ۱۲ ساعت پاسخ بدهد
|
||||
if (!offer.firstMessageAt || offer.repliedAt) return
|
||||
if (new Date() > offer.replyDeadline) return
|
||||
|
||||
@@ -65,4 +66,54 @@ async function trackOfferMessageForCommission (io, senderId, receiverId) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { trackOfferMessageForCommission, OFFER_REPLY_WINDOW_MS }
|
||||
/**
|
||||
* Sweep for paid Offers whose 12-hour reply deadline has passed without the
|
||||
* receiver replying — refunds the pending 30% commission to the sender's
|
||||
* wallet instead (both web and Android go through the same payment callback,
|
||||
* so a single server-side sweep covers both). Meant to be called
|
||||
* periodically by a cron job (see services/OfferReplyCron.js).
|
||||
*/
|
||||
async function refundExpiredOfferCommissions (io) {
|
||||
const now = new Date()
|
||||
const expiredOffers = await OfferModel.find({
|
||||
commissionCredited: false,
|
||||
commissionRefunded: false,
|
||||
pendingCommissionAmount: { $gt: 0 },
|
||||
firstMessageAt: { $ne: null },
|
||||
repliedAt: null,
|
||||
replyDeadline: { $lte: now }
|
||||
})
|
||||
|
||||
for (const offer of expiredOffers) {
|
||||
try {
|
||||
offer.commissionRefunded = true
|
||||
await offer.save()
|
||||
|
||||
await WalletTransactionModel.create({
|
||||
user: offer.sender,
|
||||
bucket: 'gift',
|
||||
type: 'credit',
|
||||
amount: offer.pendingCommissionAmount,
|
||||
referenceType: 'payment',
|
||||
referenceId: offer._id,
|
||||
description: 'بازگشت وجه درخواست همکاری (طرف مقابل ظرف ۱۲ ساعت مقرر پاسخ نداد)'
|
||||
})
|
||||
|
||||
await notifyUser(io, offer.sender, {
|
||||
type: 'offer_commission_refunded',
|
||||
title: 'بازگشت وجه به کیف پول',
|
||||
description: 'طرف مقابل ظرف مهلت ۱۲ ساعته به درخواست همکاری شما پاسخ نداد؛ مبلغ به کیف پول شما بازگشت.'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('refundExpiredOfferCommissions error for offer', offer._id, error)
|
||||
}
|
||||
}
|
||||
|
||||
return expiredOffers.length
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
trackOfferMessageForCommission,
|
||||
refundExpiredOfferCommissions,
|
||||
OFFER_REPLY_WINDOW_MS
|
||||
}
|
||||
|
||||
33
utils/panelPermissions.js
Normal file
33
utils/panelPermissions.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// فهرست همهی بخشهای پنل که میشه دسترسیِ جداگانه بهشون داد — هر کلید دقیقاً معادلِ
|
||||
// یه مسیر پنل (routes/index.js: app.use('/api/v1/panel/<key>', ...)). وقتی بخش جدیدی
|
||||
// به پنل اضافه شد، اینجا هم باید اضافه بشه وگرنه staff هیچوقت بهش دسترسی نمیگیره
|
||||
const PANEL_PERMISSIONS = [
|
||||
{ key: 'users', label: 'کاربران' },
|
||||
{ key: 'projects', label: 'پروژهها' },
|
||||
{ key: 'posts', label: 'پستها' },
|
||||
{ key: 'expertise', label: 'تخصصها' },
|
||||
{ key: 'shop-categories', label: 'دستهبندی فروشگاه' },
|
||||
{ key: 'marketplace-shops', label: 'فروشگاههای مارکتپلیس' },
|
||||
{ key: 'wallet-requests', label: 'درخواستهای کیف پول' },
|
||||
{ key: 'tickets', label: 'تیکتها' },
|
||||
{ key: 'financial', label: 'مالی' },
|
||||
{ key: 'comments', label: 'نظرات' },
|
||||
{ key: 'advertising', label: 'بیلبوردها' },
|
||||
{ key: 'shops', label: 'فروشگاههای بیلبورد' },
|
||||
{ key: 'settings', label: 'تنظیمات (مالیات/قیمت/CDN/SSL/ترافیک)' },
|
||||
{ key: 'version', label: 'نسخه اپلیکیشن' },
|
||||
{ key: 'stories', label: 'استوریها' },
|
||||
{ key: 'reports', label: 'گزارشهای محتوا' },
|
||||
{ key: 'suggestions', label: 'پیشنهادها' },
|
||||
{ key: 'moderation', label: 'داشبورد بازبینی' },
|
||||
{ key: 'analytics', label: 'آنالیتیکس' },
|
||||
{ key: 'academy-content', label: 'محتوای آموزشگاه' },
|
||||
{ key: 'licenses', label: 'مجوزها' },
|
||||
{ key: 'notifications', label: 'اعلانها' },
|
||||
{ key: 'user-management', label: 'مدیریت کاربران پنل' },
|
||||
{ key: 'system-history', label: 'تاریخچه سیستم' }
|
||||
]
|
||||
|
||||
const PANEL_PERMISSION_KEYS = new Set(PANEL_PERMISSIONS.map((p) => p.key))
|
||||
|
||||
module.exports = { PANEL_PERMISSIONS, PANEL_PERMISSION_KEYS }
|
||||
@@ -55,6 +55,43 @@ function matchesCriteria (user, criteria = {}) {
|
||||
if (!user.job || !user.job.toLowerCase().includes(criteria.job.toLowerCase())) return false
|
||||
}
|
||||
|
||||
// Multi-select dating-profile fields: match if the candidate's stored
|
||||
// array overlaps ANY of the alert's selected values — mirrors the $in
|
||||
// semantics of criteriaToMongoFilter in partnerSearchController.js.
|
||||
if (criteria.relationship_goal?.length) {
|
||||
const userValues = user.relationship_goal || []
|
||||
if (!criteria.relationship_goal.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.personality_traits?.length) {
|
||||
const userValues = user.personality_traits || []
|
||||
if (!criteria.personality_traits.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.lifestyle?.length) {
|
||||
const userValues = user.lifestyle || []
|
||||
if (!criteria.lifestyle.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.interests?.length) {
|
||||
const userValues = user.interests || []
|
||||
if (!criteria.interests.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.values?.length) {
|
||||
const userValues = user.values || []
|
||||
if (!criteria.values.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
|
||||
// Single-select dating-profile fields: exact match.
|
||||
if (criteria.seeking_gender && user.seeking_gender !== criteria.seeking_gender) return false
|
||||
if (criteria.relationship_view && user.relationship_view !== criteria.relationship_view) return false
|
||||
if (criteria.geo_preference && user.geo_preference !== criteria.geo_preference) return false
|
||||
if (criteria.smoking_status && user.smoking_status !== criteria.smoking_status) return false
|
||||
|
||||
// children_status is multi-select (a user may pick more than one status) —
|
||||
// same overlap semantics as the multi-select fields above.
|
||||
if (criteria.children_status?.length) {
|
||||
const userValues = user.children_status || []
|
||||
if (!criteria.children_status.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
|
||||
if (criteria.country_id && user.country?.id !== criteria.country_id) return false
|
||||
if (criteria.province_id && user.province?.id !== criteria.province_id) return false
|
||||
if (criteria.city_id && user.city?.id !== criteria.city_id) return false
|
||||
@@ -67,8 +104,11 @@ function matchesCriteria (user, criteria = {}) {
|
||||
const MATCH_RELEVANT_FIELDS = [
|
||||
'gender', 'height', 'weight', 'size', 'birthday', 'hair_color', 'eye_color',
|
||||
'marital_status', 'residence_type', 'residence_area', 'vehicle_type',
|
||||
'vehicle_details', 'personal_style', 'job', 'income_range', 'moral_traits',
|
||||
'country', 'province', 'city', 'is_Register'
|
||||
'vehicle_details', 'personal_style', 'job', 'income_range',
|
||||
'country', 'province', 'city', 'is_Register',
|
||||
'relationship_goal', 'seeking_gender', 'desired_age_range', 'personality_traits',
|
||||
'lifestyle', 'interests', 'values', 'relationship_view', 'geo_preference',
|
||||
'children_status', 'smoking_status'
|
||||
]
|
||||
|
||||
function shouldCheck (user) {
|
||||
|
||||
@@ -126,6 +126,11 @@ async function getSuggestedFollowUsers(userId, limit = 15) {
|
||||
previewPost: {
|
||||
_id: previewPost._id,
|
||||
type: previewPost.type,
|
||||
// مدل واقعی مدیا رو با post_images/post_video برمیگردونه، نه همیشه files —
|
||||
// بدون این دو تا، فرانتاند (RN/وب) نمیتونست فایلِ پیشنمایش رو پیدا کنه و
|
||||
// کارت کاملاً خالی میموند
|
||||
post_images: previewPost.post_images,
|
||||
post_video: previewPost.post_video,
|
||||
files: previewPost.files,
|
||||
caption: previewPost.caption,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ const AdvertisingModel = require('../models/AdvertisingModel');
|
||||
const ShopModel = require('../models/ShopModel');
|
||||
const ShopProductListingModel = require('../models/ShopProductListingModel');
|
||||
const BookingConfigModel = require('../models/BookingConfigModel');
|
||||
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES } = require('./moderationStatus');
|
||||
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus');
|
||||
|
||||
// اعتبارسنجی مشترک «آیتم قابللینکشدن» — هم پست (linked_*_id) هم استوری (لینکاورلی) از
|
||||
// همین یه تابع استفاده میکنن تا شرط مالکیت+وضعیت هرکدوم دوبار نوشته نشه. خروجی: شناسهی
|
||||
@@ -30,10 +30,13 @@ async function resolveLinkedEntity(kind, id, userId) {
|
||||
}
|
||||
case 'project': {
|
||||
const project = await ProjectModel.findById(id).select('_id creator_id status').lean();
|
||||
// عین pkg/billboard — هر وضعیتِ واقعی/متعهدشده و غیرِ رد/لغوشده رو میپذیریم، نه فقط
|
||||
// done/accepted؛ وگرنه تقریباً هیچ پروژهی واقعیای (که بیشترِ عمرش pending/ongoing/
|
||||
// temp_accept هست، نه دقیقاً done/accepted) قابللینکشدن نبود
|
||||
if (
|
||||
project &&
|
||||
String(project.creator_id) === String(userId) &&
|
||||
['done', 'accepted'].includes(project.status)
|
||||
PUBLIC_PROJECT_STATUSES.includes(project.status)
|
||||
) {
|
||||
return String(project._id);
|
||||
}
|
||||
@@ -53,14 +56,20 @@ async function resolveLinkedEntity(kind, id, userId) {
|
||||
}
|
||||
case 'shop': {
|
||||
const shop = await ShopModel.findById(id).select('_id owner status').lean();
|
||||
if (shop && String(shop.owner) === String(userId) && shop.status === 'active') {
|
||||
// draft/rejected هنوز واقعاً وجود عمومی ندارن، ولی pending_review (ثبتشده، منتظرِ
|
||||
// تایید) هم مثل بقیهی موارد بالا باید قابللینک باشه — فقط active بیشازحد سختگیرانه بود
|
||||
if (
|
||||
shop &&
|
||||
String(shop.owner) === String(userId) &&
|
||||
['active', 'pending_review'].includes(shop.status)
|
||||
) {
|
||||
return String(shop._id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'shopproduct': {
|
||||
const listing = await ShopProductListingModel.findById(id).select('_id shop status').lean();
|
||||
if (!listing || listing.status !== 'active') return null;
|
||||
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null;
|
||||
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
|
||||
if (shop && String(shop.owner) === String(userId)) {
|
||||
return String(listing._id);
|
||||
|
||||
24
utils/storyPhotoFilters.js
Normal file
24
utils/storyPhotoFilters.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const sharp = require('sharp')
|
||||
|
||||
// فیلترهای رنگ/نورِ سبک اینستاگرام — با sharp (رایگان، بدون لایسنس/کلید API، فقط
|
||||
// پردازش تصویر روی سرور). هر پیشتنظیم ترکیبی از modulate (اشباع/روشنایی/چرخشرنگ)،
|
||||
// tint (رنگآمیزیِ کلی برای حس گرم/سرد/قدیمی) و linear (کنتراست) است.
|
||||
const STORY_PHOTO_FILTERS = {
|
||||
normal: (img) => img,
|
||||
warm: (img) => img.modulate({ brightness: 1.05, saturation: 1.15 }).tint({ r: 255, g: 230, b: 200 }),
|
||||
cool: (img) => img.modulate({ brightness: 1.0, saturation: 1.1 }).tint({ r: 210, g: 230, b: 255 }),
|
||||
bw: (img) => img.grayscale().modulate({ brightness: 1.05 }),
|
||||
vivid: (img) => img.modulate({ saturation: 1.6, brightness: 1.08 }).sharpen(),
|
||||
fade: (img) => img.modulate({ saturation: 0.65, brightness: 1.12 }).linear(0.85, 25),
|
||||
vintage: (img) => img.modulate({ saturation: 0.75, brightness: 1.02 }).tint({ r: 255, g: 235, b: 205 })
|
||||
}
|
||||
|
||||
const STORY_PHOTO_FILTER_NAMES = Object.keys(STORY_PHOTO_FILTERS)
|
||||
|
||||
/** buffer ورودی -> بافرِ jpeg خروجی با فیلترِ اعمالشده. filterName نامعتبر => همون تصویر اصلی. */
|
||||
async function applyStoryPhotoFilter (buffer, filterName) {
|
||||
const apply = STORY_PHOTO_FILTERS[filterName] || STORY_PHOTO_FILTERS.normal
|
||||
return apply(sharp(buffer)).jpeg({ quality: 90 }).toBuffer()
|
||||
}
|
||||
|
||||
module.exports = { STORY_PHOTO_FILTERS, STORY_PHOTO_FILTER_NAMES, applyStoryPhotoFilter }
|
||||
@@ -1,5 +1,5 @@
|
||||
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
|
||||
const USERNAME_MIN_LENGTH = 8
|
||||
const USERNAME_MIN_LENGTH = 6
|
||||
const USERNAME_MAX_LENGTH = 30
|
||||
|
||||
/** first char letter/digit, then up to (max-1) allowed chars */
|
||||
|
||||
Reference in New Issue
Block a user