This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

View File

@@ -0,0 +1,29 @@
const NotificationModel = require("../models/NotificationModel");
const PURCHASE_TITLE = "پکیج شما خریداری شد";
const PURCHASE_DESCRIPTION = "یک کاربر پکیج آموزشی شما را خریداری کرد";
const createCoursePurchaseNotification = async ({
ownerId,
buyerId,
courseId,
}) => {
try {
if (!ownerId || !buyerId || !courseId) return;
if (String(ownerId) === String(buyerId)) return;
await NotificationModel.create({
user_id: ownerId,
project_post_id: courseId,
type: "academy_purchase",
title: PURCHASE_TITLE,
description: PURCHASE_DESCRIPTION,
});
} catch (error) {
console.error("Error creating course purchase notification:", error);
}
};
module.exports = {
createCoursePurchaseNotification,
};

134
utils/accountLifecycle.js Normal file
View File

@@ -0,0 +1,134 @@
const crypto = require('crypto')
const mongoose = require('mongoose')
const UserModel = require('../models/UserModel')
const DeletedAccountTombstoneModel = require('../models/DeletedAccountTombstoneModel')
const PostModel = require('../models/PostModel')
const MessageModel = require('../models/MessageModel')
const PostBookmarkModel = require('../models/PostBookmarkModel')
const StoryModel = require('../models/StoryModel')
const NotificationModel = require('../models/NotificationModel')
const ExploreInteractionModel = require('../models/ExploreInteractionModel')
const CourseModel = require('../models/CourseModel')
const AdvertisingModel = require('../models/AdvertisingModel')
const AcademyModel = require('../models/AcademyModel')
const DEACTIVATION_LOCK_DAYS = 7
const DEACTIVATION_PURGE_MONTHS = 6
const DELETION_TOMBSTONE_DAYS = 30
const MS_DAY = 24 * 60 * 60 * 1000
function hashValue (value) {
if (!value) return null
return crypto
.createHash('sha256')
.update(String(value).trim().toLowerCase() + (process.env.APP_SECRET || 'modstagram'))
.digest('hex')
}
function isDeactivated (user) {
return user?.account_status === 'deactivated'
}
function isHiddenFromPublic (user) {
return isDeactivated(user)
}
function formatChatUserLabel (user) {
const name =
`${user?.first_name || ''} ${user?.last_name || ''}`.trim() ||
user?.user_name ||
'کاربر'
if (isDeactivated(user)) {
return `${name} (کاربر غیرفعال)`
}
return name
}
async function getHiddenUserIds () {
return UserModel.find({ account_status: 'deactivated' }).distinct('_id')
}
async function isMobileBlockedByTombstone (mobile) {
const hash = hashValue(mobile)
if (!hash) return false
const hit = await DeletedAccountTombstoneModel.findOne({
mobile_hash: hash,
expires_at: { $gt: new Date() }
}).lean()
return Boolean(hit)
}
async function isUsernameBlockedByTombstone (userName) {
const hash = hashValue(userName)
if (!hash) return false
const hit = await DeletedAccountTombstoneModel.findOne({
user_name_hash: hash,
expires_at: { $gt: new Date() }
}).lean()
return Boolean(hit)
}
async function createDeletionTombstone ({ mobile, userName }) {
const expiresAt = new Date(Date.now() + DELETION_TOMBSTONE_DAYS * MS_DAY)
await DeletedAccountTombstoneModel.create({
mobile_hash: hashValue(mobile),
user_name_hash: hashValue(userName),
expires_at: expiresAt
})
}
async function purgeUserData (userId) {
const uid = new mongoose.Types.ObjectId(String(userId))
await Promise.all([
PostModel.deleteMany({ user_id: uid }),
MessageModel.deleteMany({ $or: [{ senderId: uid }, { receiverId: uid }] }),
PostBookmarkModel.deleteMany({ userId: uid }),
StoryModel.deleteMany({ user_id: uid }),
NotificationModel.deleteMany({ user_id: uid }),
ExploreInteractionModel.deleteMany({
$or: [{ viewerId: uid }, { authorId: uid }]
}),
CourseModel.deleteMany({ user_id: uid }),
AdvertisingModel.deleteMany({ userId: uid }),
AcademyModel.deleteMany({ userId: uid })
])
}
async function purgeExpiredDeactivatedAccounts () {
const now = new Date()
const due = await UserModel.find({
account_status: 'deactivated',
scheduled_purge_at: { $lte: now }
}).select('_id mobile user_name').lean()
for (const user of due) {
await purgeUserData(user._id)
await UserModel.deleteOne({ _id: user._id })
}
return due.length
}
async function purgeExpiredTombstones () {
await DeletedAccountTombstoneModel.deleteMany({
expires_at: { $lte: new Date() }
})
}
module.exports = {
DEACTIVATION_LOCK_DAYS,
DEACTIVATION_PURGE_MONTHS,
DELETION_TOMBSTONE_DAYS,
MS_DAY,
hashValue,
isDeactivated,
isHiddenFromPublic,
formatChatUserLabel,
getHiddenUserIds,
isMobileBlockedByTombstone,
isUsernameBlockedByTombstone,
createDeletionTombstone,
purgeUserData,
purgeExpiredDeactivatedAccounts,
purgeExpiredTombstones
}

10
utils/appLanguages.js Normal file
View File

@@ -0,0 +1,10 @@
/** Mirrors the language codes registered in modstagram-next/src/lib/i18n/registry.ts.
* Kept in sync manually — update both when a language is added/removed. */
const APP_LANGUAGE_CODES = [
'fa', 'en', 'ar', 'ku', 'tr', 'az', 'ru', 'uk', 'es', 'ro',
'sk', 'pl', 'hu', 'hr', 'sr', 'el', 'sv', 'de', 'it', 'hi',
'bn', 'th', 'ms', 'tl', 'fr', 'nl', 'no', 'fi', 'ps', 'uz',
'hy', 'zh-CN', 'zh-TW', 'zh-HK', 'ja', 'ko'
]
module.exports = { APP_LANGUAGE_CODES }

72
utils/appearanceFilter.js Normal file
View File

@@ -0,0 +1,72 @@
function parseOptionalNum(value) {
if (value === undefined || value === null || value === '') return null
const n = parseFloat(String(value).trim())
return Number.isFinite(n) ? n : null
}
function toNumericField(field) {
return {
$convert: { input: `$${field}`, to: 'double', onError: null, onNull: null },
}
}
/**
* فیلتر مشخصات ظاهری کاربر (قد، وزن، سایز، رنگ مو/چشم)
*/
function buildAppearanceUserFilter(query) {
const filter = {}
const {
hair_color,
eye_color,
heightMin,
heightMax,
weightMin,
weightMax,
sizeMin,
sizeMax,
} = query || {}
if (hair_color) filter.hair_color = hair_color
if (eye_color) filter.eye_color = eye_color
const exprAnd = []
const hMin = parseOptionalNum(heightMin)
const hMax = parseOptionalNum(heightMax)
const wMin = parseOptionalNum(weightMin)
const wMax = parseOptionalNum(weightMax)
const sMin = parseOptionalNum(sizeMin)
const sMax = parseOptionalNum(sizeMax)
if (hMin != null) {
exprAnd.push({ $gte: [toNumericField('height'), hMin] })
}
if (hMax != null) {
exprAnd.push({ $lte: [toNumericField('height'), hMax] })
}
if (wMin != null) {
exprAnd.push({ $gte: [toNumericField('weight'), wMin] })
}
if (wMax != null) {
exprAnd.push({ $lte: [toNumericField('weight'), wMax] })
}
if (sMin != null) {
exprAnd.push({ $gte: [toNumericField('size'), sMin] })
}
if (sMax != null) {
exprAnd.push({ $lte: [toNumericField('size'), sMax] })
}
if (exprAnd.length === 1) {
filter.$expr = exprAnd[0]
} else if (exprAnd.length > 1) {
filter.$expr = { $and: exprAnd }
}
return filter
}
module.exports = {
buildAppearanceUserFilter,
parseOptionalNum,
}

20
utils/asyncHandler.js Normal file
View File

@@ -0,0 +1,20 @@
function asyncHandler (handler) {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch((error) => {
console.error('[asyncHandler]', error)
if (res.headersSent) {
return next(error)
}
const status = error?.status || 500
return res.status(status).json({
message: error?.userMessage || 'خطای سرور',
en_message: error?.message || 'internal_error',
})
})
}
}
module.exports = asyncHandler
module.exports.asyncHandler = asyncHandler

63
utils/authSteps.js Normal file
View File

@@ -0,0 +1,63 @@
function isGoogleUser (user) {
return user?.auth_provider === 'google' || Boolean(user?.google_id)
}
function isInstagramUser (user) {
return user?.auth_provider === 'instagram' || Boolean(user?.instagram_id)
}
/** Social logins (Google, Instagram) skip the mobile-password step — they authenticate via the provider instead. */
function isSocialUser (user) {
return isGoogleUser(user) || isInstagramUser(user)
}
function isBasicRegistrationComplete (user) {
const hasPassword = isSocialUser(user) || Boolean(user?.password)
return Boolean(
user?.user_name &&
hasPassword &&
user?.first_name &&
user?.user_type
)
}
function getRegistrationStep (user) {
if (!user?.user_name) return 'user_name'
if (!isSocialUser(user) && !user.password) return 'password'
if (!user.first_name) return 'first_name'
if (!user.user_type) return 'registration_choice'
return 'complete'
}
function isRegistrationIncomplete (user) {
return !isBasicRegistrationComplete(user)
}
function buildAuthResponse (user, token) {
const step = getRegistrationStep(user)
const incomplete = isRegistrationIncomplete(user)
return {
message: 'ورود با موفقیت',
token,
step,
auth_provider:
user.auth_provider ||
(user.google_id ? 'google' : user.instagram_id ? 'instagram' : 'mobile'),
email: user.email || null,
id: user._id != null ? String(user._id) : undefined,
page: incomplete ? 'auth-page' : 'home',
user_type: incomplete ? null : user.user_type,
registrationComplete: !incomplete,
}
}
module.exports = {
isGoogleUser,
isInstagramUser,
isSocialUser,
isBasicRegistrationComplete,
getRegistrationStep,
isRegistrationIncomplete,
buildAuthResponse
}

View File

