full
This commit is contained in:
29
utils/academyNotification.js
Normal file
29
utils/academyNotification.js
Normal 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
134
utils/accountLifecycle.js
Normal 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
10
utils/appLanguages.js
Normal 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
72
utils/appearanceFilter.js
Normal 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
20
utils/asyncHandler.js
Normal 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
63
utils/authSteps.js
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
35
utils/bookingCommission.js
Normal file
35
utils/bookingCommission.js
Normal 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
57
utils/cache.js
Normal 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 }
|
||||
34
utils/commentEngagement.js
Normal file
34
utils/commentEngagement.js
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
38
utils/commentStatusSave.js
Normal file
38
utils/commentStatusSave.js
Normal 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,
|
||||
}
|
||||
63
utils/contentModeration.js
Normal file
63
utils/contentModeration.js
Normal 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,
|
||||
};
|
||||
83
utils/createRateLimiter.js
Normal file
83
utils/createRateLimiter.js
Normal 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
|
||||
39
utils/downloadRemoteImage.js
Normal file
39
utils/downloadRemoteImage.js
Normal 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
29
utils/e2ee.js
Normal 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
80
utils/emailService.js
Normal 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
57
utils/ensureGeoData.js
Normal 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
103
utils/ensureUserIndexes.js
Normal 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,
|
||||
}
|
||||
@@ -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
46
utils/followHelpers.js
Normal 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
|
||||
}
|
||||
38
utils/freeCreationLimit.js
Normal file
38
utils/freeCreationLimit.js
Normal 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,
|
||||
}
|
||||
37
utils/googleJwksCache.json
Normal file
37
utils/googleJwksCache.json
Normal 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
132
utils/googleTokenVerify.js
Normal 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
182
utils/googleUserAuth.js
Normal 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,
|
||||
}
|
||||
93
utils/instagramMediaImport.js
Normal file
93
utils/instagramMediaImport.js
Normal 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
165
utils/instagramUserAuth.js
Normal 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
45
utils/iranIdentity.js
Normal 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,
|
||||
}
|
||||
@@ -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
58
utils/loginResponse.js
Normal 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,
|
||||
}
|
||||
43
utils/metaSignedRequest.js
Normal file
43
utils/metaSignedRequest.js
Normal 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
89
utils/moderationStatus.js
Normal 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
27
utils/nearbyPosts.js
Normal 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
67
utils/notifyFollowers.js
Normal 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
28
utils/notifyUser.js
Normal 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
68
utils/offerReplyGate.js
Normal 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
27
utils/onlineStatus.js
Normal 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
146
utils/partnerMatch.js
Normal 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
53
utils/postBase64Files.js
Normal 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
114
utils/postEngagement.js
Normal 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
28
utils/postModeration.js
Normal 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
16
utils/postProfile.js
Normal 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
34
utils/postStatusSave.js
Normal 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,
|
||||
}
|
||||
98
utils/postVideoProcessing.js
Normal file
98
utils/postVideoProcessing.js
Normal 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
48
utils/presence.js
Normal 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
76
utils/privateProfile.js
Normal 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,
|
||||
}
|
||||
35
utils/projectCommission.js
Normal file
35
utils/projectCommission.js
Normal 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
|
||||
}
|
||||
@@ -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
100
utils/pushNotification.js
Normal 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
80
utils/redisClient.js
Normal 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
321
utils/reelsTabFeed.js
Normal 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,
|
||||
}
|
||||
84
utils/resolveLinkedEntity.js
Normal file
84
utils/resolveLinkedEntity.js
Normal 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
79
utils/secretCipher.js
Normal 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
60
utils/smsIr.js
Normal 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
48
utils/storagePath.js
Normal 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
21
utils/storageUrl.js
Normal 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
70
utils/storyChatMessage.js
Normal 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
36
utils/tagNotification.js
Normal 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
105
utils/totp.js
Normal 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
83
utils/totpStore.js
Normal 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,
|
||||
}
|
||||
15
utils/unreadMessagesCache.js
Normal file
15
utils/unreadMessagesCache.js
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
18
utils/verifyBadge.js
Normal 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 }
|
||||
Reference in New Issue
Block a user