This commit is contained in:
payacom
2026-09-13 21:32:36 +03:30
parent 90c5f94b57
commit c302aea177
49 changed files with 4063 additions and 920 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://mirror-npm.runflare.com

View File

@@ -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
}

View 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 }

View File

@@ -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
}

View File

@@ -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}`
)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View 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 }

View 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
}

View 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 }

View File

@@ -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)

View 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 }

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View 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()
}

View 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: 'شما دسترسی لازم برای این بخش را ندارید'
})
}
}

View 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()
}

View File

@@ -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
}
})

View 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
View 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)

View File

@@ -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
}
})

View File

@@ -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({

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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

View 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

View File

@@ -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 می‌شد؛ فایل مستقیم از دیسک استریم می‌شه

View File

@@ -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)

View File

@@ -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)
}

View 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

View 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

View 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

View File

@@ -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

View 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 }

View File

@@ -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({})

View File

@@ -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
View 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 }

View File

@@ -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) {

View File

@@ -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,
},

View File

@@ -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);

View 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 }

View File

@@ -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 */