@@ -1,27 +1,77 @@
const moment = require('moment-jalaali')
const BLOCK_TIERS = [
{ threshold: 15, durationMs: null, permanent: true },
{ threshold: 10, durationMs: 30 * 24 * 60 * 60 * 1000 },
{ threshold: 5, durationMs: 7 * 24 * 60 * 60 * 1000 },
{ threshold: 3, durationMs: 24 * 60 * 60 * 1000 },
]
const BLOCK_THRESHOLD = 5
const BLOCK_DURATION_MS = 7 * 24 * 60 * 60 * 1000
const formatRemainingPersian = (blockedUntil) => {
if (!blockedUntil) return ''
const diffMs = new Date(blockedUntil).getTime() - Date.now()
if (diffMs <= 0) return ''
const totalMinutes = Math.ceil(diffMs / (60 * 1000))
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
const minutes = totalMinutes % 60
const parts = []
if (days > 0) parts.push(`${days} روز`)
if (hours > 0) parts.push(`${hours} ساعت`)
if (minutes > 0 || parts.length === 0) parts.push(`${minutes} دقیقه`)
return parts.join(' و ')
}
const getBlockTier = (blockCount) => {
return BLOCK_TIERS.find((tier) => blockCount >= tier.threshold) || null
}
const applyAutoBlockSuspension = (user) => {
if (user.blocked_by.length >= BLOCK_THRESHOLD) {
user.block_status = true
user.blocked_until = new Date(Date.now() + BLOCK_DURATION_MS)
const blockCount = user.blocked_by?.length || 0
const tier = getBlockTier(blockCount)
if (!tier) {
if (user.block_permanent && user.account_status === 'deactivated') {
user.account_status = 'active'
}
user.block_status = null
user.blocked_until = null
user.block_permanent = false
return
}
user.block_status = true
if (tier.permanent) {
user.block_permanent = true
user.blocked_until = null
user.account_status = 'deactivated'
return
}
user.block_permanent = false
const currentUntil = user.blocked_until ? new Date(user.blocked_until).getTime() : 0
const newUntil = Date.now() + tier.durationMs
if (!user.blocked_until || newUntil > currentUntil) {
user.blocked_until = new Date(newUntil)
}
}
const clearAutoBlockSuspension = (user) => {
if (user.blocked_by.length < BLOCK_THRESHOLD && user.blocked_until) {
user.block_status = null
user.blocked_until = null
}
applyAutoBlockSuspension(user)
}
const isUserCurrentlyBlocked = (user) => {
if (!user.block_status) {
if (!user?.block_status) {
return false
}
if (user.block_permanent) {
return true
}
if (user.blocked_until) {
if (new Date() >= new Date(user.blocked_until)) {
return false
@@ -33,14 +83,26 @@ const isUserCurrentlyBlocked = (user) => {
}
const expireBlockSuspensionIfNeeded = async (user) => {
if (user.block_permanent) {
return user
}
if (!user.blocked_until) {
return user
}
if (new Date() >= new Date(user.blocked_until)) {
user.block_status = null
user.blocked_until = null
await user.save()
const blockCount = user.blocked_by?.length || 0
applyAutoBlockSuspension(user)
if (!isUserCurrentlyBlocked(user)) {
user.block_status = null
user.blocked_until = null
}
try {
await user.save({ validateModifiedOnly: true })
} catch (saveError) {
console.error('[expireBlockSuspensionIfNeeded] save failed:', saveError)
}
}
return user
@@ -51,27 +113,38 @@ const getBlockedAccountResponse = (user) => {
return null
}
const untilText = user.blocked_until
? moment(user.blocked_until).format('jYYYY/jMM/jDD')
: null
if (user.block_permanent || (user.blocked_by?.length || 0) >= 15) {
return {
status: 'error',
code: 403,
message:
'کاربر گرامی اکانت شما به علت تخلف زیاد و گزارش توسط کاربران دیگر غیر فعال می باشد.',
type: 'block',
blocked_until: null,
permanent: true,
}
}
const remaining = formatRemainingPersian(user.blocked_until)
return {
status: 'error',
code: 403,
message: untilText
? `حساب کاربری شما به دلیل بلاک شدن توسط ${BLOCK_THRESHOLD} کاربر تا تاریخ ${untilText} غیرفعال است.`
: 'حساب کاربری شما مسدود شده است، برای اطلاعات بیشتر با پشتیبانی تماس بگیرید!',
message: remaining
? `به مدت ${remaining} از انجام هر گونه عملیات در سایت مسدود هستید.`
: 'حساب کاربری شما موقتاً مسدود شده است.',
type: 'block',
blocked_until: user.blocked_until || null
blocked_until: user.blocked_until || null,
permanent: false,
}
}
module.exports = {
BLOCK_THRESHOLD,
BLOCK_DURATION_MS,
BLOCK_TIERS,
applyAutoBlockSuspension,
clearAutoBlockSuspension,
isUserCurrentlyBlocked,
expireBlockSuspensionIfNeeded,
getBlockedAccountResponse
getBlockedAccountResponse,
formatRemainingPersian,
}

View File

@@ -0,0 +1,35 @@
const WalletTransactionModel = require('../models/WalletTransactionModel')
/** Deposit required at booking time, when the provider has enabled it. */
const BOOKING_DEPOSIT_RATE = 0.3
/** Platform commission taken from the deposit amount. */
const BOOKING_COMMISSION_RATE = 0.05
/** Credits the provider's booking wallet bucket (net of platform commission) and pushes a live notification if possible. */
async function creditBookingShare (io, userId, amount, referenceId, description) {
if (!userId || amount <= 0) return
await WalletTransactionModel.create({
user: userId,
bucket: 'booking',
type: 'credit',
amount,
referenceType: 'payment',
referenceId,
description
})
if (io) {
try {
io.to(`user:${String(userId)}`).emit('notification', {
type: 'booking_commission',
title: 'واریز به کیف پول',
description
})
} catch (_) {}
}
}
module.exports = {
BOOKING_DEPOSIT_RATE,
BOOKING_COMMISSION_RATE,
creditBookingShare
}

57
utils/cache.js Normal file
View File

@@ -0,0 +1,57 @@
const { getRedisClient } = require('./redisClient')
// الگوی cache-aside عمومی: هر جایی یه کوئری/محاسبه‌ی سنگین و پرتکرار داره، به‌جای
// شمارنده‌ی incremental (که نگه‌داشتنِ درستش نیاز به ردیابیِ دقیقِ *همه‌ی* نقاطِ
// تغییر داره و راحت drift پیدا می‌کنه)، فقط نتیجه‌ی همون کوئریِ فعلی رو با یه TTLِ
// کوتاه کش می‌کنه. اگه Redis در دسترس نباشه، هر بار مستقیم fetchFn اجرا می‌شه —
// دقیقاً همون رفتارِ قبل از اضافه‌شدنِ کش (کندتر، ولی همیشه درست)
async function getOrSetCache (key, ttlSeconds, fetchFn) {
const redis = getRedisClient()
if (!redis) return fetchFn()
try {
const cached = await redis.get(key)
if (cached != null) return JSON.parse(cached)
} catch (err) {
console.error(`[cache] read error for ${key}:`, err.message)
}
const fresh = await fetchFn()
try {
await redis.set(key, JSON.stringify(fresh), { EX: ttlSeconds })
} catch (err) {
console.error(`[cache] write error for ${key}:`, err.message)
}
return fresh
}
// موقعِ تغییرِ داده (پیام جدید/خونده‌شدنِ پیام، لایک/آنلایک، پست جدید و...) باید
// صریحاً صدا زده بشه تا کشِ مرتبط بلافاصله باطل بشه، نه اینکه تا انقضای TTL کهنه بمونه
async function invalidateCache (key) {
const redis = getRedisClient()
if (!redis) return
try {
await redis.del(key)
} catch (err) {
console.error(`[cache] invalidate error for ${key}:`, err.message)
}
}
// برای وقتی چندین کلید هم‌زمان باید باطل بشن (مثلاً کشِ فید همه‌ی صفحه‌های اکسپلور)
async function invalidateCachePattern (pattern) {
const redis = getRedisClient()
if (!redis) return
try {
const keys = []
for await (const key of redis.scanIterator({ MATCH: pattern, COUNT: 100 })) {
keys.push(key)
}
if (keys.length) await redis.del(keys)
} catch (err) {
console.error(`[cache] pattern invalidate error for ${pattern}:`, err.message)
}
}
module.exports = { getOrSetCache, invalidateCache, invalidateCachePattern }

View File

@@ -0,0 +1,34 @@
const CommentLikeModel = require('../models/CommentLikeModel')
async function attachCommentLikeFields(comments, viewerId) {
if (!comments?.length) return comments
const ids = comments.map((c) => c._id)
const [likeAgg, userLikes] = await Promise.all([
CommentLikeModel.aggregate([
{ $match: { commentId: { $in: ids } } },
{ $group: { _id: '$commentId', count: { $sum: 1 } } },
]),
viewerId
? CommentLikeModel.find({
userId: viewerId,
commentId: { $in: ids },
})
.select('commentId')
.lean()
: [],
])
const countMap = Object.fromEntries(
likeAgg.map((row) => [String(row._id), row.count])
)
const likedSet = new Set(userLikes.map((l) => String(l.commentId)))
return comments.map((c) => ({
...c,
likesCount: countMap[String(c._id)] || 0,
is_liked: likedSet.has(String(c._id)),
}))
}
module.exports = { attachCommentLikeFields }

View File

@@ -1,57 +1,81 @@
const NotificationModel = require('../models/NotificationModel')
const { resolveCourseOwnerId } = require('./likeNotification')
const { notifyUser } = require("./notifyUser");
const COMMENT_TITLE = 'یک کامنت برای شما ثبت شد'
const COMMENT_DESCRIPTION = 'یک کامنت برای شما ثبت شد'
const RATING_TITLE = 'یک امتیاز برای شما ثبت شد'
const RATING_DESCRIPTION = 'یک امتیاز برای شما ثبت شد'
const COMMENT_TITLE = "یک کامنت برای شما ثبت شد";
const COMMENT_DESCRIPTION = "یک کامنت برای شما ثبت شد";
const RATING_TITLE = "یک امتیاز برای شما ثبت شد";
const RATING_DESCRIPTION = "یک امتیاز برای شما ثبت شد";
function normalizeId(value) {
if (value == null) return null;
if (typeof value === "object" && value._id) return String(value._id);
return String(value);
}
/**
* @param {object} params
* @param {import('socket.io').Server} [params.io] بدون این، فقط توی دیتابیس ثبت می‌شه، نه سوکت/پوش زنده
* @param {'post_comment'|'profile_comment'|'academy_comment'|'billboard_comment'|'shop_product_comment'} [params.type]
*/
const createCommentNotification = async ({
io,
ownerId,
commenterId,
entityId,
type = 'post_comment'
type = "post_comment",
}) => {
try {
if (!ownerId || !commenterId || !entityId) return
if (String(ownerId) === String(commenterId)) return
const owner = normalizeId(ownerId);
const commenter = normalizeId(commenterId);
const entity = normalizeId(entityId);
await NotificationModel.create({
user_id: ownerId,
project_post_id: entityId,
if (!owner || !commenter || !entity) return;
if (owner === commenter) return;
await notifyUser(io, owner, {
type,
title: COMMENT_TITLE,
description: COMMENT_DESCRIPTION
})
description: COMMENT_DESCRIPTION,
project_post_id: entity,
actor_id: commenter,
});
} catch (error) {
console.error('Error creating comment notification:', error)
console.error("Error creating comment notification:", error);
}
}
};
/**
* @param {object} params
* @param {import('socket.io').Server} [params.io] بدون این، فقط توی دیتابیس ثبت می‌شه، نه سوکت/پوش زنده
* @param {'post_rating'|'profile_rating'|'academy_rating'|'billboard_rating'} [params.type]
*/
const createRatingNotification = async ({
io,
ownerId,
raterId,
entityId,
type = 'billboard_rating'
type = "billboard_rating",
}) => {
try {
if (!ownerId || !raterId || !entityId) return
if (String(ownerId) === String(raterId)) return
const owner = normalizeId(ownerId);
const rater = normalizeId(raterId);
const entity = normalizeId(entityId);
await NotificationModel.create({
user_id: ownerId,
project_post_id: entityId,
if (!owner || !rater || !entity) return;
if (owner === rater) return;
await notifyUser(io, owner, {
type,
title: RATING_TITLE,
description: RATING_DESCRIPTION
})
description: RATING_DESCRIPTION,
project_post_id: entity,
actor_id: rater,
});
} catch (error) {
console.error('Error creating rating notification:', error)
console.error("Error creating rating notification:", error);
}
}
};
module.exports = {
createCommentNotification,
createRatingNotification,
resolveCourseOwnerId
}
};

View File

@@ -7,7 +7,7 @@ const findExistingUserRating = (creatorId, targetUserId) => {
return CommentModel.findOne({
creator: creatorId,
user: targetUserId,
rating: RATED_FILTER
rating: RATED_FILTER,
})
}
@@ -31,28 +31,25 @@ const resolveRatingForComment = async ({ creatorId, targetUserId, rate }) => {
}
if (!rate || rate < 1 || rate > 5) {
return { ratingValue: null, isNewRating: false, requiresRating: true }
return { ratingValue: null, isNewRating: false, requiresRating: false }
}
return { ratingValue: rate, isNewRating: true }
}
const getUserLevel = (user, totalRating) => {
if (user.expertise === 'مدل') {
if (totalRating <= 50) return 'تازه وارد'
if (totalRating <= 100) return 'استاندارد'
if (totalRating <= 300) return 'حرفه‌ای'
/** rate = مجموع ستاره‌های دریافتی | user_score = میانگین ۱ تا ۵ */
const getUserLevel = (user, totalStars) => {
if (totalStars <= 500) return 'تازه وارد'
if (totalStars <= 2000) return 'استاندارد'
if (totalStars <= 10000) return 'حرفه‌ای'
if (user.expertise === 'مدل') return 'سپورمدل'
if (['زیبایی', 'عکاس', 'آرایشگر'].includes(user.expertise)) {
return 'استاد'
}
if (['زیبایی', 'عکاس'].includes(user.expertise)) {
if (totalRating <= 50) return 'تازه وارد'
if (totalRating <= 100) return 'استاندارد'
if (totalRating <= 300) return 'حرفه‌ای'
return 'استاد'
}
return user.user_level
return 'استاد'
}
const recalculateUserRating = async (targetUserId) => {
@@ -61,7 +58,7 @@ const recalculateUserRating = async (targetUserId) => {
const ratedComments = await CommentModel.find({
user: targetUserId,
rating: RATED_FILTER
rating: RATED_FILTER,
}).sort({ createdAt: 1 })
const uniqueRatingsByCreator = new Map()
@@ -73,12 +70,13 @@ const recalculateUserRating = async (targetUserId) => {
}
const ratings = Array.from(uniqueRatingsByCreator.values())
const totalRating = ratings.reduce((acc, rating) => acc + rating, 0)
const totalStars = ratings.reduce((acc, rating) => acc + rating, 0)
const ratedCount = ratings.length
user.user_score = totalRating
user.user_level = getUserLevel(user, totalRating)
user.rate = ratedCount > 0 ? (totalRating / ratedCount).toFixed(1) : '0'
user.rate = String(totalStars)
user.user_score =
ratedCount > 0 ? (totalStars / ratedCount).toFixed(1) : '0'
user.user_level = getUserLevel(user, totalStars)
await user.save()
}
@@ -87,5 +85,6 @@ module.exports = {
findExistingUserRating,
hasCreatorRatedUser,
resolveRatingForComment,
recalculateUserRating
recalculateUserRating,
getUserLevel,
}

View File

@@ -0,0 +1,38 @@
const { resolveCommentModerationStatus } = require('./contentModeration')
const STATUS_FALLBACKS = {
temp_accept: ['temp_accept', 'accepted', 'pending'],
rejected: ['rejected', 'pending'],
}
/**
* Save comment with moderation status; fall back if server schema lacks newer enum values.
*/
async function saveCommentWithModerationStatus(Model, data, textField = 'comment') {
const text = data[textField] ?? data.text ?? ''
const preferredStatus = resolveCommentModerationStatus(text)
const candidates = STATUS_FALLBACKS[preferredStatus] ?? [preferredStatus]
let lastError = null
for (let i = 0; i < candidates.length; i += 1) {
const status = candidates[i]
const doc = new Model({ ...data, status })
try {
await doc.save()
return doc
} catch (err) {
lastError = err
const isLast = i === candidates.length - 1
if (err?.name === 'ValidationError' && !isLast) {
continue
}
throw err
}
}
throw lastError ?? new Error('Failed to save comment')
}
module.exports = {
saveCommentWithModerationStatus,
}

View File

@@ -0,0 +1,63 @@
/**
* Moderation helpers for comments and posts (Persian profanity + explicit cues).
*/
const PROFANITY_PATTERNS = [
/\b(کس|کیر|کون|جنده|حروم|لاشی|بی\s*شعور|گه|گایید|گائید)\b/i,
/\b(fuck|shit|bitch|asshole)\b/i,
];
const EXPLICIT_CONTENT_PATTERNS = [
/\b(برهنگ|برهنگی|پورن|porn|nude|naked|sex)\b/i,
/\b(سکس|جنسی\s*صریح)\b/i,
];
function normalizeText(text) {
if (!text || typeof text !== 'string') return '';
return text
.replace(/[\u200c\u200f]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function matchesAny(text, patterns) {
const normalized = normalizeText(text);
if (!normalized) return false;
return patterns.some((pattern) => pattern.test(normalized));
}
function containsProfanity(text) {
return matchesAny(text, PROFANITY_PATTERNS);
}
function containsExplicitContent(text) {
return matchesAny(text, EXPLICIT_CONTENT_PATTERNS);
}
/** Comment / vitrine / academy comment status */
function resolveCommentModerationStatus(text) {
if (containsProfanity(text) || containsExplicitContent(text)) {
return 'rejected';
}
return 'temp_accept';
}
/**
* Post moderation:
* - reject: profanity / explicit text in caption
* - temp_accept: visible in app, awaits manual full approval
*/
function resolvePostModerationStatus({ caption, filePaths = [] }) {
if (containsProfanity(caption) || containsExplicitContent(caption)) {
return { status: 'reject', reason: 'caption_violation' };
}
return { status: 'temp_accept', reason: 'awaiting_review' };
}
module.exports = {
containsProfanity,
containsExplicitContent,
resolveCommentModerationStatus,
resolvePostModerationStatus,
};

View File

@@ -0,0 +1,83 @@
const rateLimit = require('express-rate-limit')
const { getRedisClient } = require('./redisClient')
/**
* پیاده‌سازیِ سبکِ خودمون از Store — به‌جای rate-limit-redis. علتش: rate-limit-redis
* موقعِ ساختنِ RedisStore (سازنده، همون لحظه‌ی require شدنِ این ماژول، خیلی زودتر از
* وصل‌شدنِ کاملِ Redis) فوراً و بدون await دو تا SCRIPT LOAD می‌فرسته و نتیجه‌شون
* (Promise) رو *برای همیشه‌ی عمرِ اون instance* کش می‌کنه. اگه Redis همون لحظه‌ی
* boot در دسترس نباشه، اون دو Promise رد می‌شن (unhandled rejection، حتی می‌تونه
* کل پروسه رو کرش کنه) و از اون به بعد، حتی وقتی Redis بعداً وصل بشه، همون Store
* برای همیشه شکسته می‌مونه چون SHAی اسکریپت هیچ‌وقت دوباره لود نمی‌شه.
*
* این پیاده‌سازی فقط از INCR/PEXPIRE/DECR/DEL استفاده می‌کنه (بدون اسکریپتِ لوا،
* بدون هیچ حالتِ ساخته‌شده‌ی یک‌بارمصرف) — هر متد کاملاً مستقلِ متدهای دیگه‌ست، پس
* هیچ حالتی نیست که «یه بار شکست خورد، برای همیشه خرابه»
*/
class SimpleRedisRateLimitStore {
constructor (prefix, windowMs) {
this.prefix = prefix
this.windowMs = windowMs
}
async increment (key) {
const redis = getRedisClient()
if (!redis) throw new Error('Redis not ready')
const redisKey = this.prefix + key
const totalHits = await redis.incr(redisKey)
if (totalHits === 1) {
// اولین ضربه‌ی این پنجره — شروع‌کنندهٔ TTL. اگه دقیقاً همین‌جا پروسه کرش کنه
// (قبل از رسیدن به pExpire)، این کلید بدون انقضا می‌مونه — یه edge-caseِ
// شناخته‌شده و پذیرفته‌شده برای این نوع شمارنده‌ی ساده، نه یه شمارنده‌ی
// امنیتی‌ای که باید صددرصد atomic باشه
await redis.pExpire(redisKey, this.windowMs)
}
const ttl = await redis.pTTL(redisKey)
return {
totalHits,
resetTime: ttl > 0 ? new Date(Date.now() + ttl) : undefined
}
}
async decrement (key) {
const redis = getRedisClient()
if (!redis) return
await redis.decr(this.prefix + key)
}
async resetKey (key) {
const redis = getRedisClient()
if (!redis) return
await redis.del(this.prefix + key)
}
}
/**
* می‌سازه: یه میدل‌ورِ ریت‌لیمیت که خودش روی هر درخواست بین Redis (وقتی وصله —
* محدودیتِ سراسری بین همه‌ی اینستنس‌های سرور، برای مقیاس‌پذیری/امنیتِ واقعی لازمه —
* مثلاً برای جلوگیری از brute-force روی لاگین وقتی چند سرور پشتِ لودبالانسر باشه)
* و حافظه‌ی in-process (fallback وقتی Redis در دسترس نیست — فقط per-instance، ولی
* از کرش‌کردن یا بی‌اثرشدنِ کاملِ محدودیت بهتره) سوییچ می‌کنه.
*
* چون express-rate-limit اجازه نمی‌ده store رو بعد از ساختنِ میدل‌ور عوض کنی، دو
* نمونه‌ی کاملاً جدا ساخته می‌شه و روی هر درخواست، بر اساسِ آماده‌بودنِ فعلیِ Redis،
* یکی‌شون صدا زده می‌شه.
*
* @param {string} prefix - پیشوندِ یکتا برای کلیدهای این limiter توی Redis (تا با
* limiterهای دیگه قاطی نشن)
* @param {import('express-rate-limit').Options} options
*/
function createRateLimiter (prefix, options) {
const memoryLimiter = rateLimit(options)
const redisLimiter = rateLimit({
...options,
store: new SimpleRedisRateLimitStore(`rl:${prefix}:`, options.windowMs)
})
return (req, res, next) => {
const limiter = getRedisClient() ? redisLimiter : memoryLimiter
limiter(req, res, next)
}
}
module.exports = createRateLimiter

View File

@@ -0,0 +1,39 @@
const fs = require('fs-extra')
const path = require('path')
const axios = require('axios')
const EXT_BY_CONTENT_TYPE = {
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/jpeg': '.jpg',
}
/**
* Downloads a remote image and stores it under storage/profiles, the same
* directory setProfileImageController.js already uses for avatar uploads.
* Returns the relative "/profiles/<file>" path expected on UserModel.profile_image.
*/
async function downloadImageToProfiles(url, baseName) {
const uploadDir = path.join(__dirname, '../storage/profiles')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 10000,
})
const contentType = String(response.headers['content-type'] || '').split(';')[0].trim()
const ext = EXT_BY_CONTENT_TYPE[contentType] || '.jpg'
const safeBaseName = String(baseName || 'user').replace(/[^\w.\-]/g, '_')
const uniqueFileName = `${safeBaseName}-${Date.now()}${ext}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.writeFile(filePath, Buffer.from(response.data))
return `/profiles/${uniqueFileName}`
}
module.exports = { downloadImageToProfiles }

29
utils/e2ee.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* E2EE helpers — server never decrypts; only detects envelopes and opaques previews.
*/
const E2EE_PREVIEW = '🔒 پیام رمزنگاری‌شده'
function isE2eeEnvelope(content) {
if (!content || typeof content !== 'string') return false
const trimmed = content.trim()
if (!trimmed.startsWith('{')) return false
try {
const parsed = JSON.parse(trimmed)
return Boolean(parsed && parsed.e2ee === true && parsed.ciphertext)
} catch {
return false
}
}
function opaquePreview(content, maxLen = 80) {
if (isE2eeEnvelope(content)) return E2EE_PREVIEW
const text = String(content || 'پیام')
return text.slice(0, maxLen)
}
module.exports = {
E2EE_PREVIEW,
isE2eeEnvelope,
opaquePreview
}

80
utils/emailService.js Normal file
View File

@@ -0,0 +1,80 @@
let nodemailer = null
try {
nodemailer = require('nodemailer')
} catch (error) {
console.warn('[emailService] nodemailer not installed:', error.message)
}
let transporter = null
function getTransporter () {
if (!nodemailer) return null
if (transporter) return transporter
const host = process.env.SMTP_HOST
const port = Number(process.env.SMTP_PORT || 587)
const user = process.env.SMTP_USER
const pass = process.env.SMTP_PASS
if (!host || !user || !pass) {
return null
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass }
})
return transporter
}
function isEmailConfigured () {
return Boolean(nodemailer && getTransporter())
}
async function sendEmail ({ to, subject, text, html }) {
const mailer = getTransporter()
if (!mailer) {
console.warn('[emailService] SMTP is not configured. Email not sent to:', to)
return false
}
await mailer.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to,
subject,
text,
html
})
return true
}
async function sendOtpEmail (to, otp, purpose = 'verification') {
const subjectMap = {
verification: 'کد تایید مدستاگرام',
password_reset: 'بازیابی کلمه عبور مدستاگرام'
}
const subject = subjectMap[purpose] || subjectMap.verification
const text = `کد تایید شما: ${otp}\n\nاین کد تا ۲ دقیقه معتبر است.\nمدستاگرام`
const html = `
<div dir="rtl" style="font-family:Tahoma,sans-serif;line-height:1.8">
<h2>${subject}</h2>
<p>کد تایید شما:</p>
<p style="font-size:24px;font-weight:bold;letter-spacing:4px">${otp}</p>
<p>این کد تا ۲ دقیقه معتبر است.</p>
<p>مدستاگرام</p>
</div>
`
return sendEmail({ to, subject, text, html })
}
module.exports = {
isEmailConfigured,
sendEmail,
sendOtpEmail
}

57
utils/ensureGeoData.js Normal file
View File

@@ -0,0 +1,57 @@
const fs = require('fs')
const path = require('path')
const { CountryModel, ProvinceModel, CityModel } = require('../models/StateCity')
/**
* Single source of truth for keeping the Country/Province/City reference
* collections in sync with data/geo/{countries,provinces,cities}.json.
* Runs automatically on every backend boot (see boot/mongo.js) — no manual
* script to remember to re-run when the JSON files change. Cheap no-op when
* already in sync (only a countDocuments() + JSON parse), unless force=true.
*/
async function ensureGeoData(force = false) {
const dataDir = path.join(__dirname, '../data/geo')
const countries = JSON.parse(fs.readFileSync(path.join(dataDir, 'countries.json'), 'utf8'))
const provinces = JSON.parse(fs.readFileSync(path.join(dataDir, 'provinces.json'), 'utf8'))
const cities = JSON.parse(fs.readFileSync(path.join(dataDir, 'cities.json'), 'utf8'))
const provincesWithFaCount = provinces.filter((p) => p.name_fa).length
const [countryCount, provinceCount, cityCount, provinceFaCount] = await Promise.all([
CountryModel.countDocuments(),
ProvinceModel.countDocuments(),
CityModel.countDocuments(),
ProvinceModel.countDocuments({ name_fa: { $exists: true, $ne: null } })
])
const inSync =
countryCount === countries.length &&
provinceCount === provinces.length &&
cityCount === cities.length &&
provinceFaCount === provincesWithFaCount
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...`)
await CountryModel.deleteMany({})
await ProvinceModel.deleteMany({})
await CityModel.deleteMany({})
await CountryModel.insertMany(countries)
await ProvinceModel.insertMany(
provinces.map((p) => ({ id: p.id, name: p.name, name_fa: p.name_fa, slug: p.slug, country_id: p.country_id }))
)
await CityModel.insertMany(
cities.map((c) => ({
id: c.id,
name: c.name,
name_fa: c.name_fa,
slug: c.slug,
province_id: c.province_id,
country_id: c.country_id
}))
)
console.log(`[boot] Geo reference data reseeded: ${countries.length} countries, ${provinces.length} provinces, ${cities.length} cities.`)
}
module.exports = { ensureGeoData }

103
utils/ensureUserIndexes.js Normal file
View File

@@ -0,0 +1,103 @@
const UserModel = require('../models/UserModel')
const SPARSE_UNIQUE_FIELDS = ['mobile', 'email', 'google_id']
/**
* ایندکس unique غیر-sparse روی mobile باعث می‌شود فقط یک کاربر بدون موبایل
* در دیتابیس مجاز باشد (dup key: { mobile: null }).
* این تابع nullها را unset می‌کند و ایندکس‌ها را sparse می‌سازد.
*/
async function ensureUserSparseUniqueIndexes () {
const collection = UserModel.collection
for (const field of SPARSE_UNIQUE_FIELDS) {
try {
const unsetResult = await collection.updateMany(
{ [field]: null },
{ $unset: { [field]: '' } }
)
if (unsetResult.modifiedCount > 0) {
console.log(
`[ensureUserIndexes] unset null ${field}: ${unsetResult.modifiedCount}`
)
}
} catch (err) {
console.error(`[ensureUserIndexes] unset ${field} failed:`, err.message)
}
}
try {
const indexes = await collection.indexes()
for (const field of SPARSE_UNIQUE_FIELDS) {
const existing = indexes.filter(
(idx) =>
idx.key &&
Object.keys(idx.key).length === 1 &&
idx.key[field] === 1 &&
idx.unique
)
for (const idx of existing) {
const isSparse = Boolean(idx.sparse)
if (!isSparse) {
try {
await collection.dropIndex(idx.name)
console.log(`[ensureUserIndexes] dropped non-sparse index ${idx.name}`)
} catch (err) {
console.error(
`[ensureUserIndexes] drop ${idx.name} failed:`,
err.message
)
}
}
}
}
for (const field of SPARSE_UNIQUE_FIELDS) {
const name = `${field}_1`
try {
await collection.createIndex(
{ [field]: 1 },
{ unique: true, sparse: true, name, background: true }
)
console.log(`[ensureUserIndexes] ensured sparse unique index ${name}`)
} catch (err) {
// already exists with same options
if (err?.code !== 85 && err?.code !== 86) {
console.error(
`[ensureUserIndexes] create ${name} failed:`,
err.message
)
}
}
}
} catch (err) {
console.error('[ensureUserIndexes] index sync failed:', err.message)
}
// کاربران pending بدون کارت ملی نباید تیک خاکستری داشته باشند
try {
const cleaned = await collection.updateMany(
{
is_verified: 'pending',
$or: [
{ national_card_image: null },
{ national_card_image: { $exists: false } },
{ national_card_image: '' },
],
},
{ $set: { is_verified: 'none' } }
)
if (cleaned.modifiedCount > 0) {
console.log(
`[ensureUserIndexes] cleared pending without card: ${cleaned.modifiedCount}`
)
}
} catch (err) {
console.error('[ensureUserIndexes] pending cleanup failed:', err.message)
}
}
module.exports = {
ensureUserSparseUniqueIndexes,
}

View File

@@ -4,6 +4,12 @@ const OfferModel = require('../models/OfferModel')
const UserModel = require('../models/UserModel')
const PostModel = require('../models/PostModel')
const ExploreInteractionModel = require('../models/ExploreInteractionModel')
const ShopProductListingModel = require('../models/ShopProductListingModel')
const ShopCategoryModel = require('../models/ShopCategoryModel')
const CourseModel = require('../models/CourseModel')
const AcademyCategoryModel = require('../models/AcademyCategoryModel')
const AdvertisingModel = require('../models/AdvertisingModel')
const AdvertisingCategoryModel = require('../models/AdvertisingCategoryModel')
const EXPERTISE_ALIASES = {
مدل: ['مدل', 'مدلینگ'],
@@ -58,6 +64,12 @@ function shuffleArray(items, seed = Date.now()) {
return copy
}
function sortByRecency(posts) {
return [...posts].sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
)
}
function interleaveWeighted(primary, secondary, primaryRatio = 0.7) {
if (!primary.length) return shuffleArray(secondary)
if (!secondary.length) return shuffleArray(primary)
@@ -176,11 +188,10 @@ function rankColdStartReels(pool, usersById, seedAuthor, seedPost) {
}
})
const seedKey = seedPost?._id ? String(seedPost._id) : '0'
const shuffledSame = shuffleArray(sameCategory, seedKey.length * 997)
const shuffledOthers = shuffleArray(others, seedKey.length * 499)
const sortedSame = sortByRecency(sameCategory)
const sortedOthers = sortByRecency(others)
return interleaveWeighted(shuffledSame, shuffledOthers, 0.72)
return interleaveWeighted(sortedSame, sortedOthers, 0.72)
}
function diversifyByAuthor(posts, maxPerPage = 2) {
@@ -363,8 +374,142 @@ async function buildUserExploreSignals(userId) {
}
}
/**
* Builds a decayed { expertiseTag: weight } map from the viewer's post/shop/academy/billboard
* interactions, bridged through each category model's admin-tagged `relatedExpertise` (posts
* bridge directly through their author's own expertise/sub_expertise, since posts don't have a
* separate category model). This is the layer that lets e.g. photography-post interest surface
* camera-lens shop products or photography projects/billboards — reuses the same
* interactionDecay/ACTION_WEIGHTS machinery as buildUserExploreSignals, just with a wider set
* of inputs.
*/
async function buildUnifiedInterestScore(userId) {
const interestWeights = {}
if (!userId) return interestWeights
const bump = (tag, weight) => {
if (!tag) return
interestWeights[tag] = (interestWeights[tag] || 0) + weight
}
const interactions = await ExploreInteractionModel.find({
viewerId: userId,
targetType: { $in: ['post', 'shop_product', 'academy_course', 'billboard'] }
})
.sort({ createdAt: -1 })
.limit(600)
.lean()
if (!interactions.length) return interestWeights
const postInteractions = interactions.filter((i) => i.targetType === 'post')
if (postInteractions.length) {
const postIds = [...new Set(postInteractions.map((i) => String(i.targetId)))]
const posts = await PostModel.find({ _id: { $in: postIds } }).select('user_id').lean()
const authorIdByPost = Object.fromEntries(posts.map((p) => [String(p._id), String(p.user_id)]))
const authorIds = [...new Set(Object.values(authorIdByPost))]
const authors = authorIds.length
? await UserModel.find({ _id: { $in: authorIds } }).select('expertise sub_expertise').lean()
: []
const authorById = Object.fromEntries(authors.map((a) => [String(a._id), a]))
postInteractions.forEach((item) => {
const decay = interactionDecay(item.createdAt)
const base = ACTION_WEIGHTS[item.action] ?? ACTION_WEIGHTS.view
const weight = base * decay
const author = authorById[authorIdByPost[String(item.targetId)]]
if (!author) return
bump(author.expertise, weight)
;(author.sub_expertise || []).forEach((tag) => bump(tag, weight * 0.6))
})
}
const shopProductIds = interactions
.filter((i) => i.targetType === 'shop_product')
.map((i) => i.targetId)
const academyCourseIds = interactions
.filter((i) => i.targetType === 'academy_course')
.map((i) => i.targetId)
const billboardIds = interactions
.filter((i) => i.targetType === 'billboard')
.map((i) => i.targetId)
const [listings, courses, billboards] = await Promise.all([
shopProductIds.length
? ShopProductListingModel.find({ _id: { $in: shopProductIds } }).select('category').lean()
: [],
academyCourseIds.length
? CourseModel.find({ _id: { $in: academyCourseIds } }).select('category').lean()
: [],
billboardIds.length
? AdvertisingModel.find({ _id: { $in: billboardIds } }).select('category').lean()
: []
])
const listingCategoryById = Object.fromEntries(listings.map((l) => [String(l._id), l.category]))
const courseCategoryById = Object.fromEntries(courses.map((c) => [String(c._id), c.category]))
const billboardCategoryById = Object.fromEntries(billboards.map((b) => [String(b._id), b.category]))
const shopCategoryNames = [...new Set(listings.map((l) => l.category).filter(Boolean))]
const courseCategoryNames = [...new Set(courses.map((c) => c.category).filter(Boolean))]
const billboardCategoryNames = [...new Set(billboards.map((b) => b.category).filter(Boolean))]
const [shopCats, academyCats, adCats] = await Promise.all([
shopCategoryNames.length
? ShopCategoryModel.find({ category: { $in: shopCategoryNames } })
.select('category relatedExpertise')
.lean()
: [],
courseCategoryNames.length
? AcademyCategoryModel.find({ title: { $in: courseCategoryNames } })
.select('title relatedExpertise')
.lean()
: [],
billboardCategoryNames.length
? AdvertisingCategoryModel.find({ title: { $in: billboardCategoryNames } })
.select('title relatedExpertise')
.lean()
: []
])
const shopExpertiseByCategory = Object.fromEntries(
shopCats.map((c) => [c.category, c.relatedExpertise || []])
)
const academyExpertiseByCategory = Object.fromEntries(
academyCats.map((c) => [c.title, c.relatedExpertise || []])
)
const adExpertiseByCategory = Object.fromEntries(
adCats.map((c) => [c.title, c.relatedExpertise || []])
)
interactions.forEach((item) => {
const decay = interactionDecay(item.createdAt)
const base = ACTION_WEIGHTS[item.action] ?? ACTION_WEIGHTS.view
const weight = base * decay
const targetKey = String(item.targetId)
let tags = []
if (item.targetType === 'shop_product') {
tags = shopExpertiseByCategory[listingCategoryById[targetKey]] || []
} else if (item.targetType === 'academy_course') {
tags = academyExpertiseByCategory[courseCategoryById[targetKey]] || []
} else if (item.targetType === 'billboard') {
tags = adExpertiseByCategory[billboardCategoryById[targetKey]] || []
}
tags.forEach((tag) => bump(tag, weight))
})
return interestWeights
}
function scorePost(post, author, signals, options = {}) {
const { reelsMode = false, seedPost = null, seedAuthor = null } = options
const {
reelsMode = false,
seedPost = null,
seedAuthor = null,
viewerId = null
} = options
let score = Math.random() * 4
const postId = String(post._id)
@@ -377,10 +522,21 @@ function scorePost(post, author, signals, options = {}) {
score += engagementVelocity(likesCount, commentsCount, ageHours)
if (viewerId && authorId === String(viewerId)) {
score += 180
if (ageHours < 24) score += 120
else if (ageHours < 72) score += 60
}
if (signals.userExpertise && expertiseMatches(signals.userExpertise, author?.expertise)) {
score += 24
}
// شخصی‌سازی متقابل: علاقه‌ای که کاربر توی فروشگاه/آموزش/بیلبورد نشون داده هم اینجا اثر بذاره
if (author?.expertise && signals.unifiedInterestWeights?.[author.expertise]) {
score += Math.min(15, signals.unifiedInterestWeights[author.expertise] * 0.6)
}
if (
signals.userSubExpertise?.length &&
author?.sub_expertise?.some((s) => signals.userSubExpertise.includes(s))
@@ -404,9 +560,10 @@ function scorePost(post, author, signals, options = {}) {
const authorFatigue = signals.viewedAuthors?.[authorId] || 0
if (authorFatigue > 0) score -= authorFatigue * 6
if (ageHours < 24) score += 6
else if (ageHours < 48) score += 4
else if (ageHours < 168) score += 2
if (ageHours < 24) score += 14
else if (ageHours < 48) score += 10
else if (ageHours < 168) score += 6
else if (ageHours < 720) score += 2
if (reelsMode && post.type === 'video') score += 14
@@ -436,9 +593,10 @@ async function rankPostsForFeed({
userId,
seedPostId,
reelsMode = false,
blendFresh = false
blendFresh = false,
signals: prebuiltSignals = null
}) {
const signals = await buildUserExploreSignals(userId)
const signals = prebuiltSignals || (await buildUserExploreSignals(userId))
const coldStart = signals.isColdStart
let seedPost = null
@@ -471,29 +629,42 @@ async function rankPostsForFeed({
if (coldStart && reelsMode && seedAuthor) {
ranked = rankColdStartReels(pool, usersById, seedAuthor, seedPost)
} else if (coldStart && reelsMode && seedPost) {
ranked = shuffleArray(pool, String(seedPostId).length * 131)
ranked = sortByRecency(pool)
} else if (coldStart) {
ranked = shuffleArray(pool)
ranked = sortByRecency(pool)
if (blendFresh) {
ranked = blendFreshIntoRanked(ranked, pool, 0.2)
}
} else {
if (!signals.unifiedInterestWeights) {
signals.unifiedInterestWeights = userId ? await buildUnifiedInterestScore(userId) : {}
}
const scored = pool
.map((post) => ({
post,
score: scorePost(post, usersById[String(post.user_id)] || {}, signals, {
reelsMode,
seedPost,
seedAuthor
seedAuthor,
viewerId: userId
})
}))
.sort((a, b) => b.score - a.score)
.sort((a, b) => {
const scoreDiff = b.score - a.score
if (Math.abs(scoreDiff) > 0.01) return scoreDiff
return new Date(b.post.createdAt) - new Date(a.post.createdAt)
})
.map((item) => item.post)
ranked = diversifyByAuthor(scored, reelsMode ? 1 : 2)
if (blendFresh) {
ranked = blendFreshIntoRanked(ranked, pool)
ranked = blendFreshIntoRanked(
ranked,
pool,
reelsMode ? 0.12 : 0.18
)
}
}
@@ -501,7 +672,79 @@ async function rankPostsForFeed({
ranked = [seedPost, ...ranked]
}
return ranked
return { ranked, isColdStart: coldStart }
}
// کش ترتیبِ رتبه‌بندی‌شده برای ثابت نگه‌داشتنش در طول یک نشست پیجینیشن. بدون این، هر
// درخواست صفحه (page) از نو رتبه‌بندی می‌شد (scorePost با Math.random() تازه)، یعنی
// صفحه‌ی ۲ اصلاً ادامه‌ی منطقیِ صفحه‌ی ۱ نبود — پست‌ها یا تکرار می‌شدن یا برای همیشه گم
// می‌شدن (باگ «بعد از ۱۰-۱۵ ریلز فید دیگه چیز جدیدی نشون نمی‌ده»).
//
// وقتی Redis وصله ازش استفاده می‌شه (چون این کش باید بین چند اینستنسِ سرور مشترک
// باشه — وگرنه صفحه‌ی ۲ اگه به یه سرورِ دیگه بخوره همون باگِ بالا برمی‌گرده؛ و نباید
// با هر ری‌استارتِ سرور whole پاک بشه). وقتی Redis در دسترس نیست، همون Mapِ
// درون‌فرآیندیِ قبلی fallback هست — رفتار قبل از این تغییر، بدون هیچ ریگرسیونی
const { getRedisClient } = require('./redisClient')
const { invalidateCachePattern } = require('./cache')
const feedRankCache = new Map()
const FEED_CACHE_TTL_MS = 15 * 60 * 1000
const FEED_CACHE_MAX_ENTRIES = 2000
const FEED_CACHE_REDIS_PREFIX = 'feedcache:'
function pruneFeedCache() {
if (feedRankCache.size <= FEED_CACHE_MAX_ENTRIES) return
const now = Date.now()
for (const [key, entry] of feedRankCache) {
if (now - entry.builtAt > FEED_CACHE_TTL_MS) feedRankCache.delete(key)
}
}
async function getFeedCacheEntry (cacheKey) {
const redis = getRedisClient()
if (redis) {
try {
const raw = await redis.get(FEED_CACHE_REDIS_PREFIX + cacheKey)
// TTLِ خودِ Redis (پایین، هنگام set) این کلید رو خودکار منقضی می‌کنه — یه hit
// یعنی هنوز داخل TTLه، نیازی به چکِ دستیِ builtAt نیست
return raw ? JSON.parse(raw) : null
} catch (err) {
console.error('[exploreAlgorithm] feed cache read error:', err.message)
}
}
const cached = feedRankCache.get(cacheKey)
if (cached && Date.now() - cached.builtAt < FEED_CACHE_TTL_MS) return cached
return null
}
async function setFeedCacheEntry (cacheKey, entry) {
const redis = getRedisClient()
if (redis) {
try {
await redis.set(FEED_CACHE_REDIS_PREFIX + cacheKey, JSON.stringify(entry), {
PX: FEED_CACHE_TTL_MS
})
return
} catch (err) {
console.error('[exploreAlgorithm] feed cache write error:', err.message)
}
}
feedRankCache.set(cacheKey, entry)
pruneFeedCache()
}
// «ریست الگوریتم» — کلید کش همیشه با `${userId}:` شروع می‌شه (هر scope ای که باشه: explore/
// reels/home/`reels-profile:<id>`)، پس همه‌ی ورودی‌های این کاربر رو صرف‌نظر از scope پاک می‌کنه
// تا فید رتبه‌بندی‌شده از نو، بدون هیچ ترتیب قبلی، ساخته بشه
function resetUserFeedCache (userId) {
if (!userId) return
const prefix = `${userId}:`
if (getRedisClient()) {
invalidateCachePattern(FEED_CACHE_REDIS_PREFIX + prefix + '*').catch(() => {})
}
for (const key of feedRankCache.keys()) {
if (key.startsWith(prefix)) feedRankCache.delete(key)
}
}
async function paginatePersonalizedExplore({
@@ -512,32 +755,84 @@ async function paginatePersonalizedExplore({
userId,
seedPostId,
reelsMode = false,
blendFresh = false
blendFresh = false,
// شناسه‌ی نوع فید (مثلاً "reels"/"home"/"following-reels") — وقتی ست بشه، ترتیب
// رتبه‌بندی‌شده برای این کاربر+نوع‌فید کش می‌شه تا صفحه‌های بعدی همون ترتیب رو ادامه بدن
cacheScope = null
}) {
const pageNum = Math.max(1, parseInt(page, 10) || 1)
const limitNum = Math.max(1, parseInt(limit, 10) || 10)
const cacheKey = cacheScope ? `${userId || 'anon'}:${cacheScope}` : null
const cached = cacheKey ? await getFeedCacheEntry(cacheKey) : null
const ranked = await rankPostsForFeed({
posts,
usersById,
userId,
seedPostId,
reelsMode,
blendFresh: blendFresh && !seedPostId && !reelsMode
})
let order
let isColdStart
if (cached && pageNum > 1) {
// ادامه‌ی همون رتبه‌بندیِ کش‌شده به‌جای بازمحاسبه‌ی کامل — پایداری صفحه‌بندی
order = cached.order
isColdStart = cached.isColdStart
} else {
const result = await rankPostsForFeed({
posts,
usersById,
userId,
seedPostId,
reelsMode,
blendFresh: blendFresh && !seedPostId && !reelsMode
})
order = result.ranked.map((p) => String(p._id))
isColdStart = result.isColdStart
if (cacheKey) {
await setFeedCacheEntry(cacheKey, { order, isColdStart, builtAt: Date.now() })
}
}
const postsById = Object.fromEntries(posts.map((p) => [String(p._id), p]))
let ranked = order.map((id) => postsById[id]).filter(Boolean)
// پست‌های تازه‌ای که بعد از ساخت این ترتیب منتشر شدن، ته لیست اضافه می‌شن — نه گم می‌شن،
// نه ترتیب قبلی به‌هم می‌ریزه
const rankedIds = new Set(order)
const freshOnes = posts.filter((p) => !rankedIds.has(String(p._id)))
if (freshOnes.length) {
const sortedFresh = sortByRecency(freshOnes)
ranked = [...ranked, ...sortedFresh]
order = [...order, ...sortedFresh.map((p) => String(p._id))]
}
const totalItems = ranked.length
const startIndex = (pageNum - 1) * limitNum
// بی‌نهایت مثل ریلز اینستاگرام: به‌جای برگردوندن صفحه‌ی خالی وقتی به ته فهرست
// رتبه‌بندی‌شده رسیدیم (که فید رو برای همیشه متوقف می‌کنه)، دوباره از استخر پست‌ها با
// ترتیب تازه وصلش می‌کنیم — تا وقتی حداقل یک پست وجود داشته باشه، فید هیچ‌وقت تموم نمی‌شه
while (startIndex >= ranked.length && posts.length > 0) {
const recycled = shuffleArray(posts, Date.now() + ranked.length)
ranked = [...ranked, ...recycled]
order = [...order, ...recycled.map((p) => String(p._id))]
}
if (cacheKey && posts.length > 0) {
await setFeedCacheEntry(cacheKey, { order, isColdStart, builtAt: cached?.builtAt || Date.now() })
}
// وقتی posts.length>0، همیشه «حداقل یه صفحه‌ی بعدی» گزارش می‌شه (فید بی‌نهایته)؛ وقتی
// استخر واقعاً خالیه (مثلاً دنبال‌شونده‌ای وجود نداره)، صفر می‌مونه و فید درست متوقف می‌شه
const totalItems = posts.length > 0 ? startIndex + limitNum + 1 : 0
return {
posts: ranked.slice(startIndex, startIndex + limitNum),
totalItems
totalItems,
isColdStart
}
}
module.exports = {
buildUserExploreSignals,
buildUnifiedInterestScore,
paginatePersonalizedExplore,
rankPostsForFeed,
scorePost
scorePost,
blendFreshIntoRanked,
resetUserFeedCache
}

46
utils/followHelpers.js Normal file
View File

@@ -0,0 +1,46 @@
const UserFollowModel = require('../models/UserFollowModel')
const UserModel = require('../models/UserModel')
async function getFollowCounts (userId) {
const [followersCount, followingCount] = await Promise.all([
UserFollowModel.countDocuments({ followingId: userId }),
UserFollowModel.countDocuments({ followerId: userId })
])
return { followersCount, followingCount }
}
async function mapFollowUsers (ids, viewerId) {
if (!ids.length) return []
const users = await UserModel.find({ _id: { $in: ids } })
.select('_id user_name first_name last_name profile_image is_verified verify_badge account_status')
.lean()
const byId = Object.fromEntries(users.map((u) => [String(u._id), u]))
let followingSet = new Set()
if (viewerId) {
const rows = await UserFollowModel.find({
followerId: viewerId,
followingId: { $in: ids }
}).select('followingId').lean()
followingSet = new Set(rows.map((r) => String(r.followingId)))
}
return ids
.map((id) => byId[String(id)])
.filter(Boolean)
.map((u) => ({
_id: String(u._id),
user_name: u.user_name || '',
first_name: u.first_name || '',
last_name: u.last_name || '',
profile_image: u.profile_image || '',
is_verified: u.is_verified || 'none',
verify_badge: u.verify_badge || 'none',
is_following: followingSet.has(String(u._id))
}))
}
module.exports = {
getFollowCounts,
mapFollowUsers
}

View File

@@ -0,0 +1,38 @@
const FREE_LIFETIME_CREATION_LIMIT = 100
const canUseFreeCreation = (user) => {
const used = user?.lifetime_free_creations || 0
return used < FREE_LIFETIME_CREATION_LIMIT
}
const remainingFreeCreations = (user) => {
const used = user?.lifetime_free_creations || 0
return Math.max(0, FREE_LIFETIME_CREATION_LIMIT - used)
}
const consumeFreeCreation = async (user) => {
if (!canUseFreeCreation(user)) {
return {
ok: false,
message: `سقف ${FREE_LIFETIME_CREATION_LIMIT} ثبت رایگان (پروژه، بیلبورد و درخواست) به پایان رسیده است.`,
}
}
user.lifetime_free_creations = (user.lifetime_free_creations || 0) + 1
await user.save()
return { ok: true }
}
const freeCreationLimitResponse = () => ({
error: true,
message: `سقف ${FREE_LIFETIME_CREATION_LIMIT} ثبت رایگان (پروژه، بیلبورد و درخواست) به پایان رسیده است.`,
})
module.exports = {
FREE_LIFETIME_CREATION_LIMIT,
canUseFreeCreation,
remainingFreeCreations,
consumeFreeCreation,
freeCreationLimitResponse,
}

View File

@@ -0,0 +1,37 @@
{
"fetchedAt": 1753196400000,
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "5896225329794346b0639e6f9d7bd8bce2954fd2",
"use": "sig",
"n": "sjGSud3Gx-92yeucu7BIAhvzkybkL21eOCejL9t2JMpqy6cMThhS2Dtr-ByKdjtoD0GLP8LCT2yJfJH5YAbHVbvBU88eUsbGd7ZlaYqicTD5Pc6B_BO8LjSr3YH1kVoOcn8Lct31-EhloVAIxBLROsS2489N3bwWOLaOhnYCLvMWqVFqV5TJPMbIBzADXeJmAyF_K2uP5P7KDWYlGz2V6AH7aS6n3_K0vdb8SVeqCu8N0M5SifpSUMQidVp5Ku-wd0Yu6P9mZcAzS9GuzePJMNsbKjDkITlc1k-KZMO2RH23zAbMCNqVABQRFLCQhulYEAbd-sYbulsrHaw4MYo3Yw",
"alg": "RS256"
},
{
"e": "AQAB",
"kty": "RSA",
"n": "rpqQOnhPWBqtg1DIQ3cQ1ozZWsyViJvQgvg1IvKYeMlpRHTMI2ySsQeCtUftWtohH_zTg2xY-Yg09bZ5Iq-gCS8haEUVb5nFOlmteomObtS5W3S8J1laBPfABiEICAcw-Q7YhP_WXlrnzDSQqvUMl2fR2Sl9WlWp30s13OS4mJv9EE0oa8k22BNxCv5_fhH4YXcTLKdp2U4F5nnoxFS-HaAsCQlCAK7g2yEn1eNn0h14yQ3IaGVx8ilOxDZxJpAeeqVUVJ_lA-1S47mHdRjrqmfTZ5JD60IL906mVBc_uSEyIh7XPbeuECjFDtyu_4N5cHqMekfbxoSQ5iOVugMkTQ",
"alg": "RS256",
"kid": "30fe0e23c4d6e36c52577b1e2fefd1abc38895de",
"use": "sig"
},
{
"alg": "RS256",
"n": "pIpnzA2ezyEERJSxiqpLBmMeIqATH-V6iuBtKIibXEyYovujrx8niqTeO6RIyXT6uDUUv0V2kJ8V_iWYFxzXY1BqK9IfcAmjg0XUDoyTVkoyLsF0gj299LH-zw5vCvy8jmamFIZKAbKcQ5hpHvSittM1vl-6vVL-i2GxyGbMA9aY6Hq15NylS1t7ELTYfQimlnvxcb7_DM0cuS5U1SfbCZMCpKhh0nrSlYds240oxpCJOV2rBahs_Ea5c7tezS1nwVC9W_E-bR9TF6BHkC_fv-E8DcWfkI_6geaJzBhINNxBfjx-w1-WUp2Jz3YYFWEfeQjxMqu-Fg6cGxwk7V16uQ",
"use": "sig",
"kid": "943a3a5d7d919625a454e489b75c29adab57acba",
"e": "AQAB",
"kty": "RSA"
},
{
"use": "sig",
"kid": "f10f87405a979c1df36df26606734f33cd85c271",
"alg": "RS256",
"n": "4rY5uwZK1dQ-UVgB5s4NLyC-u5LC2MT7b8GWZztiNgMsp0Nnqx0pM7Ofx0ws32N2aZcx10-J8ydQxnNb9uAcf-7LyhyOIcv_WEyzaSbUAMOgoF-nQmJetckxNg6ekhNfaFcTQS0T-29ql2_CBLIML6CvSh-r0fgWRsqN2ayB7wCl74Gv6OOVbvagUWhj5z2L6o_plmsPDwLVuvA7o3WDEDjoq-IXafRQowj92kQUenrOKD4YCopuLIBhel6VH8doFRNZ6KISQhMcOivWaLU_UtKKAMloGJieTf_3r-_nErs2h5wB7T7FrMCScmO7mvFQXKh8_4P-MlbfgS9CUvQksw",
"kty": "RSA",
"e": "AQAB"
}
]
}

132
utils/googleTokenVerify.js Normal file
View File

@@ -0,0 +1,132 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const jwt = require('jsonwebtoken')
const axios = require('axios')
const JWKS_URLS = [
'https://www.googleapis.com/oauth2/v3/certs',
'https://www.googleapis.com/oauth2/v1/certs',
]
const CACHE_FILE = path.join(__dirname, 'googleJwksCache.json')
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
let memoryCache = { keys: [], fetchedAt: 0 }
function jwkToPem(jwk) {
return crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({
type: 'spki',
format: 'pem',
})
}
function readDiskCache() {
try {
if (!fs.existsSync(CACHE_FILE)) return null
const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
if (!Array.isArray(parsed?.keys) || parsed.keys.length === 0) return null
return parsed
} catch (err) {
console.warn('[googleTokenVerify] cache read failed:', err.message)
return null
}
}
function writeDiskCache(keys) {
try {
const payload = { fetchedAt: Date.now(), keys }
fs.writeFileSync(CACHE_FILE, JSON.stringify(payload, null, 2))
memoryCache = payload
} catch (err) {
console.warn('[googleTokenVerify] cache write failed:', err.message)
}
}
async function fetchJwksFromGoogle() {
for (const url of JWKS_URLS) {
try {
const res = await axios.get(url, { timeout: 12000 })
if (Array.isArray(res.data?.keys) && res.data.keys.length > 0) {
return res.data.keys
}
} catch (err) {
console.warn('[googleTokenVerify] JWKS fetch failed:', url, err.message)
}
}
return null
}
async function getGoogleJwksKeys(forceRefresh = false) {
if (
!forceRefresh &&
memoryCache.keys.length > 0 &&
Date.now() - memoryCache.fetchedAt < CACHE_TTL_MS
) {
return memoryCache.keys
}
const liveKeys = await fetchJwksFromGoogle()
if (liveKeys?.length) {
writeDiskCache(liveKeys)
return liveKeys
}
const disk = readDiskCache()
if (disk?.keys?.length) {
memoryCache = disk
return disk.keys
}
throw new Error(
'Google JWKS unavailable (server cannot reach googleapis.com). Cached keys missing.'
)
}
function verifyWithJwk(idToken, jwk, audiences) {
const pem = jwkToPem(jwk)
return jwt.verify(idToken, pem, {
algorithms: ['RS256'],
audience: audiences,
issuer: ['https://accounts.google.com', 'accounts.google.com'],
})
}
async function verifyGoogleIdToken(idToken, audiences) {
if (!idToken || typeof idToken !== 'string') {
throw new Error('Invalid token')
}
const allowedAudiences = (
Array.isArray(audiences) ? audiences : [audiences]
).filter(Boolean)
if (allowedAudiences.length === 0) {
throw new Error('Google client ID is not configured')
}
const decoded = jwt.decode(idToken, { complete: true })
const kid = decoded?.header?.kid
if (!kid) {
throw new Error('Invalid token header')
}
let keys = await getGoogleJwksKeys(false)
let jwk = keys.find((key) => key.kid === kid)
if (!jwk) {
keys = await getGoogleJwksKeys(true)
jwk = keys.find((key) => key.kid === kid)
}
if (!jwk) {
throw new Error('Signing key not found for Google token')
}
return verifyWithJwk(idToken, jwk, allowedAudiences)
}
module.exports = {
verifyGoogleIdToken,
getGoogleJwksKeys,
}

182
utils/googleUserAuth.js Normal file
View File

@@ -0,0 +1,182 @@
const UserModel = require('../models/UserModel')
function escapeRegex (value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function normalizeEmail (email) {
return String(email || '').toLowerCase().trim()
}
async function findUserByGoogleId (googleId) {
if (!googleId) return null
return UserModel.findOne({ google_id: String(googleId) })
}
async function findUserByEmail (email) {
if (!email) return null
const normalized = normalizeEmail(email)
return UserModel.findOne({
$or: [
{ email: normalized },
{ email: new RegExp(`^${escapeRegex(normalized)}$`, 'i') },
],
})
}
async function findExistingGoogleUser ({ googleId, email }) {
const byGoogle = await findUserByGoogleId(googleId)
if (byGoogle) return byGoogle
return findUserByEmail(email)
}
async function patchGoogleUser (userId, fields) {
// فقط فیلدهای واقعی؛ هرگز uniqueهای sparse را null نکن
const $set = {}
for (const [key, value] of Object.entries(fields || {})) {
if (value === undefined || value === null || value === '') continue
$set[key] = value
}
if (Object.keys($set).length === 0) {
return UserModel.findById(userId)
}
await UserModel.collection.updateOne(
{ _id: userId },
{ $set }
)
return UserModel.findById(userId)
}
function getDuplicateKeyValue (err) {
return err?.keyValue && typeof err.keyValue === 'object' ? err.keyValue : null
}
/**
* ساخت کاربر گوگل بدون اعمال defaultهای null روی فیلدهای unique/sparse
*/
async function createGoogleUser ({ googleId, email }) {
const now = new Date()
const doc = {
google_id: String(googleId),
email: normalizeEmail(email),
email_verified: true,
auth_provider: 'google',
createdAt: now,
updatedAt: now,
}
try {
const result = await UserModel.collection.insertOne(doc)
const created = await UserModel.findById(result.insertedId)
if (created) return created
return findExistingGoogleUser({ googleId, email })
} catch (err) {
if (err?.code !== 11000) throw err
const existing = await findExistingGoogleUser({ googleId, email })
if (existing) return existing
const keyValue = getDuplicateKeyValue(err)
// ایندکس غیر-sparse روی mobile:null — nullها را پاک کن و دوباره بساز
if (keyValue && Object.prototype.hasOwnProperty.call(keyValue, 'mobile')) {
try {
await UserModel.collection.updateMany(
{ mobile: null },
{ $unset: { mobile: '' } }
)
const retry = await UserModel.collection.insertOne(doc)
return UserModel.findById(retry.insertedId)
} catch (retryErr) {
if (retryErr?.code === 11000) {
const again = await findExistingGoogleUser({ googleId, email })
if (again) return again
}
throw retryErr
}
}
if (keyValue?.email) {
const byEmail = await findUserByEmail(keyValue.email)
if (byEmail) {
return (
(await patchGoogleUser(byEmail._id, {
google_id: String(googleId),
email: normalizeEmail(byEmail.email || email),
email_verified: true,
})) || byEmail
)
}
}
if (keyValue?.google_id) {
const byGoogle = await findUserByGoogleId(keyValue.google_id)
if (byGoogle) return byGoogle
}
throw err
}
}
/**
* Login/register via Google:
* - existing google_id → login
* - existing email without google / same google → link + login
* - existing email with different google_id → conflict
* - otherwise create new user
*/
async function findOrCreateGoogleUser ({ googleId, email }) {
const normalizedEmail = normalizeEmail(email)
const normalizedGoogleId = String(googleId || '').trim()
if (!normalizedGoogleId || !normalizedEmail) {
const err = new Error('MISSING_GOOGLE_IDENTITY')
err.code = 'MISSING_GOOGLE_IDENTITY'
throw err
}
let user = await findUserByGoogleId(normalizedGoogleId)
if (user) {
const patch = {}
if (!user.email) patch.email = normalizedEmail
if (!user.email_verified) patch.email_verified = true
if (Object.keys(patch).length > 0) {
return (await patchGoogleUser(user._id, patch)) || user
}
return user
}
user = await findUserByEmail(normalizedEmail)
if (user) {
const existingGoogleId = user.google_id ? String(user.google_id).trim() : ''
if (existingGoogleId && existingGoogleId !== normalizedGoogleId) {
const conflict = new Error('EMAIL_LINKED_TO_OTHER_GOOGLE')
conflict.code = 'EMAIL_LINKED_TO_OTHER_GOOGLE'
throw conflict
}
return (
(await patchGoogleUser(user._id, {
google_id: normalizedGoogleId,
email: normalizeEmail(user.email || normalizedEmail),
email_verified: true,
})) || user
)
}
return createGoogleUser({
googleId: normalizedGoogleId,
email: normalizedEmail,
})
}
module.exports = {
findOrCreateGoogleUser,
findExistingGoogleUser,
}

View File

@@ -0,0 +1,93 @@
const axios = require('axios')
const PostModel = require('../models/PostModel')
const InstagramImportedPostModel = require('../models/InstagramImportedPostModel')
const { saveBase64PostFiles } = require('./postBase64Files')
const { resolvePostModerationStatus } = require('./contentModeration')
const { savePostWithModerationStatus } = require('./postStatusSave')
const path = require('path')
const imageDir = path.join(__dirname, '../storage/posts/images')
const videoDir = path.join(__dirname, '../storage/posts/videos')
/**
* CAROUSEL_ALBUM items need a second Graph API call (children edge) to resolve
* each slide's own media_url — intentionally out of scope for v1, single
* image/video posts only.
*/
const SUPPORTED_MEDIA_TYPES = ['IMAGE', 'VIDEO']
async function downloadMediaAsFile (mediaUrl, mediaType) {
const response = await axios.get(mediaUrl, {
responseType: 'arraybuffer',
timeout: 20000,
})
const contentType =
response.headers['content-type'] ||
(mediaType === 'VIDEO' ? 'video/mp4' : 'image/jpeg')
const base64Data = `data:${contentType};base64,${Buffer.from(response.data).toString('base64')}`
const ext = mediaType === 'VIDEO' ? 'mp4' : 'jpg'
return {
data: base64Data,
type: contentType,
name: `instagram-import.${ext}`,
}
}
/**
* Imports one Instagram media item as a Modstagram post, if not already imported
* and if its type is supported. Shared by the manual/automatic import endpoints
* and the recurring sync cron — same dedup + storage + moderation path either way.
* Returns the created post, or null if the item was skipped (already imported /
* unsupported type).
*/
async function importInstagramMedia (user, mediaItem) {
if (!SUPPORTED_MEDIA_TYPES.includes(mediaItem.media_type)) {
return null
}
if (!mediaItem.media_url) {
return null
}
const alreadyImported = await InstagramImportedPostModel.findOne({
user: user._id,
instagram_media_id: String(mediaItem.id),
})
if (alreadyImported) {
return null
}
const file = await downloadMediaAsFile(mediaItem.media_url, mediaItem.media_type)
const [savedFile] = await saveBase64PostFiles([file], imageDir, videoDir)
const filePaths = [
{
path: savedFile.path,
type: savedFile.mimetype.startsWith('video/') ? 'video' : 'image',
},
]
const captionText = mediaItem.caption ? String(mediaItem.caption).trim().slice(0, 2000) : null
const moderation = resolvePostModerationStatus({ caption: captionText, filePaths })
if (moderation.status === 'reject') {
return null
}
const post = await savePostWithModerationStatus(PostModel, {
user_id: user._id,
caption: captionText,
files: filePaths,
type: filePaths[0].type,
status: moderation.status,
})
await InstagramImportedPostModel.create({
user: user._id,
instagram_media_id: String(mediaItem.id),
post: post._id,
})
return post
}
module.exports = { importInstagramMedia, SUPPORTED_MEDIA_TYPES }

165
utils/instagramUserAuth.js Normal file
View File

@@ -0,0 +1,165 @@
const UserModel = require('../models/UserModel')
const { downloadImageToProfiles } = require('./downloadRemoteImage')
async function findUserByInstagramId (instagramId) {
if (!instagramId) return null
return UserModel.findOne({ instagram_id: String(instagramId) })
}
/**
* Unlike Google, Instagram OAuth never returns an email — the only stable
* identity we have is instagram_id, so there is no email-based merge path.
*/
async function findExistingInstagramUser ({ instagramId }) {
return findUserByInstagramId(instagramId)
}
async function patchInstagramUser (userId, fields) {
// فقط فیلدهای واقعی؛ هرگز uniqueهای sparse را null نکن
const $set = {}
for (const [key, value] of Object.entries(fields || {})) {
if (value === undefined || value === null || value === '') continue
$set[key] = value
}
if (Object.keys($set).length === 0) {
return UserModel.findById(userId)
}
await UserModel.collection.updateOne(
{ _id: userId },
{ $set }
)
return UserModel.findById(userId)
}
function getDuplicateKeyValue (err) {
return err?.keyValue && typeof err.keyValue === 'object' ? err.keyValue : null
}
/**
* ساخت کاربر اینستاگرام بدون اعمال defaultهای null روی فیلدهای unique/sparse
*/
async function createInstagramUser ({
instagramId,
username,
accountType,
tokenEnc,
tokenExpiresAt,
profilePictureUrl
}) {
const now = new Date()
const doc = {
instagram_id: String(instagramId),
instagram_username: username || null,
instagram_account_type: accountType || null,
instagram_access_token_enc: tokenEnc || null,
instagram_token_expires_at: tokenExpiresAt || null,
instagram_connected_at: now,
auth_provider: 'instagram',
createdAt: now,
updatedAt: now
}
if (profilePictureUrl) {
try {
doc.profile_image = await downloadImageToProfiles(profilePictureUrl, `instagram-${instagramId}`)
} catch (err) {
// آواتار اینستاگرام صرفاً یک مقدار پیش‌فرض دلپذیر است — شکست دانلود نباید ثبت‌نام را متوقف کند
console.error('[createInstagramUser] avatar download failed:', err.message)
}
}
try {
const result = await UserModel.collection.insertOne(doc)
const created = await UserModel.findById(result.insertedId)
if (created) return created
return findExistingInstagramUser({ instagramId })
} catch (err) {
if (err?.code !== 11000) throw err
const existing = await findExistingInstagramUser({ instagramId })
if (existing) return existing
const keyValue = getDuplicateKeyValue(err)
// ایندکس غیر-sparse روی mobile:null — nullها را پاک کن و دوباره بساز
if (keyValue && Object.prototype.hasOwnProperty.call(keyValue, 'mobile')) {
try {
await UserModel.collection.updateMany(
{ mobile: null },
{ $unset: { mobile: '' } }
)
const retry = await UserModel.collection.insertOne(doc)
return UserModel.findById(retry.insertedId)
} catch (retryErr) {
if (retryErr?.code === 11000) {
const again = await findExistingInstagramUser({ instagramId })
if (again) return again
}
throw retryErr
}
}
if (keyValue?.instagram_id) {
const byInstagram = await findUserByInstagramId(keyValue.instagram_id)
if (byInstagram) return byInstagram
}
throw err
}
}
/**
* Login/register via Instagram:
* - existing instagram_id → login (refresh token/profile fields)
* - otherwise create new user
*
* No email-based matching (Instagram doesn't provide one), so — unlike Google —
* there is no EMAIL_LINKED_TO_OTHER conflict case here.
*/
async function findOrCreateInstagramUser ({
instagramId,
username,
accountType,
tokenEnc,
tokenExpiresAt,
profilePictureUrl
}) {
const normalizedInstagramId = String(instagramId || '').trim()
if (!normalizedInstagramId) {
const err = new Error('MISSING_INSTAGRAM_IDENTITY')
err.code = 'MISSING_INSTAGRAM_IDENTITY'
throw err
}
const user = await findUserByInstagramId(normalizedInstagramId)
if (user) {
return (
(await patchInstagramUser(user._id, {
instagram_username: username,
instagram_account_type: accountType,
instagram_access_token_enc: tokenEnc,
instagram_token_expires_at: tokenExpiresAt
})) || user
)
}
return createInstagramUser({
instagramId: normalizedInstagramId,
username,
accountType,
tokenEnc,
tokenExpiresAt
})
}
module.exports = {
findUserByInstagramId,
findExistingInstagramUser,
patchInstagramUser,
createInstagramUser,
findOrCreateInstagramUser
}

45
utils/iranIdentity.js Normal file
View File

@@ -0,0 +1,45 @@
function ibanMod97(iban) {
const rearranged = iban.slice(4) + iban.slice(0, 4)
const expanded = rearranged
.split('')
.map((char) => {
const code = char.charCodeAt(0)
if (code >= 65 && code <= 90) return String(code - 55)
return char
})
.join('')
let remainder = expanded
while (remainder.length > 2) {
const block = remainder.slice(0, 9)
remainder = String(parseInt(block, 10) % 97) + remainder.slice(block.length)
}
return parseInt(remainder, 10) % 97
}
/** digits24: the 24 digits after the "IR" prefix */
function isValidIranSheba(digits24) {
if (!/^\d{24}$/.test(String(digits24 || ''))) return false
return ibanMod97(`IR${digits24}`) === 1
}
/** Standard Iranian national-code (کد ملی) checksum */
function isValidIranNationalCode(code) {
const value = String(code || '')
if (!/^\d{10}$/.test(value)) return false
if (/^(\d)\1{9}$/.test(value)) return false
const digits = value.split('').map(Number)
const sum = digits
.slice(0, 9)
.reduce((acc, digit, index) => acc + digit * (10 - index), 0)
const remainder = sum % 11
const checkDigit = remainder < 2 ? remainder : 11 - remainder
return checkDigit === digits[9]
}
module.exports = {
isValidIranSheba,
isValidIranNationalCode,
}

View File

@@ -1,47 +1,74 @@
const NotificationModel = require('../models/NotificationModel')
const AcademyModel = require('../models/AcademyModel')
const { notifyUser } = require("./notifyUser");
const AcademyModel = require("../models/AcademyModel");
const LIKE_TITLE = 'پست شما لایک شد'
const LIKE_DESCRIPTION = 'پست شما لایک شد'
const LIKE_TITLE = "پست شما لایک شد";
const LIKE_DESCRIPTION = "پست شما لایک شد";
function normalizeId(value) {
if (value == null) return null;
if (typeof value === "object" && value._id) return String(value._id);
return String(value);
}
/**
* @param {object} params
* @param {import('socket.io').Server} [params.io] بدون این، فقط توی دیتابیس ثبت می‌شه، نه سوکت/پوش زنده
* @param {string|import('mongoose').Types.ObjectId} params.ownerId
* @param {string|import('mongoose').Types.ObjectId} params.likerId
* @param {string|import('mongoose').Types.ObjectId} params.entityId
* @param {'post_like'|'academy_like'|'billboard_like'} [params.type]
*/
const createLikeNotification = async ({
io,
ownerId,
likerId,
entityId,
type = 'post_like'
type = "post_like",
}) => {
try {
if (!ownerId || !likerId || !entityId) return
if (String(ownerId) === String(likerId)) return
const owner = normalizeId(ownerId);
const liker = normalizeId(likerId);
const entity = normalizeId(entityId);
await NotificationModel.create({
user_id: ownerId,
project_post_id: entityId,
if (!owner || !liker || !entity) return;
if (owner === liker) return;
await notifyUser(io, owner, {
type,
title: LIKE_TITLE,
description: LIKE_DESCRIPTION
})
description: LIKE_DESCRIPTION,
project_post_id: entity,
actor_id: liker,
});
} catch (error) {
console.error('Error creating like notification:', error)
console.error("Error creating like notification:", error);
}
}
};
const resolveCourseOwnerId = async (course) => {
if (course?.user_id) {
return course.user_id
}
if (!course) return null;
if (course?.academyId) {
const directOwner = normalizeId(course.user_id);
if (directOwner) return directOwner;
if (course.academyId) {
const academy = await AcademyModel.findById(course.academyId)
if (academy?.userId) {
return academy.userId
}
.select("userId")
.lean();
const academyOwner = normalizeId(academy?.userId);
if (academyOwner) return academyOwner;
}
return null
}
return null;
};
const resolveAdvertisingOwnerId = (advertising) => {
if (!advertising) return null;
return normalizeId(advertising.creator_id);
};
module.exports = {
createLikeNotification,
resolveCourseOwnerId
}
resolveCourseOwnerId,
resolveAdvertisingOwnerId,
};

58
utils/loginResponse.js Normal file
View File

@@ -0,0 +1,58 @@
const jwt = require('jsonwebtoken')
const { buildAuthResponse } = require('./authSteps')
const { readTotpState } = require('./totpStore')
function signTemp2faToken (userId) {
return jwt.sign(
{ id: userId, purpose: '2fa' },
process.env.APP_SECRET,
{ expiresIn: '5m' }
)
}
function verifyTemp2faToken (token) {
const decoded = jwt.verify(token, process.env.APP_SECRET)
if (!decoded?.id || decoded.purpose !== '2fa') {
const err = new Error('INVALID_2FA_TOKEN')
err.code = 'INVALID_2FA_TOKEN'
throw err
}
return decoded
}
function buildLoginResponse (user, token) {
if (user?.totp_enabled && user?.totp_secret) {
return {
requires_2fa: true,
temp_token: signTemp2faToken(user._id),
message: 'کد دو مرحله‌ای را وارد کنید',
}
}
return buildAuthResponse(user, token)
}
async function buildLoginResponseForUser (user, token) {
try {
const state = await readTotpState(user._id)
if (state?.totp_enabled && state?.totp_secret) {
return {
requires_2fa: true,
temp_token: signTemp2faToken(user._id),
message: 'کد دو مرحله‌ای را وارد کنید',
}
}
} catch (error) {
console.error('[buildLoginResponseForUser] totp check failed:', error)
}
return buildAuthResponse(user, token)
}
module.exports = {
signTemp2faToken,
verifyTemp2faToken,
buildLoginResponse,
buildLoginResponseForUser,
}

View File

@@ -0,0 +1,43 @@
const crypto = require('crypto')
function base64UrlDecode (input) {
const normalized = String(input).replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
return Buffer.from(padded, 'base64')
}
/**
* Verifies + decodes a Meta `signed_request` (used by the Instagram deauthorize
* and data-deletion callbacks). Returns the decoded payload, or null if the
* signature doesn't match / the value is malformed — callers must treat null
* as "reject the request", never fall back to trusting the unverified payload.
*/
function parseSignedRequest (signedRequest, appSecret) {
if (!signedRequest || typeof signedRequest !== 'string' || !appSecret) return null
const parts = signedRequest.split('.')
if (parts.length !== 2) return null
const [encodedSig, encodedPayload] = parts
let providedSig
try {
providedSig = base64UrlDecode(encodedSig)
} catch {
return null
}
const expectedSig = crypto.createHmac('sha256', appSecret).update(encodedPayload).digest()
if (providedSig.length !== expectedSig.length || !crypto.timingSafeEqual(providedSig, expectedSig)) {
return null
}
try {
const data = JSON.parse(base64UrlDecode(encodedPayload).toString('utf8'))
if (data.algorithm !== 'HMAC-SHA256') return null
return data
} catch {
return null
}
}
module.exports = { parseSignedRequest }

89
utils/moderationStatus.js Normal file
View File

@@ -0,0 +1,89 @@
/** وضعیت تایید موقت — محتوا در اپ نمایش داده می‌شود ولی نیاز به بازبینی دستی دارد */
const TEMP_ACCEPT = 'temp_accept'
/** وضعیت‌های عمومی قابل نمایش — پست */
const PUBLIC_POST_STATUSES = [TEMP_ACCEPT, 'accept']
/** وضعیت‌های عمومی قابل نمایش — کامنت */
const PUBLIC_COMMENT_STATUSES = [TEMP_ACCEPT, 'accepted']
/** وضعیت‌های عمومی قابل نمایش — پروژه */
const PUBLIC_PROJECT_STATUSES = [TEMP_ACCEPT, 'paid', 'accepted', 'done', 'ongoing']
/** وضعیت‌های عمومی قابل نمایش — بیلبورد */
const PUBLIC_BILLBOARD_STATUSES = [TEMP_ACCEPT, 'paid', 'accepted']
/** وضعیت‌های عمومی قابل نمایش — پکیج آموزشی */
const PUBLIC_COURSE_STATUSES = [TEMP_ACCEPT, 'accept']
/** وضعیت‌های عمومی قابل نمایش — ویدیو آموزشگاه */
const PUBLIC_ACADEMY_CONTENT_STATUSES = [TEMP_ACCEPT, 'accept']
/** صف بازبینی ادمین — پست */
const REVIEW_POST_STATUSES = [TEMP_ACCEPT, 'pending']
/** صف بازبینی ادمین — کامنت */
const REVIEW_COMMENT_STATUSES = [TEMP_ACCEPT, 'pending']
/** صف بازبینی ادمین — پروژه / بیلبورد */
const REVIEW_PROJECT_STATUSES = [TEMP_ACCEPT, 'paid']
const REVIEW_BILLBOARD_STATUSES = [TEMP_ACCEPT, 'paid']
function postStatusFilter() {
return { $in: PUBLIC_POST_STATUSES }
}
function commentStatusFilter() {
return { $in: PUBLIC_COMMENT_STATUSES }
}
function projectPublicStatusFilter() {
return { $in: PUBLIC_PROJECT_STATUSES }
}
function billboardPublicStatusFilter() {
return { $in: PUBLIC_BILLBOARD_STATUSES }
}
function coursePublicStatusFilter() {
return { $in: PUBLIC_COURSE_STATUSES }
}
function academyContentPublicStatusFilter() {
return { $in: PUBLIC_ACADEMY_CONTENT_STATUSES }
}
function isPublicPostStatus(status) {
return PUBLIC_POST_STATUSES.includes(status)
}
function isPublicCommentStatus(status) {
return PUBLIC_COMMENT_STATUSES.includes(status)
}
function publicCourseStatusFilter() {
return { $in: PUBLIC_COURSE_STATUSES }
}
module.exports = {
TEMP_ACCEPT,
PUBLIC_POST_STATUSES,
PUBLIC_COMMENT_STATUSES,
PUBLIC_PROJECT_STATUSES,
PUBLIC_BILLBOARD_STATUSES,
PUBLIC_COURSE_STATUSES,
PUBLIC_ACADEMY_CONTENT_STATUSES,
REVIEW_POST_STATUSES,
REVIEW_COMMENT_STATUSES,
REVIEW_PROJECT_STATUSES,
REVIEW_BILLBOARD_STATUSES,
postStatusFilter,
commentStatusFilter,
projectPublicStatusFilter,
billboardPublicStatusFilter,
coursePublicStatusFilter,
academyContentPublicStatusFilter,
isPublicPostStatus,
isPublicCommentStatus,
publicCourseStatusFilter,
}

27
utils/nearbyPosts.js Normal file
View File

@@ -0,0 +1,27 @@
function haversineMeters(lat1, lon1, lat2, lon2) {
const toRad = (deg) => (deg * Math.PI) / 180
const dLat = toRad(lat2 - lat1)
const dLon = toRad(lon2 - lon1)
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
function filterPostsByDistance(posts, viewerLat, viewerLng, radiusMeters) {
const parsedLat = parseFloat(viewerLat)
const parsedLng = parseFloat(viewerLng)
if (!Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) return []
return posts.filter((post) => {
const postLat = parseFloat(post.lat)
const postLng = parseFloat(post.lng)
if (!Number.isFinite(postLat) || !Number.isFinite(postLng)) return false
return haversineMeters(parsedLat, parsedLng, postLat, postLng) <= radiusMeters
})
}
module.exports = {
haversineMeters,
filterPostsByDistance,
}

67
utils/notifyFollowers.js Normal file
View File

@@ -0,0 +1,67 @@
const UserFollowModel = require('../models/UserFollowModel')
const UserModel = require('../models/UserModel')
const NotificationModel = require('../models/NotificationModel')
const { sendPushToTokens } = require('./pushNotification')
let ioInstance = null
/** یک‌بار از index.js صدا زده می‌شه — مثل الگوی OrderLifecycleCron.js */
function setIo (io) {
ioInstance = io
}
/**
* وقتی یه خالق پست جدید می‌ذاره، همه‌ی فالوورهاش رو مطلع می‌کنه — دسته‌ای، نه یه‌به‌یه،
* چون برای خالق‌های پرفالوور یه حلقه‌ی تکی روی هر فالوور هم به دیتابیس فشار میاره هم به FCM
*/
async function notifyFollowers (authorId, post) {
try {
const follows = await UserFollowModel.find({ followingId: authorId })
.select('followerId')
.lean()
const followerIds = follows.map((f) => f.followerId)
if (followerIds.length === 0) return
const author = await UserModel.findById(authorId).select('first_name last_name user_name').lean()
const authorName = [author?.first_name, author?.last_name].filter(Boolean).join(' ') || author?.user_name || 'یکی از دنبال‌شده‌های شما'
const notifications = followerIds.map((followerId) => ({
user_id: followerId,
type: 'new_post_from_followed',
title: 'پست جدید',
description: `${authorName} یک پست جدید منتشر کرد`,
project_post_id: post._id,
actor_id: authorId
}))
await NotificationModel.insertMany(notifications)
if (ioInstance) {
followerIds.forEach((followerId) => {
try {
ioInstance.to(`user:${String(followerId)}`).emit('notification', {
type: 'new_post_from_followed',
title: 'پست جدید',
description: `${authorName} یک پست جدید منتشر کرد`
})
} catch (_) {}
})
}
const followerUsers = await UserModel.find({ _id: { $in: followerIds } })
.select('fcm_tokens fcm_web_tokens')
.lean()
const tokens = followerUsers.flatMap((u) => [...(u.fcm_tokens || []), ...(u.fcm_web_tokens || [])])
if (tokens.length > 0) {
await sendPushToTokens(tokens, {
title: 'پست جدید',
description: `${authorName} یک پست جدید منتشر کرد`,
type: 'new_post_from_followed',
data: { project_post_id: String(post._id) }
})
}
} catch (error) {
console.error('[notifyFollowers] خطا:', error.message)
}
}
module.exports = { notifyFollowers, setIo }

28
utils/notifyUser.js Normal file
View File

@@ -0,0 +1,28 @@
const NotificationModel = require('../models/NotificationModel')
const { sendPushToUser } = require('./pushNotification')
/**
* Creates a Notification row and, if a socket.io instance is available,
* pushes it live to the user's `user:{id}` room so it can be surfaced
* immediately (e.g. as a browser Notification) without waiting for a poll.
* همچنین یه پوش FCM هم برای گوشی کاربر می‌فرسته — این تنها نقطه‌ایه که باعث میشه
* همه‌ی نوتیفیکیشن‌هایی که از این تابع رد میشن به‌صورت پوش هم نمایش داده بشن
*/
async function notifyUser (io, userId, { type, title, description, project_post_id, actor_id } = {}) {
await NotificationModel.create({ user_id: userId, type, title, description, project_post_id, actor_id })
if (io) {
try {
io.to(`user:${String(userId)}`).emit('notification', { type, title, description })
} catch (_) {}
}
sendPushToUser(userId, {
title,
description,
type,
data: project_post_id ? { project_post_id: String(project_post_id) } : undefined
}).catch(() => {})
}
module.exports = { notifyUser }

68
utils/offerReplyGate.js Normal file
View File

@@ -0,0 +1,68 @@
const OfferModel = require('../models/OfferModel')
const WalletTransactionModel = require('../models/WalletTransactionModel')
const OFFER_REPLY_WINDOW_MS = 24 * 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.
*/
async function trackOfferMessageForCommission (io, senderId, receiverId) {
try {
const offer = await OfferModel.findOne({
$or: [
{ sender: senderId, receiver: receiverId },
{ sender: receiverId, receiver: senderId }
],
commissionCredited: false,
pendingCommissionAmount: { $gt: 0 }
}).sort({ createdAt: -1 })
if (!offer) return
const isFromRequester = String(offer.sender) === String(senderId)
if (isFromRequester) {
if (!offer.firstMessageAt) {
offer.firstMessageAt = new Date()
offer.replyDeadline = new Date(Date.now() + OFFER_REPLY_WINDOW_MS)
await offer.save()
}
return
}
// پیام از طرف گیرنده — کسی که باید ظرف ۲۴ ساعت پاسخ بدهد
if (!offer.firstMessageAt || offer.repliedAt) return
if (new Date() > offer.replyDeadline) return
offer.repliedAt = new Date()
offer.commissionCredited = true
await offer.save()
await WalletTransactionModel.create({
user: offer.receiver,
bucket: 'gift',
type: 'credit',
amount: offer.pendingCommissionAmount,
referenceType: 'payment',
referenceId: offer._id,
description: 'سهم دریافتی از درخواست همکاری (۳۰٪، پس از پاسخ به‌موقع)'
})
if (io) {
try {
io.to(`user:${String(offer.receiver)}`).emit('notification', {
type: 'offer_commission',
title: 'واریز سهم درخواست همکاری',
description: 'با پاسخ به‌موقع، ۳۰٪ مبلغ درخواست همکاری به کیف پول شما واریز شد.'
})
} catch (_) {}
}
} catch (error) {
console.error('trackOfferMessageForCommission error:', error)
}
}
module.exports = { trackOfferMessageForCommission, OFFER_REPLY_WINDOW_MS }

27
utils/onlineStatus.js Normal file
View File

@@ -0,0 +1,27 @@
const moment = require('moment-jalaali')
function formatLastOnlineStatus(lastOnlineDate, ghostMode = false) {
if (ghostMode) return 'نامشخص'
if (!lastOnlineDate) return 'آفلاین'
const now = moment()
const lastOnline = moment(lastOnlineDate)
if (!lastOnline.isValid()) return 'آفلاین'
const diffMinutes = now.diff(lastOnline, 'minutes')
if (diffMinutes < 1) {
return 'آنلاین'
}
if (diffMinutes < 60) {
return `${diffMinutes} دقیقه پیش`
}
if (diffMinutes < 1440) {
return `${Math.floor(diffMinutes / 60)} ساعت پیش`
}
return `${Math.floor(diffMinutes / 1440)} روز پیش`
}
module.exports = {
formatLastOnlineStatus
}

146
utils/partnerMatch.js Normal file
View File

@@ -0,0 +1,146 @@
const PartnerSearchAlertModel = require('../models/PartnerSearchAlertModel')
const NotificationModel = require('../models/NotificationModel')
const { sendPushToTokens } = require('./pushNotification')
let ioInstance = null
/** یک‌بار از index.js صدا زده می‌شه — مثل الگوی notifyFollowers.js */
function setIo (io) {
ioInstance = io
}
function ageFromBirthday (birthday) {
if (!birthday) return null
const diffMs = Date.now() - new Date(birthday).getTime()
return Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000))
}
function inRange (value, min, max) {
if (value === null || value === undefined || Number.isNaN(value)) return false
if (min !== undefined && min !== null && value < min) return false
if (max !== undefined && max !== null && value > max) return false
return true
}
/**
* Compares one user document against one saved alert's criteria. Runs as a
* plain JS predicate (not a Mongo query) since it always checks a single
* already-loaded document, not a collection.
*/
function matchesCriteria (user, criteria = {}) {
if (criteria.gender && user.gender !== criteria.gender) return false
if (criteria.heightMin !== undefined || criteria.heightMax !== undefined) {
if (!inRange(Number(user.height), criteria.heightMin, criteria.heightMax)) return false
}
if (criteria.weightMin !== undefined || criteria.weightMax !== undefined) {
if (!inRange(Number(user.weight), criteria.weightMin, criteria.weightMax)) return false
}
if (criteria.sizeMin !== undefined || criteria.sizeMax !== undefined) {
if (!inRange(Number(user.size), criteria.sizeMin, criteria.sizeMax)) return false
}
if (criteria.ageMin !== undefined || criteria.ageMax !== undefined) {
if (!inRange(ageFromBirthday(user.birthday), criteria.ageMin, criteria.ageMax)) return false
}
if (criteria.hair_color && user.hair_color !== criteria.hair_color) return false
if (criteria.eye_color && user.eye_color !== criteria.eye_color) return false
if (criteria.marital_status && user.marital_status !== criteria.marital_status) return false
if (criteria.residence_type && user.residence_type !== criteria.residence_type) return false
if (criteria.vehicle_type && user.vehicle_type !== criteria.vehicle_type) return false
if (criteria.personal_style && user.personal_style !== criteria.personal_style) return false
if (criteria.income_range && user.income_range !== criteria.income_range) return false
if (criteria.job) {
if (!user.job || !user.job.toLowerCase().includes(criteria.job.toLowerCase())) 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
return true
}
/** Only re-check when a field a saved search could plausibly filter on has
* actually changed — avoids running on unrelated saves like `last_online`. */
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'
]
function shouldCheck (user) {
return MATCH_RELEVANT_FIELDS.some((field) => user.isModified(field))
}
/** Finds every active alert this user now matches (that hasn't already been
* notified about this exact user) and notifies each alert owner once.
* Caller is responsible for the shouldCheck() gate — isModified() is only
* meaningful in pre('save'), so it can't be re-checked here in a post('save')
* context (Mongoose clears the modified-paths tracking before post hooks run). */
async function checkPartnerAlerts (user) {
try {
const alerts = await PartnerSearchAlertModel.find({
active: true,
user_id: { $ne: user._id },
notified_user_ids: { $ne: user._id }
})
if (alerts.length === 0) return
const matchedAlerts = alerts.filter((alert) => matchesCriteria(user, alert.criteria))
if (matchedAlerts.length === 0) return
const title = 'همکار پیشنهادی جدید'
const description = 'کاربری با مشخصات مورد نظر شما به مجموعه مدستاگرام پیوست'
const notifications = matchedAlerts.map((alert) => ({
user_id: alert.user_id,
type: 'partner_match',
title,
description,
project_post_id: user._id,
actor_id: user._id
}))
// create() (not insertMany) — insertMany skips document middleware, so the
// mongoose-timestamp plugin never runs and createdAt/updatedAt are left
// unset, which sorts these notifications to the very last page of the list
await NotificationModel.create(notifications)
await Promise.all(
matchedAlerts.map((alert) =>
PartnerSearchAlertModel.updateOne(
{ _id: alert._id },
{ $addToSet: { notified_user_ids: user._id } }
)
)
)
if (ioInstance) {
matchedAlerts.forEach((alert) => {
try {
ioInstance.to(`user:${String(alert.user_id)}`).emit('notification', { type: 'partner_match', title, description })
} catch (_) {}
})
}
const UserModel = require('../models/UserModel')
const alertOwners = await UserModel.find({ _id: { $in: matchedAlerts.map((a) => a.user_id) } })
.select('fcm_tokens fcm_web_tokens')
.lean()
const tokens = alertOwners.flatMap((u) => [...(u.fcm_tokens || []), ...(u.fcm_web_tokens || [])])
if (tokens.length > 0) {
await sendPushToTokens(tokens, {
title,
description,
type: 'partner_match',
data: { project_post_id: String(user._id) }
}).catch(() => {})
}
} catch (error) {
console.error('[checkPartnerAlerts] خطا:', error.message)
}
}
module.exports = { matchesCriteria, checkPartnerAlerts, setIo, ageFromBirthday, shouldCheck }

53
utils/postBase64Files.js Normal file
View File

@@ -0,0 +1,53 @@
const fs = require('fs')
const path = require('path')
function decodeBase64Buffer(data) {
if (!data || typeof data !== 'string') {
throw new Error('داده فایل نامعتبر است')
}
const payload = data.includes(',') ? data.split(',')[1] : data
if (!payload) {
throw new Error('داده base64 خالی است')
}
return Buffer.from(payload, 'base64')
}
async function saveBase64PostFiles(files, imageDir, videoDir, { allowEmpty = false } = {}) {
if (!Array.isArray(files) || files.length === 0) {
if (allowEmpty) return []
throw new Error('هیچ فایلی ارسال نشده است')
}
return Promise.all(
files.map(async (file, index) => {
if (!file?.data || !file?.type) {
throw new Error(`فایل ${index + 1} ناقص است`)
}
const buffer = decodeBase64Buffer(file.data)
if (!buffer.length) {
throw new Error(`فایل ${index + 1} خالی است`)
}
const dir = String(file.type).startsWith('video/') ? videoDir : imageDir
const safeName = path
.basename(file.name || `upload-${index}`)
.replace(/[^\w.\-()+\s]/g, '_')
const filename = `${Date.now()}-${index}-${safeName}`
const filePath = path.join(dir, filename)
await fs.promises.writeFile(filePath, buffer)
return {
path: filePath,
filename,
mimetype: file.type,
}
})
)
}
module.exports = {
decodeBase64Buffer,
saveBase64PostFiles,
}

114
utils/postEngagement.js Normal file
View File

@@ -0,0 +1,114 @@
const PostBookmarkModel = require('../models/PostBookmarkModel')
const PostSendModel = require('../models/PostSendModel')
const PostShareModel = require('../models/PostShareModel')
const UserFollowModel = require('../models/UserFollowModel')
const ExploreInteractionModel = require('../models/ExploreInteractionModel')
const UserModel = require('../models/UserModel')
const StoryModel = require('../models/StoryModel')
async function attachPostEngagementFields(posts, viewerId) {
if (!posts?.length) return posts
const postIds = posts.map((p) => p._id)
const authorIds = [...new Set(posts.map((p) => String(p.user_id)))]
const [bookmarkAgg, sendAgg, shareAgg, viewAgg, authorDocs, userBookmarks, userFollows, activeStories] = await Promise.all([
PostBookmarkModel.aggregate([
{ $match: { postId: { $in: postIds } } },
{ $group: { _id: '$postId', count: { $sum: 1 } } },
]),
PostSendModel.aggregate([
{ $match: { postId: { $in: postIds } } },
{ $group: { _id: '$postId', count: { $sum: 1 } } },
]),
PostShareModel.aggregate([
{ $match: { postId: { $in: postIds } } },
{ $group: { _id: '$postId', count: { $sum: 1 } } },
]),
ExploreInteractionModel.aggregate([
{
$match: {
targetType: 'post',
targetId: { $in: postIds },
action: 'view',
},
},
{ $group: { _id: '$targetId', count: { $sum: 1 } } },
]),
UserModel.find({ _id: { $in: authorIds } })
.select('_id allow_save_posts')
.lean(),
viewerId
? PostBookmarkModel.find({ userId: viewerId, postId: { $in: postIds } })
.select('postId')
.lean()
: [],
viewerId
? UserFollowModel.find({
followerId: viewerId,
followingId: { $in: authorIds },
})
.select('followingId')
.lean()
: [],
StoryModel.find({ user_id: { $in: authorIds }, expires_at: { $gt: new Date() } })
.select('user_id viewers')
.lean(),
])
const savesMap = Object.fromEntries(
bookmarkAgg.map((b) => [String(b._id), b.count])
)
const sendsMap = Object.fromEntries(
sendAgg.map((s) => [String(s._id), s.count])
)
const sharesMap = Object.fromEntries(
shareAgg.map((s) => [String(s._id), s.count])
)
const viewsMap = Object.fromEntries(
viewAgg.map((v) => [String(v._id), v.count])
)
const allowSaveMap = Object.fromEntries(
authorDocs.map((u) => [String(u._id), u.allow_save_posts !== false])
)
const savedSet = new Set(userBookmarks.map((b) => String(b.postId)))
const followSet = new Set(userFollows.map((f) => String(f.followingId)))
const storiesByAuthor = {}
activeStories.forEach((s) => {
const key = String(s.user_id)
if (!storiesByAuthor[key]) storiesByAuthor[key] = []
storiesByAuthor[key].push(s)
})
const hasStorySet = new Set(Object.keys(storiesByAuthor))
const unviewedStorySet = new Set(
Object.keys(storiesByAuthor).filter((authorId) =>
storiesByAuthor[authorId].some(
(s) => !s.viewers?.some((v) => String(v.user_id) === String(viewerId))
)
)
)
return posts.map((post) => ({
...post,
savesCount: savesMap[String(post._id)] || 0,
sendsCount: sendsMap[String(post._id)] || 0,
sharesCount: sharesMap[String(post._id)] || 0,
viewsCount: viewsMap[String(post._id)] || 0,
author_allow_save: allowSaveMap[String(post.user_id)] !== false,
is_saved: savedSet.has(String(post._id)),
is_following: followSet.has(String(post.user_id)),
has_story: hasStorySet.has(String(post.user_id)),
has_unviewed_story: viewerId ? unviewedStorySet.has(String(post.user_id)) : hasStorySet.has(String(post.user_id)),
}))
}
async function attachSinglePostEngagement(post, viewerId) {
const [enriched] = await attachPostEngagementFields([post], viewerId)
return enriched
}
module.exports = {
attachPostEngagementFields,
attachSinglePostEngagement,
}

28
utils/postModeration.js Normal file
View File

@@ -0,0 +1,28 @@
const { isPublicPostStatus } = require('./moderationStatus')
function applyReportHiddenFilter (filter = {}) {
const hiddenGuard = {
$or: [
{ report_hidden: false },
{ report_hidden: { $exists: false } },
{ report_hidden: null },
],
}
if (filter.$and) {
return { ...filter, $and: [...filter.$and, hiddenGuard] }
}
return { ...filter, $and: [hiddenGuard] }
}
function isPostPubliclyVisible (post) {
if (!post) return false
if (!isPublicPostStatus(post.status)) return false
return post.report_hidden !== true
}
module.exports = {
applyReportHiddenFilter,
isPostPubliclyVisible,
}

16
utils/postProfile.js Normal file
View File

@@ -0,0 +1,16 @@
function userHasPostExpertise(user) {
return Boolean(user?.expertise?.trim())
}
function respondPostExpertiseRequired(res) {
return res.status(422).json({
error: true,
message: 'کاربر گرامی برای ثبت پست باید تخصص کاربری خود را مشخص کنید',
type: 'expertise_required',
})
}
module.exports = {
userHasPostExpertise,
respondPostExpertiseRequired,
}

34
utils/postStatusSave.js Normal file
View File

@@ -0,0 +1,34 @@
const STATUS_FALLBACKS = {
temp_accept: ['temp_accept', 'accept', 'pending'],
}
/**
* Save post; fall back to older status enum values if server schema is outdated.
*/
async function savePostWithModerationStatus(Model, payload) {
const preferredStatus = payload.status || 'pending'
const candidates = STATUS_FALLBACKS[preferredStatus] ?? [preferredStatus]
let lastError = null
for (let i = 0; i < candidates.length; i += 1) {
const status = candidates[i]
const doc = new Model({ ...payload, status })
try {
await doc.save()
return doc
} catch (err) {
lastError = err
const isLast = i === candidates.length - 1
if (err?.name === 'ValidationError' && !isLast) {
continue
}
throw err
}
}
throw lastError ?? new Error('Failed to save post')
}
module.exports = {
savePostWithModerationStatus,
}

View File

@@ -0,0 +1,98 @@
const fs = require("fs-extra");
const path = require("path");
const { exec } = require("child_process");
const util = require("util");
const PostModel = require("../models/PostModel");
const execPromise = util.promisify(exec);
async function runFfmpeg(command) {
try {
await execPromise(command);
return true;
} catch (error) {
console.error("[postVideoProcessing]", error.message);
return false;
}
}
/** faststart بدون re-encode — شروع پخش سریع‌تر */
async function optimizeVideoFaststart(filePath) {
if (!(await fs.pathExists(filePath))) return;
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const tempPath = path.join(dir, `faststart-${Date.now()}-${base}`);
const ok = await runFfmpeg(
`ffmpeg -i "${filePath}" -c copy -movflags +faststart "${tempPath}" -y`
);
if (!ok || !(await fs.pathExists(tempPath))) return;
try {
await fs.rename(tempPath, filePath);
console.log(`[postVideoProcessing] faststart applied: ${base}`);
} catch (error) {
await fs.remove(tempPath).catch(() => {});
console.error("[postVideoProcessing] faststart rename failed:", error.message);
}
}
/** thumbnail برای poster در گرید و ریلز */
async function generateVideoThumbnail(filePath, postId) {
if (!(await fs.pathExists(filePath))) return null;
const thumbDir = path.join(path.dirname(filePath), "..", "thumbnails");
await fs.ensureDir(thumbDir);
const thumbName = `${postId}.jpg`;
const thumbPath = path.join(thumbDir, thumbName);
const ok = await runFfmpeg(
`ffmpeg -ss 00:00:00.5 -i "${filePath}" -vframes 1 -q:v 3 -y "${thumbPath}"`
);
if (!ok || !(await fs.pathExists(thumbPath))) return null;
// مسیر نسبی قابل سرو از /storage/posts/thumbnails
return `/posts/thumbnails/${thumbName}`;
}
async function processPostVideoInBackground(filePath, postId) {
if (!filePath || !postId) return;
const existing = await PostModel.findById(postId).select('files post_images').lean();
const hasCustomCover =
existing?.files?.some((file) => file.type === 'image') ||
(existing?.post_images?.length ?? 0) > 0;
if (hasCustomCover) {
console.log(`[postVideoProcessing] skip thumbnail (custom cover) post=${postId}`);
await optimizeVideoFaststart(filePath);
return;
}
console.log(`[postVideoProcessing] start post=${postId}`);
await optimizeVideoFaststart(filePath);
const relativeThumb = await generateVideoThumbnail(filePath, postId);
if (!relativeThumb) return;
try {
await PostModel.findByIdAndUpdate(postId, {
$set: {
post_images: [relativeThumb],
},
});
console.log(`[postVideoProcessing] thumbnail saved post=${postId} path=${relativeThumb}`);
} catch (error) {
console.error("[postVideoProcessing] db update failed:", error.message);
}
}
module.exports = {
processPostVideoInBackground,
optimizeVideoFaststart,
generateVideoThumbnail,
};

48
utils/presence.js Normal file
View File

@@ -0,0 +1,48 @@
const { getRedisClient } = require('./redisClient')
// وضعیتِ «آنلاینِ لحظه‌ای» (نه last_online، که توی Mongo ذخیره می‌شه و throttle-شده‌ست) —
// این کلید فقط توی Redis زندگی می‌کنه، هیچ‌جا persist نمی‌شه، و منبعِ حقیقتِ «الان
// آنلاینه یا نه» هم از سوکت هم از هر درخواستِ HTTPِ احرازهویت‌شده رفرش می‌شه
const PRESENCE_TTL_SECONDS = 90
function key (userId) {
return `presence:online:${userId}`
}
async function markOnline (userId) {
const redis = getRedisClient()
if (!redis || !userId) return
try {
await redis.set(key(userId), '1', { EX: PRESENCE_TTL_SECONDS })
} catch (err) {
console.error('[presence] markOnline error:', err.message)
}
}
// موقعِ قطع‌شدنِ سوکت صدا زده می‌شه تا انتقال به «آفلاین» فوری‌تر باشه، نه اینکه تا
// انقضای TTL (۹۰ ثانیه) صبر کنه — best-effort، اگه کاربر یه سوکتِ دیگه (تبِ دیگه/
// دستگاهِ دیگه) هم داشته باشه، همون یکی دوباره presence رو رفرش می‌کنه
async function markOffline (userId) {
const redis = getRedisClient()
if (!redis || !userId) return
try {
await redis.del(key(userId))
} catch (err) {
console.error('[presence] markOffline error:', err.message)
}
}
// null یعنی نامشخص (Redis در دسترس نیست) — صدازننده باید به last_online-محورِ Mongo برگرده
async function isOnline (userId) {
const redis = getRedisClient()
if (!redis || !userId) return null
try {
const val = await redis.get(key(userId))
return val != null
} catch (err) {
console.error('[presence] isOnline error:', err.message)
return null
}
}
module.exports = { markOnline, markOffline, isOnline, PRESENCE_TTL_SECONDS }

76
utils/privateProfile.js Normal file
View File

@@ -0,0 +1,76 @@
const UserFollowModel = require('../models/UserFollowModel')
const { isHiddenFromPublic } = require('./accountLifecycle')
async function viewerCanSeeUserContent (viewerId, profileUser) {
if (!profileUser) return false
if (isHiddenFromPublic(profileUser)) return false
if (!profileUser.is_private) return true
if (!viewerId) return false
if (String(viewerId) === String(profileUser._id)) return true
return !!(await UserFollowModel.exists({
followerId: viewerId,
followingId: profileUser._id,
}))
}
async function filterPostsHiddenByPrivateProfile (posts, viewerId) {
if (!posts?.length) return posts
const authorIds = [...new Set(posts.map((p) => String(p.user_id)))]
const UserModel = require('../models/UserModel')
const authors = await UserModel.find({ _id: { $in: authorIds } })
.select('_id is_private account_status')
.lean()
const authorsById = Object.fromEntries(authors.map((u) => [String(u._id), u]))
const privateAuthorIds = authors
.filter((u) => u.is_private && !isHiddenFromPublic(u))
.map((u) => String(u._id))
let visiblePrivateIds = new Set()
if (privateAuthorIds.length && viewerId) {
if (privateAuthorIds.includes(String(viewerId))) {
visiblePrivateIds.add(String(viewerId))
}
const follows = await UserFollowModel.find({
followerId: viewerId,
followingId: { $in: privateAuthorIds },
})
.select('followingId')
.lean()
follows.forEach((f) => visiblePrivateIds.add(String(f.followingId)))
}
return posts.filter((post) => {
const author = authorsById[String(post.user_id)]
if (!author || isHiddenFromPublic(author)) return false
if (!author.is_private) return true
if (!viewerId) return false
if (String(viewerId) === String(author._id)) return true
return visiblePrivateIds.has(String(author._id))
})
}
async function filterStoryUserIdsByPrivateProfile (userIds, viewerId) {
if (!userIds?.length) return []
const UserModel = require('../models/UserModel')
const users = await UserModel.find({ _id: { $in: userIds } })
.select('_id is_private account_status')
.lean()
const visible = []
for (const user of users) {
// eslint-disable-next-line no-await-in-loop
if (await viewerCanSeeUserContent(viewerId, user)) {
visible.push(String(user._id))
}
}
return visible
}
module.exports = {
viewerCanSeeUserContent,
filterPostsHiddenByPrivateProfile,
filterStoryUserIdsByPrivateProfile,
}

View File

@@ -0,0 +1,35 @@
const WalletTransactionModel = require('../models/WalletTransactionModel')
/** Paid to the selected user in full when the creator accepts/pays to select them. */
const PROJECT_ACCEPT_RATE = 0.3
/** Platform commission taken from the remaining amount paid at project completion. */
const PROJECT_COMPLETION_COMMISSION_RATE = 0.1
/** Credits the selected user's project wallet bucket and pushes a live notification if possible. */
async function creditProjectShare (io, userId, amount, referenceId, description) {
if (!userId || amount <= 0) return
await WalletTransactionModel.create({
user: userId,
bucket: 'project',
type: 'credit',
amount,
referenceType: 'payment',
referenceId,
description
})
if (io) {
try {
io.to(`user:${String(userId)}`).emit('notification', {
type: 'project_commission',
title: 'واریز به کیف پول',
description
})
} catch (_) {}
}
}
module.exports = {
PROJECT_ACCEPT_RATE,
PROJECT_COMPLETION_COMMISSION_RATE,
creditProjectShare
}

View File

@@ -1,20 +1,22 @@
function canUseProjects(user) {
return Boolean(user?.user_name?.trim() && user?.first_name?.trim())
}
function canCreateProject(user) {
return Boolean(
user?.user_name?.trim() &&
user?.first_name?.trim() &&
user?.last_name?.trim()
);
return canUseProjects(user)
}
function respondProjectProfileIncomplete(res) {
return res.status(422).json({
error: true,
message:
"برای ثبت پروژه، تکمیل نام کاربری، نام و نام خانوادگی در تنظیمات الزامی است",
});
'برای استفاده از پروژه‌ها، تکمیل نام کاربری و نام در ثبت‌نام الزامی است',
type: 'not_register',
})
}
module.exports = {
canUseProjects,
canCreateProject,
respondProjectProfileIncomplete,
};
}

100
utils/pushNotification.js Normal file
View File

@@ -0,0 +1,100 @@
const admin = require('firebase-admin')
// firebase-admin v12+ دیگه admin.messaging(app) رو صادر نمی‌کنه — باید از ساب‌ماژول جدا بگیریم
const { getMessaging } = require('firebase-admin/messaging')
const UserModel = require('../models/UserModel')
let firebaseApp = null
let warnedMissingCredential = false
function getFirebaseApp () {
if (firebaseApp) return firebaseApp
const raw = process.env.FIREBASE_SERVICE_ACCOUNT_BASE64
if (!raw) {
// بدون این هشدار، ارسال پوش کاملاً بی‌صدا هیچ‌کاری نمی‌کنه (نوتیف داخل‌برنامه‌ای و
// سوکت درست کار می‌کنن، فقط پوش واقعاً نمی‌ره) و تشخیصش خیلی سخت می‌شه — یک‌بار در طول
// عمر پروسه لاگ می‌کنیم تا توی لاگ سرور واضح دیده بشه
if (!warnedMissingCredential) {
warnedMissingCredential = true
console.error('[pushNotification] FIREBASE_SERVICE_ACCOUNT_BASE64 تنظیم نشده — ارسال پوش نوتیفیکیشن غیرفعاله (نوتیف داخل‌برنامه‌ای همچنان کار می‌کنه)')
}
return null
}
try {
const serviceAccount = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'))
// firebase-admin v12+ آبجکت credential رو دیگه تو‌درتو صادر نمی‌کنه (admin.cert مستقیم)؛
// نسخه‌های قدیمی‌تر هنوز admin.credential.cert دارن — هر دو رو پشتیبانی می‌کنیم
const cert = admin.credential?.cert ?? admin.cert
firebaseApp = admin.initializeApp({
credential: cert(serviceAccount)
})
return firebaseApp
} catch (error) {
console.error('[pushNotification] پیکربندی FIREBASE_SERVICE_ACCOUNT_BASE64 نامعتبر است:', error.message)
return null
}
}
/** ارسال پوش به یه لیست توکن مشخص — پایه‌ی مسیرهای دسته‌جمعی (فالوورها، پیام‌های همگانی پنل) */
async function sendPushToTokens (tokens, { title, description, type, data, image } = {}) {
const app = getFirebaseApp()
const cleanTokens = (tokens || []).filter(Boolean)
if (!app || cleanTokens.length === 0) return { successCount: 0, invalidTokens: [] }
const message = {
// notification.image: عکس/آواتار نمایش داده‌شده توی خودِ نوتیف (اندروید: بزرگ‌آیکون/
// big-picture، iOS: پیوست) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام
notification: { title, body: description, ...(image ? { image } : {}) },
data: Object.fromEntries(
Object.entries({ type, ...data }).map(([k, v]) => [k, String(v ?? '')])
),
tokens: cleanTokens
}
const invalidTokens = []
let successCount = 0
// FCM حداکثر ۵۰۰ توکن در هر multicast قبول می‌کنه
for (let i = 0; i < cleanTokens.length; i += 500) {
const chunk = cleanTokens.slice(i, i + 500)
try {
const messaging = admin.messaging ? admin.messaging(app) : getMessaging(app)
const res = await messaging.sendEachForMulticast({ ...message, tokens: chunk })
successCount += res.successCount
res.responses.forEach((r, idx) => {
if (!r.success && r.error?.code === 'messaging/registration-token-not-registered') {
invalidTokens.push(chunk[idx])
}
})
} catch (error) {
console.error('[pushNotification] خطا در ارسال دسته‌ای:', error.message)
}
}
return { successCount, invalidTokens }
}
/** ارسال پوش به یک کاربر مشخص — توکن‌هاش رو از دیتابیس می‌خونه و توکن‌های نامعتبر رو پاک می‌کنه */
async function sendPushToUser (userId, { title, description, type, data, image } = {}) {
try {
const user = await UserModel.findById(userId).select('fcm_tokens fcm_web_tokens').lean()
const tokens = [...(user?.fcm_tokens || []), ...(user?.fcm_web_tokens || [])]
if (tokens.length === 0) return
const { invalidTokens } = await sendPushToTokens(tokens, { title, description, type, data, image })
if (invalidTokens.length > 0) {
await UserModel.updateOne(
{ _id: userId },
{
$pull: {
fcm_tokens: { $in: invalidTokens },
fcm_web_tokens: { $in: invalidTokens }
}
}
)
}
} catch (error) {
console.error('[pushNotification] sendPushToUser خطا:', error.message)
}
}
module.exports = { sendPushToUser, sendPushToTokens, getFirebaseApp }

80
utils/redisClient.js Normal file
View File

@@ -0,0 +1,80 @@
const { createClient } = require('redis')
// یک کلاینتِ Redis مشترک برای کل اپ (presence، کش، rate-limit، session) — Redis
// اختیاریه: اگه سرورش در دسترس نباشه (چه موقع boot، چه بعداً قطع بشه)، هیچ‌جای اپ
// نباید کرش کنه یا هنگ کنه. برای همین getRedisClient() به‌جای خودِ کلاینت، فقط وقتی
// واقعاً "ready"ه اون رو برمی‌گردونه؛ صدازننده‌ها همیشه باید مسیرِ fallback
// (مستقیم Mongo/memory) رو هم داشته باشن، دقیقاً عین چیزی که توی lastOnline.js و
// middlewares/index.js پیاده شده
let client = null
let isReady = false
function buildRedisUrl () {
if (process.env.REDIS_URL) return process.env.REDIS_URL
const host = process.env.REDIS_HOST || '127.0.0.1'
const port = process.env.REDIS_PORT || '6379'
const password = process.env.REDIS_PASSWORD || ''
const db = process.env.REDIS_DB || '0'
const auth = password ? `:${encodeURIComponent(password)}@` : ''
return `redis://${auth}${host}:${port}/${db}`
}
function getClient () {
if (client) return client
client = createClient({
url: buildRedisUrl(),
socket: {
// بک‌آف نمایی با سقف ۱۰ ثانیه — تا ابد retry می‌کنه (وقتی سرورِ Redis بعداً بالا
// اومد خودش وصل می‌شه)، ولی هیچ‌وقت کل اپ رو برای اتصال بلاک نمی‌کنه چون
// connectRedis() پایین await نمی‌شه
reconnectStrategy: (retries) => Math.min(retries * 200, 10000),
// بدون این، وقتی REDIS_HOST به یه آدرسِ اشتباه/غیرقابل‌دسترس (نه صرفاً «رد شد»،
// بلکه ساکت هیچ پاسخی نمی‌ده) اشاره کنه، تلاشِ اتصال می‌تونه تا timeoutِ پیش‌فرضِ
// TCP سطحِ سیستم‌عامل (روی بعضی سیستم‌ها ۱۰۰+ ثانیه) طول بکشه — و هر درخواستی که
// منتظرِ همون تلاشِ اتصاله (مثلاً session store) به همون اندازه کند می‌شه
connectTimeout: 5000
}
})
// کتابخونه‌ی redis v4+: بدون این listener، هر خطای اتصال کل پروسه‌ی Node رو
// کرش می‌کنه (unhandled 'error' event) — این مهم‌ترین خطِ این فایله
client.on('error', (err) => {
isReady = false
console.error('[redis] connection error:', err.message)
})
client.on('ready', () => {
isReady = true
console.log('[redis] connected')
})
client.on('end', () => {
isReady = false
})
return client
}
// عمداً await نمی‌شه توی boot — اگه Redis در دسترس نباشه، بوت‌شدنِ کل سرور نباید
// معطلِ چندین‌بار retry بمونه؛ اتصال توی بک‌گراند انجام می‌شه و هر وقت آماده شد
// isReady=true می‌شه
function connectRedis () {
const c = getClient()
c.connect().catch((err) => {
console.error('[redis] initial connection failed — app continues without Redis:', err.message)
})
}
// همه‌جای اپ باید از همین تابع استفاده کنن، نه مستقیم از getRawClient() — چون فقط
// وقتی واقعاً متصله چیزی برمی‌گردونه
function getRedisClient () {
return isReady ? client : null
}
// فقط برای jaهایی مثل connect-redis که خودشون client رو نگه می‌دارن و باید همون
// اول (حتی قبل از وصل‌شدن) بهش رفرنس بدیم — چون کتابخونه‌ی redis v4 دستورهای
// قبل از اتصال رو صف می‌کنه (offline queue)، نه اینکه فوراً خطا بده
function getRawClient () {
return getClient()
}
module.exports = { connectRedis, getRedisClient, getRawClient }

321
utils/reelsTabFeed.js Normal file
View File

@@ -0,0 +1,321 @@
const UserFollowModel = require('../models/UserFollowModel')
const NEAR_ME_RADIUS_METERS = 5000
const PostBookmarkModel = require('../models/PostBookmarkModel')
const PostModel = require('../models/PostModel')
const UserModel = require('../models/UserModel')
const LikeModel = require('../models/LikeModel')
const ExploreInteractionModel = require('../models/ExploreInteractionModel')
const { getBlockerUserIds } = require('./blockVisibility')
const { filterPostsByDistance } = require('./nearbyPosts')
const { postStatusFilter } = require('./moderationStatus')
const { buildUnifiedInterestScore } = require('./exploreAlgorithm')
async function getSuggestedFollowUsers(userId, limit = 15) {
const viewer = await UserModel.findById(userId)
.select('expertise blocked_users')
.lean()
const following = await UserFollowModel.find({ followerId: userId })
.select('followingId')
.lean()
const excludeIds = new Set(
following.map((f) => String(f.followingId)).concat(String(userId))
)
const blockerIds = new Set(
(await getBlockerUserIds(UserModel, userId)).map(String)
)
const [interactions, likedPosts, interestWeights] = await Promise.all([
ExploreInteractionModel.find({ viewerId: userId })
.sort({ createdAt: -1 })
.limit(40)
.select('targetId authorId action')
.lean(),
LikeModel.find({ userId })
.sort({ createdAt: -1 })
.limit(30)
.select('postId')
.lean(),
// شخصی‌سازی متقابل: علایقی که کاربر توی فروشگاه/آموزش/بیلبورد نشون داده هم اینجا اثر بذاره
buildUnifiedInterestScore(userId),
])
const interactedAuthorIds = new Set()
interactions.forEach((row) => {
if (row.authorId) interactedAuthorIds.add(String(row.authorId))
})
const likedPostIds = likedPosts.map((l) => l.postId).filter(Boolean)
if (likedPostIds.length) {
const likedPostsData = await PostModel.find({ _id: { $in: likedPostIds } })
.select('user_id')
.lean()
likedPostsData.forEach((p) => interactedAuthorIds.add(String(p.user_id)))
}
let preferredExpertises = new Set()
if (viewer?.expertise) preferredExpertises.add(viewer.expertise)
if (interactedAuthorIds.size) {
const authors = await UserModel.find({
_id: { $in: [...interactedAuthorIds] },
})
.select('expertise')
.lean()
authors.forEach((a) => {
if (a.expertise) preferredExpertises.add(a.expertise)
})
}
const candidates = await UserModel.find({
_id: { $nin: [...excludeIds] },
user_name: { $nin: [null, ''] },
})
.select(
'_id user_name first_name last_name expertise profile_image user_level'
)
.limit(120)
.lean()
const filtered = candidates.filter(
(u) => !blockerIds.has(String(u._id)) && !excludeIds.has(String(u._id))
)
if (!filtered.length) return []
const candidateIds = filtered.map((u) => u._id)
const recentPosts = await PostModel.find({
user_id: { $in: candidateIds },
status: postStatusFilter(),
is_archived: { $ne: true },
})
.sort({ createdAt: -1 })
.lean()
const postByUser = {}
for (const p of recentPosts) {
const uid = String(p.user_id)
if (!postByUser[uid]) postByUser[uid] = p
}
const scored = filtered
.filter((u) => postByUser[String(u._id)])
.map((u) => {
let score = Math.random() * 2
if (preferredExpertises.has(u.expertise)) score += 8
if (interactedAuthorIds.has(String(u._id))) score += 12
if (u.expertise && interestWeights[u.expertise]) {
score += Math.min(20, interestWeights[u.expertise])
}
const post = postByUser[String(u._id)]
score += (post.likes?.length || 0) * 0.15
return { user: u, score, previewPost: postByUser[String(u._id)] }
})
.sort((a, b) => b.score - a.score)
.slice(0, limit)
return scored.map(({ user, previewPost }) => ({
_id: user._id,
user_name: user.user_name,
first_name: user.first_name,
last_name: user.last_name,
expertise: user.expertise,
user_level: user.user_level,
profile_image: user.profile_image,
previewPost: {
_id: previewPost._id,
type: previewPost.type,
files: previewPost.files,
caption: previewPost.caption,
},
is_following: false,
}))
}
async function getFollowingReelsFeed({
userId,
page,
limit,
postFilter,
mapPostsWithUsers,
}) {
const follows = await UserFollowModel.find({ followerId: userId })
.select('followingId')
.lean()
const followingIds = follows.map((f) => f.followingId)
if (!followingIds.length) {
const suggestedUsers = await getSuggestedFollowUsers(userId)
return {
posts: [],
totalPages: 0,
totalItems: 0,
feedMeta: { emptyFollowing: true, suggestedUsers },
}
}
const suggestedUsers = await getSuggestedFollowUsers(userId)
let posts = await PostModel.find({
...postFilter,
status: postStatusFilter(),
user_id: { $in: followingIds },
})
.sort({ createdAt: -1 })
.lean()
const userIds = [...new Set(posts.map((p) => String(p.user_id)))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified user_score rate blocked_users'
)
.lean()
posts = await mapPostsWithUsers(posts, users, { id: userId })
const totalItems = posts.length
const pageNum = parseInt(page, 10) || 1
const limitNum = parseInt(limit, 10) || 10
const startIndex = (pageNum - 1) * limitNum
const paginatedPosts = posts.slice(startIndex, startIndex + limitNum)
return {
posts: paginatedPosts,
totalPages: Math.ceil(totalItems / limitNum) || 1,
totalItems,
feedMeta: { emptyFollowing: false, suggestedUsers },
}
}
async function getSavedReelsFeed({
userId,
page,
limit,
postFilter,
mapPostsWithUsers,
}) {
const pageNum = Math.max(1, parseInt(page, 10) || 1)
const limitNum = Math.min(30, Math.max(1, parseInt(limit, 10) || 10))
const skip = (pageNum - 1) * limitNum
const totalItems = await PostBookmarkModel.countDocuments({ userId })
const bookmarks = await PostBookmarkModel.find({ userId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limitNum)
.lean()
const postIds = bookmarks.map((b) => b.postId)
if (!postIds.length) {
return {
posts: [],
totalPages: Math.ceil(totalItems / limitNum) || 0,
totalItems,
feedMeta: { emptySaved: true },
}
}
let posts = await PostModel.find({
_id: { $in: postIds },
status: postStatusFilter(),
...postFilter,
}).lean()
const orderMap = new Map(postIds.map((id, idx) => [String(id), idx]))
posts.sort(
(a, b) =>
(orderMap.get(String(a._id)) ?? 0) - (orderMap.get(String(b._id)) ?? 0)
)
const userIds = [...new Set(posts.map((p) => p.user_id))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified user_score rate blocked_users'
)
.lean()
posts = await mapPostsWithUsers(posts, users, { id: userId })
return {
posts,
totalPages: Math.ceil(totalItems / limitNum) || 1,
totalItems,
feedMeta: { emptySaved: totalItems === 0 },
}
}
async function getNearMeReelsFeed({
lat,
lng,
page,
limit,
postFilter,
mapPostsWithUsers,
decodedToken,
}) {
const parsedLat = parseFloat(lat)
const parsedLng = parseFloat(lng)
if (!Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) {
return {
posts: [],
totalPages: 0,
totalItems: 0,
feedMeta: { needsLocation: true },
}
}
let posts = await PostModel.find({
...postFilter,
status: postStatusFilter(),
lat: { $nin: [null, ''] },
lng: { $nin: [null, ''] },
})
.sort({ createdAt: -1 })
.lean()
posts = filterPostsByDistance(
posts,
parsedLat,
parsedLng,
NEAR_ME_RADIUS_METERS
)
if (!posts.length) {
return {
posts: [],
totalPages: 0,
totalItems: 0,
feedMeta: { emptyNearMe: true },
}
}
const userIds = [...new Set(posts.map((p) => p.user_id))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified user_score rate lat lng blocked_users'
)
.lean()
posts = await mapPostsWithUsers(posts, users, decodedToken)
const totalItems = posts.length
const pageNum = parseInt(page, 10) || 1
const limitNum = parseInt(limit, 10) || 10
const startIndex = (pageNum - 1) * limitNum
const paginatedPosts = posts.slice(startIndex, startIndex + limitNum)
return {
posts: paginatedPosts,
totalPages: Math.ceil(totalItems / limitNum) || 1,
totalItems,
feedMeta: { emptyNearMe: totalItems === 0 },
}
}
module.exports = {
getSuggestedFollowUsers,
getFollowingReelsFeed,
getSavedReelsFeed,
getNearMeReelsFeed,
}

View File

@@ -0,0 +1,84 @@
const mongoose = require('mongoose');
const CourseModel = require('../models/CourseModel');
const ProjectModel = require('../models/ProjectModel');
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');
// اعتبارسنجی مشترک «آیتم قابل‌لینک‌شدن» — هم پست (linked_*_id) هم استوری (لینک‌اورلی) از
// همین یه تابع استفاده می‌کنن تا شرط مالکیت+وضعیت هرکدوم دوبار نوشته نشه. خروجی: شناسه‌ی
// معتبری که باید ذخیره بشه، یا null اگه کاربر مالک نیست/وضعیتش پذیرفته‌شده نیست.
async function resolveLinkedEntity(kind, id, userId) {
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
switch (kind) {
case 'pkg': {
const course = await CourseModel.findById(id).select('_id user_id status').lean();
// با همون آستانه‌ی «عمومی قابل‌نمایش» که همه‌جای اپ برای پکیج آموزشی استفاده می‌شه
// (temp_accept هم شامل می‌شه) — نه فقط accept نهایی؛ وگرنه هر پکیجی که کاربر تازه
// ثبت کرده (وضعیت پیش‌فرضش temp_accept) توی پیکر قابل‌انتخاب بود ولی همیشه رد می‌شد
if (
course &&
String(course.user_id) === String(userId) &&
PUBLIC_COURSE_STATUSES.includes(course.status)
) {
return String(course._id);
}
return null;
}
case 'project': {
const project = await ProjectModel.findById(id).select('_id creator_id status').lean();
if (
project &&
String(project.creator_id) === String(userId) &&
['done', 'accepted'].includes(project.status)
) {
return String(project._id);
}
return null;
}
case 'billboard': {
const ad = await AdvertisingModel.findById(id).select('_id creator_id status').lean();
// عین pkg — همون آستانه‌ی «عمومی قابل‌نمایش» بیلبورد (شامل temp_accept/paid هم می‌شه)
if (
ad &&
String(ad.creator_id) === String(userId) &&
PUBLIC_BILLBOARD_STATUSES.includes(ad.status)
) {
return String(ad._id);
}
return null;
}
case 'shop': {
const shop = await ShopModel.findById(id).select('_id owner status').lean();
if (shop && String(shop.owner) === String(userId) && shop.status === 'active') {
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;
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
if (shop && String(shop.owner) === String(userId)) {
return String(listing._id);
}
return null;
}
case 'booking': {
// برای رزرو، id همون userId مالک کانفیگه (نه _id خودِ BookingConfig) — چون صفحه‌ی
// عمومی رزرو (/booking/[userId]) با شناسه‌ی کاربر مسیردهی می‌شه، نه شناسه‌ی کانفیگ
if (String(id) !== String(userId)) return null;
const config = await BookingConfigModel.findOne({ owner: userId, status: 'active' })
.select('_id')
.lean();
return config ? String(userId) : null;
}
default:
return null;
}
}
module.exports = { resolveLinkedEntity };

79
utils/secretCipher.js Normal file
View File

@@ -0,0 +1,79 @@
const crypto = require('crypto')
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 12
const SALT = 'modstagram-instagram-salt'
let cachedKey = null
function getKey () {
if (cachedKey) return cachedKey
const secret = process.env.INSTAGRAM_TOKEN_ENC_KEY || process.env.APP_SECRET
if (!secret) {
throw new Error(
'[secretCipher] Neither INSTAGRAM_TOKEN_ENC_KEY nor APP_SECRET is configured'
)
}
if (!process.env.INSTAGRAM_TOKEN_ENC_KEY) {
console.warn(
'[secretCipher] INSTAGRAM_TOKEN_ENC_KEY is not set; falling back to APP_SECRET. Set a dedicated key in production.'
)
}
cachedKey = crypto.scryptSync(secret, SALT, 32)
return cachedKey
}
/**
* Encrypt a plaintext secret (e.g. Instagram access token) for at-rest storage.
* Returns `${iv}:${authTag}:${ciphertext}` (all hex), or null for empty input.
*/
function encryptSecret (plaintext) {
if (!plaintext) return null
const key = getKey()
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([
cipher.update(String(plaintext), 'utf8'),
cipher.final()
])
const authTag = cipher.getAuthTag()
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`
}
/**
* Decrypt a value produced by encryptSecret. Returns null on missing/invalid input
* instead of throwing, so a corrupt/legacy value never crashes a caller.
*/
function decryptSecret (encoded) {
if (!encoded || typeof encoded !== 'string') return null
const parts = encoded.split(':')
if (parts.length !== 3) return null
try {
const [ivHex, authTagHex, dataHex] = parts
const key = getKey()
const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'))
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
const decrypted = Buffer.concat([
decipher.update(Buffer.from(dataHex, 'hex')),
decipher.final()
])
return decrypted.toString('utf8')
} catch (err) {
console.error('[secretCipher] decrypt failed:', err.message)
return null
}
}
module.exports = {
encryptSecret,
decryptSecret
}

60
utils/smsIr.js Normal file
View File

@@ -0,0 +1,60 @@
const axios = require('axios')
/**
* کلید API پیامک — سازگاری با نام‌های قدیمی و جدید env
*/
function getSmsIrApiKey () {
return (
process.env.SMS_IR_API_KEY ||
process.env.SMS_VERIFY_KEY ||
process.env.SMS_SECRET ||
''
)
}
function getSmsIrTemplateId (fallback = '930719') {
return process.env.SMS_IR_TEMPLATE_ID || fallback
}
/**
* ارسال OTP از طریق SMS.ir Verify API
* @returns {{ ok: boolean, error?: any }}
*/
async function sendSmsIrVerify ({ mobile, otp, templateId }) {
const apiKey = getSmsIrApiKey()
if (!apiKey) {
console.error('[smsIr] missing API key (SMS_IR_API_KEY / SMS_VERIFY_KEY)')
return { ok: false, error: 'SMS_API_KEY_MISSING' }
}
try {
await axios({
method: 'post',
url: 'https://api.sms.ir/v1/send/verify',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-api-key': apiKey
},
data: {
mobile: String(mobile),
templateId: String(templateId || getSmsIrTemplateId()),
parameters: [{ name: 'CODE', value: String(otp) }]
},
timeout: 15000
})
return { ok: true }
} catch (error) {
console.error(
'[smsIr] send failed:',
error?.response?.data || error.message
)
return { ok: false, error: error?.response?.data || error.message }
}
}
module.exports = {
getSmsIrApiKey,
getSmsIrTemplateId,
sendSmsIrVerify
}

48
utils/storagePath.js Normal file
View File

@@ -0,0 +1,48 @@
/** نرمال‌سازی مسیر فایل‌های storage برای URL عمومی */
function normalizeStoragePath(input) {
if (!input || typeof input !== 'string') return input
let s = input.replace(/\\/g, '/')
// مسیر مطلق سرور → نسبی از /storage
const storageIdx = s.indexOf('/storage/')
if (storageIdx >= 0) {
s = s.slice(storageIdx + '/storage'.length)
}
// thumbnails قدیمی: .../posts/thumbnails/id.jpg یا .../thumbnails/id.jpg
const thumbMatch = s.match(/\/posts\/thumbnails\/([^/?#]+)$/i) ||
s.match(/\/thumbnails\/([^/?#]+)$/i)
if (thumbMatch) {
return `/posts/thumbnails/${thumbMatch[1]}`
}
if (!s.startsWith('/') && !s.startsWith('http')) {
s = `/${s}`
}
return s
}
function normalizePostMediaFields(post) {
if (!post || typeof post !== 'object') return post
const next = { ...post }
if (Array.isArray(next.post_images)) {
next.post_images = next.post_images.map(normalizeStoragePath).filter(Boolean)
}
if (next.post_image) {
next.post_image = normalizeStoragePath(next.post_image)
}
if (next.post_video) {
next.post_video = normalizeStoragePath(next.post_video)
}
if (Array.isArray(next.files)) {
next.files = next.files.map((f) =>
f && f.path ? { ...f, path: normalizeStoragePath(f.path) } : f
)
}
return next
}
module.exports = {
normalizeStoragePath,
normalizePostMediaFields,
}

21
utils/storageUrl.js Normal file
View File

@@ -0,0 +1,21 @@
// معادل سرور buildStorageUrl سمت RN (modstagram_front-main/src/shared/config/env.ts) —
// مسیر نسبی ذخیره‌شده در دیتابیس (مثلاً "/profiles/xxx.jpg") رو به URL کامل قابل‌دسترس
// از بیرون (برای notification.image پوش FCM و مشابه) تبدیل می‌کنه
const STORAGE_BASE_URL = (process.env.APP_URL || 'https://api.modstagram.com/api/v1').replace(/\/api\/v1\/?$/, '') + '/storage'
function buildStorageUrl (path) {
if (!path) return ''
const value = String(path)
if (value.startsWith('http://') || value.startsWith('https://')) return value
let normalized = value.replace(/\\/g, '/')
normalized = normalized.replace('/root/modstagram-back/storage', '')
if (normalized.startsWith('/storage/')) {
normalized = normalized.slice('/storage'.length)
} else if (normalized.startsWith('storage/')) {
normalized = `/${normalized.slice('storage/'.length)}`
}
if (!normalized.startsWith('/')) normalized = `/${normalized}`
return `${STORAGE_BASE_URL}${normalized}`
}
module.exports = { buildStorageUrl, STORAGE_BASE_URL }

70
utils/storyChatMessage.js Normal file
View File

@@ -0,0 +1,70 @@
const MessageModel = require('../models/MessageModel')
const UserModel = require('../models/UserModel')
const { enrichMessage, emitChatEvents } = require('../helpers/chatHelpers')
const { viewerIsBlockedBy } = require('./blockVisibility')
function storyMediaUrl (storagePath) {
if (!storagePath) return ''
const normalized = storagePath.replace(/\\/g, '/')
const idx = normalized.indexOf('/storage/')
if (idx >= 0) return normalized.slice(idx)
return normalized.replace('/root/modstagram-back/storage', '/storage')
}
async function sendStoryReactionMessage ({
io,
senderId,
receiverId,
story,
owner,
reactionType,
commentText
}) {
if (!senderId || !receiverId || !story) return false
if (String(senderId) === String(receiverId)) return false
const receiver = await UserModel.findById(receiverId)
.select('blocked_users')
.lean()
if (!receiver) return false
if (viewerIsBlockedBy(receiver, senderId)) return false
if (owner && viewerIsBlockedBy(owner, senderId)) return false
const sharedStory = {
storyId: String(story._id),
mediaType: story.media_type,
preview: storyMediaUrl(story.media_path),
ownerUserName: owner?.user_name || '',
ownerDisplayName:
`${owner?.first_name || ''} ${owner?.last_name || ''}`.trim() ||
owner?.user_name ||
'کاربر',
reactionType
}
const suffix =
reactionType === 'like'
? '❤️'
: (commentText || '').trim()
const content = `${JSON.stringify({ sharedStory })}\n${suffix}`
const newMessage = new MessageModel({
senderId,
receiverId,
content
})
await newMessage.save()
if (io) {
const formatted = await enrichMessage(newMessage)
emitChatEvents(io, formatted, senderId, receiverId)
}
return true
}
module.exports = {
sendStoryReactionMessage,
storyMediaUrl
}

36
utils/tagNotification.js Normal file
View File

@@ -0,0 +1,36 @@
const NotificationModel = require('../models/NotificationModel')
const UserModel = require('../models/UserModel')
const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
try {
if (!taggerId || !postId || !taggedUserIds?.length) return
const uniqueIds = [...new Set(taggedUserIds.map(String))].filter(
(id) => id !== String(taggerId)
)
if (!uniqueIds.length) return
const tagger = await UserModel.findById(taggerId).select('user_name')
const taggerUsername = tagger?.user_name || 'کاربر'
const message = `${taggerUsername} شما را در پستی نشانه‌گذاری کرد.`
await Promise.all(
uniqueIds.map((userId) =>
NotificationModel.create({
user_id: userId,
project_post_id: postId,
actor_id: taggerId,
type: 'post_tag',
title: message,
description: message
})
)
)
} catch (error) {
console.error('Error creating tag notifications:', error)
}
}
module.exports = {
createTagNotifications
}

105
utils/totp.js Normal file
View File

@@ -0,0 +1,105 @@
const crypto = require('crypto')
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
function base32Encode (buffer) {
let bits = 0
let value = 0
let output = ''
for (let i = 0; i < buffer.length; i += 1) {
value = (value << 8) | buffer[i]
bits += 8
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31]
}
return output
}
function base32Decode (input) {
const normalized = String(input || '')
.toUpperCase()
.replace(/=+$/g, '')
.replace(/[^A-Z2-7]/g, '')
let bits = 0
let value = 0
const output = []
for (let i = 0; i < normalized.length; i += 1) {
const idx = BASE32_ALPHABET.indexOf(normalized[i])
if (idx === -1) continue
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 255)
bits -= 8
}
}
return Buffer.from(output)
}
function generateSecret (byteLength = 20) {
return base32Encode(crypto.randomBytes(byteLength))
}
function hotp (secret, counter) {
const key = base32Decode(secret)
const buffer = Buffer.alloc(8)
let tmp = counter
for (let i = 7; i >= 0; i -= 1) {
buffer[i] = tmp & 0xff
tmp = Math.floor(tmp / 256)
}
const hmac = crypto.createHmac('sha1', key).update(buffer).digest()
const offset = hmac[hmac.length - 1] & 0x0f
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff)
return String(code % 1_000_000).padStart(6, '0')
}
function generateTotp (secret, timeStep = 30, timestamp = Date.now()) {
const counter = Math.floor(timestamp / 1000 / timeStep)
return hotp(secret, counter)
}
function verifyTotp (secret, token, window = 1, timeStep = 30) {
if (!secret || token == null) return false
const normalized = String(token).replace(/\D/g, '').padStart(6, '0')
if (normalized.length !== 6) return false
const counter = Math.floor(Date.now() / 1000 / timeStep)
for (let offset = -window; offset <= window; offset += 1) {
if (hotp(secret, counter + offset) === normalized) {
return true
}
}
return false
}
function buildOtpAuthUrl ({ secret, accountName, issuer = 'Modstagram' }) {
const label = encodeURIComponent(`${issuer}:${accountName}`)
const issuerParam = encodeURIComponent(issuer)
return `otpauth://totp/${label}?secret=${secret}&issuer=${issuerParam}&algorithm=SHA1&digits=6&period=30`
}
module.exports = {
generateSecret,
generateTotp,
verifyTotp,
buildOtpAuthUrl,
}

83
utils/totpStore.js Normal file
View File

@@ -0,0 +1,83 @@
const mongoose = require('mongoose')
const UserModel = require('../models/UserModel')
function toObjectId (id) {
if (id instanceof mongoose.Types.ObjectId) return id
return new mongoose.Types.ObjectId(String(id))
}
async function readTotpState (userId) {
const doc = await UserModel.collection.findOne(
{ _id: toObjectId(userId) },
{ projection: { totp_secret: 1, totp_pending_secret: 1, totp_enabled: 1 } }
)
if (!doc) return null
return {
totp_secret: doc.totp_secret || null,
totp_pending_secret: doc.totp_pending_secret || null,
totp_enabled: Boolean(doc.totp_enabled),
}
}
async function setPendingSecret (userId, secret) {
const result = await UserModel.updateOne(
{ _id: toObjectId(userId) },
{ $set: { totp_pending_secret: secret } },
{ strict: false }
)
if (!result.matchedCount) {
const err = new Error('USER_NOT_FOUND')
err.status = 404
err.userMessage = 'کاربر یافت نشد'
throw err
}
}
async function activateTotp (userId, secret) {
const result = await UserModel.updateOne(
{ _id: toObjectId(userId) },
{
$set: {
totp_secret: secret,
totp_enabled: true,
},
$unset: { totp_pending_secret: '' },
},
{ strict: false }
)
if (!result.matchedCount) {
const err = new Error('USER_NOT_FOUND')
err.status = 404
err.userMessage = 'کاربر یافت نشد'
throw err
}
}
async function deactivateTotp (userId) {
const result = await UserModel.updateOne(
{ _id: toObjectId(userId) },
{
$set: { totp_enabled: false },
$unset: { totp_secret: '', totp_pending_secret: '' },
},
{ strict: false }
)
if (!result.matchedCount) {
const err = new Error('USER_NOT_FOUND')
err.status = 404
err.userMessage = 'کاربر یافت نشد'
throw err
}
}
module.exports = {
readTotpState,
setPendingSecret,
activateTotp,
deactivateTotp,
}

View File

@@ -0,0 +1,15 @@
const { invalidateCache } = require('./cache')
function unreadMessagesCacheKey (userId) {
return `unread:messages:${userId}`
}
// هرجا وضعیتِ خوانده‌نشده‌یِ پیام‌های یه کاربر واقعاً عوض می‌شه (پیامِ جدید بهش
// می‌رسه، یا پیام‌هاش خونده می‌شن) باید صدا زده بشه — تا کشِ getUnreadMessages
// (messageController.js) بلافاصله باطل بشه، نه اینکه تا انقضای TTL کهنه بمونه
function invalidateUnreadMessages (userId) {
if (!userId) return
return invalidateCache(unreadMessagesCacheKey(userId))
}
module.exports = { invalidateUnreadMessages, unreadMessagesCacheKey }

View File

@@ -3,6 +3,7 @@ const USER_LEVELS = {
STANDARD: 'استاندارد',
PRO: 'حرفه‌ای',
MASTER: 'استاد',
SUPERMODEL: 'سپورمدل',
}
/** Normalize level strings from URL/query (handles ی / ZWNJ variants). */
@@ -17,6 +18,7 @@ function normalizeUserLevel(raw) {
if (text === USER_LEVELS.NEW) return USER_LEVELS.NEW
if (text === USER_LEVELS.STANDARD) return USER_LEVELS.STANDARD
if (text === USER_LEVELS.MASTER) return USER_LEVELS.MASTER
if (text === USER_LEVELS.SUPERMODEL) return USER_LEVELS.SUPERMODEL
if (text === USER_LEVELS.PRO || text === 'حرفه ای') return USER_LEVELS.PRO
return text

View File

@@ -1,4 +1,8 @@
const { validateUsername, USERNAME_MAX_LENGTH } = require('./usernameValidation')
const {
validateUsername,
USERNAME_MIN_LENGTH,
USERNAME_MAX_LENGTH,
} = require('./usernameValidation')
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -12,18 +16,27 @@ const isUsernameTaken = async (UserModel, userName, excludeUserId) => {
return true
}
const padBase = (base) => {
let value = base.replace(/[^a-zA-Z0-9._-]/g, '')
if (!/^[a-zA-Z0-9]/.test(value)) value = `u${value}`
while (value.length < USERNAME_MIN_LENGTH - 2) {
value = `${value}0`
}
return value.slice(0, Math.max(USERNAME_MIN_LENGTH - 2, 1))
}
const buildCandidates = (base, attempt) => {
const random2 = () => String(Math.floor(Math.random() * 90 + 10))
const random3 = () => String(Math.floor(Math.random() * 900 + 100))
const random4 = () => String(Math.floor(Math.random() * 9000 + 1000))
const stem = padBase(base)
const builders = [
() => `${base}_${random2()}`,
() => `${base}.${random2()}`,
() => `${base}${random3()}`,
() => `${base}-${random2()}`,
() => `${base}_${random3()}`,
() => `${base}${random4()}`,
() => `${stem}${random2()}`,
() => `${stem}_${random2()}`,
() => `${stem}.${random2()}`,
() => `${stem}-${random2()}`,
() => `${stem}${random3()}`,
() => `${stem.slice(0, Math.max(USERNAME_MIN_LENGTH - 3, 1))}${random3()}`,
]
return builders[attempt % builders.length]()
@@ -37,7 +50,7 @@ const generateUsernameSuggestions = async (UserModel, baseUserName, excludeUserI
const suggestions = []
let attempt = 0
while (suggestions.length < 3 && attempt < 30) {
while (suggestions.length < 3 && attempt < 40) {
const candidate = trimToMax(buildCandidates(base, attempt))
attempt += 1

View File

@@ -1,8 +1,11 @@
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,99}$/
const USERNAME_MIN_LENGTH = 8
const USERNAME_MAX_LENGTH = 30
const USERNAME_MIN_LENGTH = 6
const USERNAME_MAX_LENGTH = 100
/** first char letter/digit, then up to (max-1) allowed chars */
const USERNAME_REGEX = new RegExp(
`^[a-zA-Z0-9][a-zA-Z0-9._-]{${USERNAME_MIN_LENGTH - 1},${USERNAME_MAX_LENGTH - 1}}$`
)
const validateUsername = (user_name) => {
if (!user_name || typeof user_name !== 'string') {

18
utils/verifyBadge.js Normal file
View File

@@ -0,0 +1,18 @@
function computeVerifyBadge({ is_verified, license_verified, gold_badge }) {
if (gold_badge) return 'gold'
if (license_verified) return 'green'
if (is_verified === 'verified') return 'blue'
if (is_verified === 'pending') return 'gray'
return 'none'
}
function syncVerifyBadge(userDoc) {
userDoc.verify_badge = computeVerifyBadge({
is_verified: userDoc.is_verified,
license_verified: userDoc.license_verified,
gold_badge: userDoc.gold_badge
})
return userDoc.verify_badge
}
module.exports = { computeVerifyBadge, syncVerifyBadge }