This commit is contained in:
payacom
2026-09-24 21:03:27 +03:30
parent 7f0ddfde70
commit 806471241e
43 changed files with 2166 additions and 144 deletions

View File

@@ -18,6 +18,7 @@ const AcademyCategoryModel = require('../../../models/AcademyCategoryModel')
const { buildUnifiedInterestScore, blendFreshIntoRanked: blendInterestIntoRanked } = require('../../../utils/exploreAlgorithm')
const { resolveCommentModerationStatus } = require('../../../utils/contentModeration')
const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave')
const { resolveStoryFlags } = require('../../../utils/storyRingFlags')
const { isVerifiedBadge } = require('../../../utils/verifyBadge')
const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking')
const {
@@ -39,6 +40,7 @@ const {
} = require('../../../utils/academyNotification')
const AdvertisingProfileModel = require('../../../models/AdvertisingProfile')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const ACADEMY_COMMISSION_RATE = 0.15
@@ -1811,10 +1813,42 @@ const getCourseComments = async (req, res) => {
.sort(sortOptions)
.skip(skip)
.limit(parseInt(limit))
.populate('user_id', 'name username profileImage'),
// فیلدهای قبلی (name/username/profileImage) روی این اسکیما اصلاً وجود نداشتن —
// یعنی اطلاعاتِ نویسنده‌ی کامنت همیشه خالی برمی‌گشت
.populate('user_id', 'user_name first_name last_name profile_image verify_badge is_verified')
.lean(),
CourseComment.countDocuments(query)
])
// بررسی وجود توکن (اختیاری — لیست کامنت‌ها بدون لاگین هم قابل مشاهده‌ست)
let viewerId = null
const authHeader = req.header('Authorization')
if (authHeader) {
const tokenParts = authHeader.split(' ')
if (tokenParts.length === 2) {
try {
viewerId = jwt.verify(tokenParts[1], process.env.APP_SECRET).id
} catch (err) {
console.error('Invalid Token:', err.message)
}
}
}
const commenterIds = comments.map((c) => c.user_id?._id).filter(Boolean)
const storyFlags = await resolveStoryFlags(commenterIds, viewerId)
const commentsWithStoryFlags = comments.map((c) => {
if (!c.user_id) return c
const flags = storyFlags[String(c.user_id._id)] || {}
return {
...c,
user_id: {
...c.user_id,
has_story: flags.has_story || false,
has_unviewed_story: flags.has_unviewed_story || false
}
}
})
// محاسبه آمار کامنت‌ها
const stats = await CourseComment.aggregate([
{ $match: { course_id: courseId, status: 'accepted' } },
@@ -1836,7 +1870,7 @@ const getCourseComments = async (req, res) => {
success: true,
message: 'لیست کامنت‌ها با موفقیت دریافت شد',
data: {
comments,
comments: commentsWithStoryFlags,
pagination: {
currentPage: parseInt(page),
totalPages,
@@ -2058,7 +2092,7 @@ const coursePayment = async (req, res, next) => {
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const { courseId } = req.body
const { courseId, useWallet } = req.body
// ========== 1. بررسی وجود دوره ==========
const course = await CourseModel.findById(courseId)
@@ -2133,8 +2167,68 @@ const coursePayment = async (req, res, next) => {
})
}
// ========== 5.۵ بررسی موجودی کیف پول (عین shopOrderPaymentController.js) ==========
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(
userId,
finalPrice,
useWallet
)
// کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست
if (fullyCovered) {
await debitWalletForPurchase(userId, walletAmount, {
referenceType: 'payment',
referenceId: course._id,
description: `خرید پکیج آموزشی «${course.cuorse_name}» از کیف پول`
})
const paymentRecord = await AcademyPaymentModel.create({
price: finalPrice,
discountAmount: priceNum - discountedPrice,
taxAmount: finalTaxPrice,
taxRate,
course_name: course.cuorse_name,
course_id: course._id,
academy_id: course.academyId,
user_id: userId,
walletAmountUsed: walletAmount,
status: 'success'
})
await IsPaymentCourse.create({
user_id: userId,
course_id: course._id,
price_paid: finalPrice,
purchased_at: new Date()
})
const courseOwnerId = await resolveCourseOwnerId(course)
await createCoursePurchaseNotification({
ownerId: courseOwnerId,
buyerId: userId,
courseId: course._id
})
if (courseOwnerId) {
const sellerNetAmount = Math.round(finalPrice * (1 - ACADEMY_COMMISSION_RATE))
if (sellerNetAmount > 0) {
await WalletTransactionModel.create({
user: courseOwnerId,
bucket: 'academy',
type: 'credit',
amount: sellerNetAmount,
referenceType: 'payment',
referenceId: paymentRecord._id,
description: `درآمد فروش پکیج آموزشی «${course.cuorse_name}» (پس از کسر ۱۵٪ کارمزد)`
})
}
}
return res.status(200).json({ success: true, paidFromWallet: true })
}
// تبدیل به ریال (ضرب در 10 چون هر تومان = 10 ریال)
const amountInRials = Math.round(finalPrice * 10)
const amountInRials = Math.round(gatewayAmount * 10)
// مبلغ باید حداقل 1000 تومان باشد (10000 ریال)
if (amountInRials < 10000) {
@@ -2172,7 +2266,8 @@ const coursePayment = async (req, res, next) => {
// ========== 8. بررسی پاسخ زرین‌پال ==========
if (result.data && result.data.code === 100) {
// ذخیره اطلاعات پرداخت در حالت pending
// ذخیره اطلاعات پرداخت در حالت pending — walletAmountUsed فقط یادداشت می‌شه،
// کسرِ واقعی توی verify (بعد از تاییدِ قطعیِ پرداخت) انجام می‌شه
await AcademyPaymentModel.create({
price: finalPrice,
discountAmount: priceNum - discountedPrice,
@@ -2183,6 +2278,7 @@ const coursePayment = async (req, res, next) => {
academy_id: course.academyId,
user_id: userId,
payment_authority: result.data.authority,
walletAmountUsed: walletAmount,
status: 'pending'
})
@@ -2245,8 +2341,11 @@ const CoursePaymentCallback = async (req, res) => {
)
}
// ========== 4. محاسبه مبلغ ==========
const amountInRials = Math.round(paymentRecord.price * 10)
// ========== 4. محاسبه مبلغ — فقط سهمِ درگاه (walletAmountUsed تا همینجا فقط
// یادداشت شده، کیف پول دست‌نخورده مونده — عین shopOrderPaymentController.js) ==========
const walletAmount = paymentRecord.walletAmountUsed || 0
const gatewayAmount = paymentRecord.price - walletAmount
const amountInRials = Math.round(gatewayAmount * 10)
// ========== 5. تایید پرداخت با زرین‌پال ==========
const verify = await axios.post(
@@ -2266,6 +2365,14 @@ const CoursePaymentCallback = async (req, res) => {
// ========== 6. بررسی نتیجه تایید ==========
if (data.code === 100) {
// درگاه فقط بخشِ باقی‌مونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ
// کیف پول هم واقعاً کسر می‌شه
await debitWalletForPurchase(userId, walletAmount, {
referenceType: 'payment',
referenceId: paymentRecord._id,
description: `خرید پکیج آموزشی «${paymentRecord.course_name}» از کیف پول`
})
// به‌روزرسانی رکورد پرداخت
await AcademyPaymentModel.findByIdAndUpdate(paymentRecord._id, {
status: 'success',

View File

@@ -0,0 +1,388 @@
const axios = require('axios')
const mongoose = require('mongoose')
const AdModel = require('../../../models/AdModel')
const PostModel = require('../../../models/PostModel')
const StoryModel = require('../../../models/StoryModel')
const UserModel = require('../../../models/UserModel')
const { resolveAdPrice } = require('../../../utils/adPricing')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const { deliverNotificationAd, trackAdEvent, findActiveAd, buildAdLinkedContentItem } = require('../../../utils/adDelivery')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const SPONSORED_TYPES = ['project', 'product', 'shop', 'billboard']
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
const AD_CAMPAIGN_DURATION_DAYS = 7
const AD_TYPES = AdModel.AD_TYPES
function toRial (toman) {
return Math.round(Number(toman) * 10)
}
function normalizeTargeting (body) {
// برای POST (createAd) یه آرایه‌ی واقعیه (JSON body)؛ برای GET (getAdPricing) کوئری‌استرینگ
// که فرانت با کاما جدا می‌فرسته — هر دو شکل رو قبول می‌کنیم
let subExpertise = []
if (Array.isArray(body.target_sub_expertise)) {
subExpertise = body.target_sub_expertise.filter(Boolean)
} else if (typeof body.target_sub_expertise === 'string' && body.target_sub_expertise) {
subExpertise = body.target_sub_expertise.split(',').map((s) => s.trim()).filter(Boolean)
}
return {
target_country_id: body.target_country_id != null && body.target_country_id !== '' ? Number(body.target_country_id) : null,
target_province_id: body.target_province_id != null && body.target_province_id !== '' ? Number(body.target_province_id) : null,
target_city_id: body.target_city_id != null && body.target_city_id !== '' ? Number(body.target_city_id) : null,
target_expertise: body.target_expertise ? String(body.target_expertise).trim() : null,
target_sub_expertise: subExpertise,
target_gender: ['female', 'male'].includes(body.target_gender) ? body.target_gender : 'any'
}
}
/** GET /ads/pricing — کاربر با هر انتخابی توی ویزارد، قیمتِ همون سناریو رو زنده می‌بینه */
const getAdPricing = async (req, res, next) => {
try {
const { ad_type: adType } = req.query
if (!AD_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
const targeting = normalizeTargeting(req.query)
const price = await resolveAdPrice(adType, {
provinceId: targeting.target_province_id,
gender: targeting.target_gender === 'any' ? null : targeting.target_gender,
expertise: targeting.target_expertise,
subExpertise: targeting.target_sub_expertise
})
return res.json({ price })
} catch (error) {
next(error)
}
}
async function activateAd (ad) {
ad.status = 'active'
ad.started_at = new Date()
ad.expires_at = new Date(Date.now() + AD_CAMPAIGN_DURATION_DAYS * 24 * 60 * 60 * 1000)
await ad.save()
if (ad.ad_type === 'notification') {
deliverNotificationAd(ad).catch((error) => console.error('deliverNotificationAd error:', error.message))
}
}
/** POST /ads — ساخت تبلیغ؛ عین سفارش فروشگاه/دوره، اول کیف پول رو چک می‌کنه */
const createAd = async (req, res, next) => {
try {
const { ad_type: adType, useWallet } = req.body
if (!AD_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
let contentPostId = null
let contentStoryId = null
let contentMediaPath = null
let contentMediaType = null
let contentOverlays = null
let contentProjectId = null
let contentShopProductId = null
let contentShopId = null
let contentBillboardId = null
let notificationTitle = null
let notificationBody = null
// نگاشتِ ad_type → {کلیدِ ورودی، تابعِ resolveLinkedEntity، پیامِ خطا} برای چهار
// نوعِ «تبلیغِ محتوای موجود» (پروژه/کالا/فروشگاه/بیلبورد) — همون منطقِ مالکیت+وضعیتی
// که ثبتِ پست (resolveLinkedEntity.js) از قبل داره، دوباره‌نویسی نشده
const LINKED_CONTENT_TYPES = {
project: { bodyKey: 'content_project_id', kind: 'project', message: 'پروژه‌ای برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این پروژه نیستید' },
product: { bodyKey: 'content_shop_product_id', kind: 'shopproduct', message: 'کالایی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این کالا نیستید' },
shop: { bodyKey: 'content_shop_id', kind: 'shop', message: 'فروشگاهی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این فروشگاه نیستید' },
billboard: { bodyKey: 'content_billboard_id', kind: 'billboard', message: 'بیلبوردی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این بیلبورد نیستید' }
}
if (adType === 'first_post') {
const postId = req.body.content_post_id
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(422).json({ error: true, message: 'پستی برای تبلیغ انتخاب نشده است' })
}
const post = await PostModel.findById(postId).select('user_id').lean()
if (!post || String(post.user_id) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این پست نیستید' })
}
contentPostId = postId
} else if (adType === 'first_story') {
const storyId = req.body.content_story_id
if (!storyId || !mongoose.Types.ObjectId.isValid(storyId)) {
return res.status(422).json({ error: true, message: 'استوری‌ای برای تبلیغ انتخاب نشده است' })
}
// مدیا/اورلی‌ها همینجا اسنپ‌شات می‌شن (نه فقط رفرنس) — استوریِ اصلی معمولاً ۲۴ساعته
// منقضی می‌شه، ولی کمپینِ تبلیغاتی باید کاملِ مدتش (activateAd) زنده بمونه
const story = await StoryModel.findById(storyId).select('user_id media_path media_type overlays').lean()
if (!story || String(story.user_id) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این استوری نیستید' })
}
contentStoryId = storyId
contentMediaPath = story.media_path
contentMediaType = story.media_type
contentOverlays = story.overlays || []
} else if (adType === 'notification') {
notificationTitle = String(req.body.notification_title || '').trim()
notificationBody = String(req.body.notification_body || '').trim()
if (!notificationTitle || !notificationBody) {
return res.status(422).json({ error: true, message: 'عنوان و متن نوتیفیکیشن الزامی است' })
}
} else if (LINKED_CONTENT_TYPES[adType]) {
const { bodyKey, kind, message, ownerMessage } = LINKED_CONTENT_TYPES[adType]
const rawId = req.body[bodyKey]
if (!rawId || !mongoose.Types.ObjectId.isValid(rawId)) {
return res.status(422).json({ error: true, message })
}
const resolvedId = await resolveLinkedEntity(kind, rawId, req.user._id)
if (!resolvedId) {
return res.status(403).json({ error: true, message: ownerMessage })
}
if (adType === 'project') contentProjectId = resolvedId
else if (adType === 'product') contentShopProductId = resolvedId
else if (adType === 'shop') contentShopId = resolvedId
else if (adType === 'billboard') contentBillboardId = resolvedId
}
const targeting = normalizeTargeting(req.body)
const price = await resolveAdPrice(adType, {
provinceId: targeting.target_province_id,
gender: targeting.target_gender === 'any' ? null : targeting.target_gender,
expertise: targeting.target_expertise,
subExpertise: targeting.target_sub_expertise
})
if (!price || price <= 0) {
return res.status(422).json({ error: true, message: 'قیمتی برای این سناریوی تبلیغ تعریف نشده است' })
}
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(
req.user._id,
price,
useWallet
)
const adPayload = {
user_id: req.user._id,
ad_type: adType,
content_post_id: contentPostId,
content_story_id: contentStoryId,
content_project_id: contentProjectId,
content_shop_product_id: contentShopProductId,
content_shop_id: contentShopId,
content_billboard_id: contentBillboardId,
content_media_path: contentMediaPath,
content_media_type: contentMediaType,
content_overlays: contentOverlays,
notification_title: notificationTitle,
notification_body: notificationBody,
...targeting,
price,
walletAmountUsed: walletAmount
}
if (fullyCovered) {
const ad = await AdModel.create(adPayload)
await debitWalletForPurchase(req.user._id, walletAmount, {
referenceType: 'payment',
referenceId: ad._id,
description: 'ثبت تبلیغ از کیف پول'
})
await activateAd(ad)
return res.status(201).json({ success: true, paidFromWallet: true, adId: ad._id })
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const ad = await AdModel.create({ ...adPayload, status: 'pending_payment' })
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(gatewayAmount),
description: `ثبت تبلیغ (${adType})`,
callback_url: `${callbackBase}/payment/ads/callback?adId=${ad._id}`
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
ad.payment_authority = result.authority
await ad.save()
return res.status(201).json({
success: true,
adId: ad._id,
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
})
}
await AdModel.deleteOne({ _id: ad._id })
console.error('ZarinPal initiate error:', response.data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
console.error('createAd error:', error?.response?.data || error.message)
next(error)
}
}
/** GET /ads/payment/verify — کال‌بک زرین‌پال */
const verifyAdPayment = async (req, res, next) => {
const { adId, Authority, Status } = req.query
const failRedirect = `${process.env.APP_SITE}/settings/ads?payment=failed`
const successRedirect = `${process.env.APP_SITE}/settings/ads?payment=success`
try {
const ad = await AdModel.findById(adId)
if (!ad || ad.status !== 'pending_payment') {
return res.redirect(failRedirect)
}
if (Status !== 'OK') {
ad.status = 'failed'
await ad.save()
return res.redirect(failRedirect)
}
const walletAmount = ad.walletAmountUsed || 0
const gatewayAmount = ad.price - walletAmount
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
const data = verify.data?.data
if (data?.code === 100 || data?.code === 101) {
await debitWalletForPurchase(ad.user_id, walletAmount, {
referenceType: 'payment',
referenceId: ad._id,
description: 'ثبت تبلیغ از کیف پول'
})
await activateAd(ad)
return res.redirect(successRedirect)
}
ad.status = 'failed'
await ad.save()
return res.redirect(failRedirect)
} catch (error) {
console.error('verifyAdPayment error:', error?.response?.data || error.message)
return res.redirect(failRedirect)
}
}
/** GET /ads/mine */
const getMyAds = async (req, res, next) => {
try {
const ads = await AdModel.find({ user_id: req.user._id })
.sort({ createdAt: -1 })
.lean()
const now = Date.now()
const withEffectiveStatus = ads.map((ad) => ({
...ad,
effective_status:
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
}))
return res.json({ ads: withEffectiveStatus })
} catch (error) {
next(error)
}
}
/** GET /ads/:id — جزئیات + آنالیتیکس */
const getAdDetail = async (req, res, next) => {
try {
const ad = await AdModel.findById(req.params.id).lean()
if (!ad || String(ad.user_id) !== String(req.user._id)) {
return res.status(404).json({ error: true, message: 'تبلیغ یافت نشد' })
}
const now = Date.now()
const effectiveStatus =
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
let content = null
if (ad.ad_type === 'first_post' && ad.content_post_id) {
content = await PostModel.findById(ad.content_post_id).select('files caption type').lean()
} else if (ad.ad_type === 'first_story') {
// اسنپ‌شاتِ خودِ AdModel — نه لوکاپِ زنده روی StoryModel، چون استوریِ اصلی معمولاً
// ۲۴ساعته منقضی می‌شه ولی این جزئیات باید کاملِ عمرِ تبلیغ در دسترس بمونه
content = { media_path: ad.content_media_path, media_type: ad.content_media_type }
} else if (SPONSORED_TYPES.includes(ad.ad_type)) {
content = await buildAdLinkedContentItem(ad)
}
return res.json({ ad: { ...ad, effective_status: effectiveStatus }, content })
} catch (error) {
next(error)
}
}
/** GET /ads/sponsored?ad_type=project|product|shop|billboard — لیستِ مربوطه (اسپشیالیست‌ها/
* کالاهای فروشگاه/فروشگاه‌ها/بیلبوردها) این رو صدا می‌زنه و اگه چیزی برگشت، اولِ لیستِ خودش
* نشونش می‌ده. برخلافِ first_post/first_story که مستقیم توی fید/استوری تزریق می‌شن، این چهار
* نوع endpoint جدا دارن — چون fetch لیست‌شون توی کنترلرهای پرترافیک/حساسِ جداگانه‌ست و
* دست‌نخورده موندنشون امن‌تره */
const getSponsoredAd = async (req, res, next) => {
try {
const { ad_type: adType } = req.query
if (!SPONSORED_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
const viewer = await UserModel.findById(req.user._id)
.select('country province city gender expertise sub_expertise')
.lean()
const ad = await findActiveAd(adType, { ...viewer, _id: req.user._id })
if (!ad) return res.json({ ad: null })
const item = await buildAdLinkedContentItem(ad)
if (!item) return res.json({ ad: null })
trackAdEvent(ad._id, 'view').catch(() => {})
return res.json({ ad: item })
} catch (error) {
next(error)
}
}
/** POST /ads/:id/track — {type:'view'|'click'} — بیننده صدا می‌زنه، نیازی به مالکیت نیست */
const trackAdEventEndpoint = async (req, res, next) => {
try {
const { type } = req.body
if (!['view', 'click'].includes(type)) {
return res.status(422).json({ error: true, message: 'نوع رویداد نامعتبر است' })
}
await trackAdEvent(req.params.id, type)
return res.json({ success: true })
} catch (error) {
next(error)
}
}
module.exports = {
getAdPricing,
createAd,
verifyAdPayment,
getMyAds,
getAdDetail,
getSponsoredAd,
trackAdEventEndpoint
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable eqeqeq */
/* eslint-disable camelcase */
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const { check, validationResult } = require('express-validator')
const { billboardPublicStatusFilter } = require('../../../utils/moderationStatus')
@@ -25,6 +26,8 @@ const {
createRatingNotification
} = require('../../../utils/commentNotification')
const { buildUnifiedInterestScore, blendFreshIntoRanked } = require('../../../utils/exploreAlgorithm')
const { resolveStoryFlags } = require('../../../utils/storyRingFlags')
const { findActiveAd, buildAdBillboardItem, trackAdEvent } = require('../../../utils/adDelivery')
/** Billboards matching the viewer's cross-content interest profile, for blending into the default (unsorted, unfiltered) listing. */
async function getInterestMatchedAds (userId, filter, excludeIds, limit = 5) {
@@ -540,6 +543,24 @@ const getAdvertisingsWeb = async (req, res, next) => {
} catch {
/* non-blocking: personalization is a bonus, never break the listing */
}
// تبلیغِ نوع billboard — عینِ الگوی first_post (adDelivery.js): فقط صفحه‌ی اول،
// فقط برای کاربرِ واردشده، خطاش هیچ‌وقت نباید لیستِ بیلبوردها رو بشکنه
try {
const viewer = await UserModel.findById(userId)
.select('country province city gender expertise sub_expertise')
.lean()
const sponsoredAd = await findActiveAd('billboard', { ...viewer, _id: userId })
if (sponsoredAd) {
const adBillboard = await buildAdBillboardItem(sponsoredAd)
if (adBillboard) {
newAds = [adBillboard, ...newAds]
trackAdEvent(sponsoredAd._id, 'view').catch(() => {})
}
}
} catch (adError) {
console.error('billboard ad injection error:', adError.message)
}
}
res.status(200).json({
@@ -831,12 +852,26 @@ const addComment = async (req, res, next) => {
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const user = await UserModel.findById(decodedToken.id)
// مثل شاپ‌پروداکت/آموزشگاه: امتیاز فقط روی اولین کامنتِ هر کاربر روی این آگهی مجازه
let rateValue = null
const requestedRate = Number(req.body.rate)
if (requestedRate >= 1 && requestedRate <= 5) {
const existingRating = await AdvertisingComment.findOne({
advertisingId,
userId,
rate: { $ne: null, $gt: 0 }
}).select('_id')
if (!existingRating) rateValue = requestedRate
}
const newComment = await saveCommentWithModerationStatus(
AdvertisingComment,
{
advertisingId,
userId,
text
text,
rate: rateValue
},
'text',
user?.verify_badge
@@ -872,11 +907,62 @@ const getComments = async (req, res, next) => {
populate: [{ path: 'userId', select: '_id profile_image user_type user_name first_name last_name is_verified verify_badge' }]
}
const comments = await AdvertisingComment.paginate({ advertisingId, status: commentStatusFilter() }, options)
const [comments, ratingAgg] = await Promise.all([
AdvertisingComment.paginate({ advertisingId, status: commentStatusFilter() }, options),
AdvertisingComment.aggregate([
{ $match: { advertisingId: new mongoose.Types.ObjectId(advertisingId), rate: { $ne: null, $gt: 0 } } },
{ $group: { _id: null, average: { $avg: '$rate' }, count: { $sum: 1 } } }
])
])
// بررسی وجود توکن (اختیاری — لیست کامنت‌ها بدون لاگین هم قابل مشاهده‌ست)
let currentUserId = null
const authHeader = req.header('Authorization')
if (authHeader) {
const tokenParts = authHeader.split(' ')
if (tokenParts.length === 2) {
try {
currentUserId = jwt.verify(tokenParts[1], process.env.APP_SECRET).id
} catch (err) {
console.error('Invalid Token:', err.message)
}
}
}
let hasRated = false
if (currentUserId) {
const existingRating = await AdvertisingComment.findOne({
advertisingId,
userId: currentUserId,
rate: { $ne: null, $gt: 0 }
}).select('_id')
hasRated = Boolean(existingRating)
}
const commenterIds = comments.docs.map((c) => c.userId?._id).filter(Boolean)
const storyFlags = await resolveStoryFlags(commenterIds, currentUserId)
const commentsWithStoryFlags = comments.docs.map((c) => {
const obj = c.toObject ? c.toObject() : c
if (obj.userId) {
const flags = storyFlags[String(obj.userId._id)] || {}
obj.userId = {
...obj.userId,
has_story: flags.has_story || false,
has_unviewed_story: flags.has_unviewed_story || false
}
}
return obj
})
res.status(200).json({
comments: comments.docs,
comments: commentsWithStoryFlags,
totalPages: comments.totalPages,
totalItems: comments.totalDocs
totalItems: comments.totalDocs,
has_rated: hasRated,
rating: {
average: ratingAgg[0]?.average || 0,
count: ratingAgg[0]?.count || 0
}
})
} catch (error) {
next(error)

View File

@@ -0,0 +1,91 @@
const AutoDirectRuleModel = require('../../../models/AutoDirectRuleModel')
function ruleError (res, status, message) {
return res.status(status).json({ error: true, message })
}
async function findOwnedRule (ruleId, userId) {
const rule = await AutoDirectRuleModel.findById(ruleId)
if (!rule) return { error: 'NOT_FOUND' }
if (String(rule.user_id) !== String(userId)) return { error: 'FORBIDDEN' }
return { rule }
}
const getMyAutoDirectRules = async (req, res, next) => {
try {
const rules = await AutoDirectRuleModel.find({ user_id: req.user._id }).sort({ createdAt: -1 })
return res.json({ rules })
} catch (error) {
next(error)
}
}
const createAutoDirectRule = async (req, res, next) => {
try {
const { trigger_text, direct_message, reply_caption } = req.body
if (!trigger_text?.trim() || !direct_message?.trim() || !reply_caption?.trim()) {
return ruleError(res, 422, 'همه‌ی فیلدها الزامی است')
}
const rule = await AutoDirectRuleModel.create({
user_id: req.user._id,
trigger_text: trigger_text.trim(),
direct_message: direct_message.trim(),
reply_caption: reply_caption.trim()
})
return res.status(201).json({ message: 'دایرکت اتوماتیک ثبت شد', rule })
} catch (error) {
next(error)
}
}
const updateAutoDirectRule = async (req, res, next) => {
try {
const { rule, error } = await findOwnedRule(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return ruleError(res, 404, 'دایرکت اتوماتیک یافت نشد')
if (error === 'FORBIDDEN') return ruleError(res, 403, 'شما مالک این قانون نیستید')
const { trigger_text, direct_message, reply_caption, is_active } = req.body
if (trigger_text !== undefined) {
if (!trigger_text?.trim()) return ruleError(res, 422, 'متن کامنت الزامی است')
rule.trigger_text = trigger_text.trim()
}
if (direct_message !== undefined) {
if (!direct_message?.trim()) return ruleError(res, 422, 'متن دایرکت الزامی است')
rule.direct_message = direct_message.trim()
}
if (reply_caption !== undefined) {
if (!reply_caption?.trim()) return ruleError(res, 422, 'متن پاسخ کامنت الزامی است')
rule.reply_caption = reply_caption.trim()
}
if (is_active !== undefined) {
rule.is_active = Boolean(is_active)
}
await rule.save()
return res.json({ message: 'دایرکت اتوماتیک به‌روزرسانی شد', rule })
} catch (error) {
next(error)
}
}
const deleteAutoDirectRule = async (req, res, next) => {
try {
const { rule, error } = await findOwnedRule(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return ruleError(res, 404, 'دایرکت اتوماتیک یافت نشد')
if (error === 'FORBIDDEN') return ruleError(res, 403, 'شما مالک این قانون نیستید')
await rule.deleteOne()
return res.json({ message: 'دایرکت اتوماتیک حذف شد' })
} catch (error) {
next(error)
}
}
module.exports = {
getMyAutoDirectRules,
createAutoDirectRule,
updateAutoDirectRule,
deleteAutoDirectRule
}

View File

@@ -0,0 +1,14 @@
const { checkClientRegion } = require('../../../utils/vpnCheck')
/** GET /geo/check — طبق خواسته‌ی کاربر: هر بار اپ (وب/اندروید) باز می‌شه صدا زده می‌شه
* تا اگه کاربر با آی‌پیِ غیرایران یا VPN/پراکسی وارد شده، بهش هشدار نشون داده بشه */
const checkGeo = async (req, res, next) => {
try {
const result = await checkClientRegion(req.ip)
return res.status(200).json(result)
} catch (error) {
next(error)
}
}
module.exports = { checkGeo }

View File

@@ -389,7 +389,13 @@ const sendPostToUsers = async (req, res, next) => {
if (io) {
const formatted = await enrichMessage(newMessage)
emitChatEvents(io, formatted, senderId, receiverId)
// برخلافِ پیامِ متنیِ معمولی، این پیام رو خودِ سرور از رویِ دیتای واقعیِ پست ساخته
// (نه متنِ رمزنگاری‌شدهٔ کاربر)، پس اینجا محدودیتِ E2EE برای پوش صدق نمی‌کنه —
// می‌تونیم دقیقاً بگیم ریلز بوده یا پست
emitChatEvents(io, formatted, senderId, receiverId, {
type: post.type === 'video' ? 'reel_shared' : 'post_shared',
description: post.type === 'video' ? 'یک ریلز برای شما ارسال کرد' : 'یک پست برای شما ارسال کرد'
})
}
sentCount += 1
}

View File

@@ -153,35 +153,55 @@ const getAccountStatus = async (req, res) => {
const updatePrivacySettings = async (req, res) => {
try {
const user = await UserModel.findById(req.user._id)
// auth فقط توکن را چک می‌کند؛ blockCheck باید req.user را ست کرده باشد
const userId = req.user?._id || getUserIdFromToken(req)
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' })
}
const $set = {}
if (typeof req.body.allow_save_posts === 'boolean') {
$set.allow_save_posts = req.body.allow_save_posts
}
if (typeof req.body.ghost_mode === 'boolean') {
$set.ghost_mode = req.body.ghost_mode
}
if (typeof req.body.show_location === 'boolean') {
$set.show_location = req.body.show_location
}
if (typeof req.body.post_location_enabled === 'boolean') {
$set.post_location_enabled = req.body.post_location_enabled
}
if (typeof req.body.is_private === 'boolean') {
$set.is_private = req.body.is_private
}
if (typeof req.body.gift_enabled === 'boolean') {
$set.gift_enabled = req.body.gift_enabled
}
if (typeof req.body.offer_payment_enabled === 'boolean') {
$set.offer_payment_enabled = req.body.offer_payment_enabled
}
if (APP_LANGUAGE_CODES.includes(req.body.preferred_language)) {
$set.preferred_language = req.body.preferred_language
}
// فقط فیلدهای privacy را با $set آپدیت می‌کنیم تا validation کل سند
// (فیلدهای قدیمی/ناسازگار) باعث 500 نشود
const user = await UserModel.findByIdAndUpdate(
userId,
Object.keys($set).length ? { $set } : {},
{
new: true,
runValidators: false,
select:
'allow_save_posts ghost_mode show_location post_location_enabled is_private gift_enabled offer_payment_enabled preferred_language',
}
).lean()
if (!user) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (typeof req.body.allow_save_posts === 'boolean') {
user.allow_save_posts = req.body.allow_save_posts
}
if (typeof req.body.ghost_mode === 'boolean') {
user.ghost_mode = req.body.ghost_mode
}
if (typeof req.body.show_location === 'boolean') {
user.show_location = req.body.show_location
}
if (typeof req.body.post_location_enabled === 'boolean') {
user.post_location_enabled = req.body.post_location_enabled
}
if (typeof req.body.is_private === 'boolean') {
user.is_private = req.body.is_private
}
if (typeof req.body.gift_enabled === 'boolean') {
user.gift_enabled = req.body.gift_enabled
}
if (typeof req.body.offer_payment_enabled === 'boolean') {
user.offer_payment_enabled = req.body.offer_payment_enabled
}
if (APP_LANGUAGE_CODES.includes(req.body.preferred_language)) {
user.preferred_language = req.body.preferred_language
}
await user.save()
return res.status(200).json({
allow_save_posts: user.allow_save_posts !== false,
ghost_mode: Boolean(user.ghost_mode),
@@ -193,7 +213,11 @@ const updatePrivacySettings = async (req, res) => {
preferred_language: user.preferred_language || 'fa',
})
} catch (err) {
return res.status(500).json({ message: 'خطا در ذخیره تنظیمات' })
console.error('updatePrivacySettings:', err)
return res.status(500).json({
message: 'خطا در ذخیره تنظیمات',
detail: err?.message || String(err),
})
}
}

View File

@@ -6,6 +6,7 @@ const RequestModel = require('../../../models/RequestModel')
const UserModel = require('../../../models/UserModel')
const jwt = require('jsonwebtoken')
const { buildUnifiedInterestScore, blendFreshIntoRanked } = require('../../../utils/exploreAlgorithm')
const { findActiveAd, buildAdProjectItem, trackAdEvent } = require('../../../utils/adDelivery')
function getOptionalUserId (req) {
try {
@@ -168,6 +169,27 @@ const getProjects = async (req, res, next) => {
} catch {
/* non-blocking: personalization is a bonus, never break the listing */
}
// تبلیغِ نوع project — عینِ الگوی first_post (adDelivery.js): فقط صفحه‌ی اول،
// فقط برای کاربرِ واردشده، خطاش هیچ‌وقت نباید لیستِ پروژه‌ها رو بشکنه
try {
const userId = getOptionalUserId(req)
if (userId) {
const viewer = await UserModel.findById(userId)
.select('country province city gender expertise sub_expertise')
.lean()
const ad = await findActiveAd('project', { ...viewer, _id: userId })
if (ad) {
const adProject = await buildAdProjectItem(ad)
if (adProject) {
newProj = [adProject, ...newProj]
trackAdEvent(ad._id, 'view').catch(() => {})
}
}
}
} catch (adError) {
console.error('project ad injection error:', adError.message)
}
}
res.status(200).json({

View File

@@ -0,0 +1,102 @@
const mongoose = require('mongoose')
const ShareableLinkModel = require('../../../models/ShareableLinkModel')
const UserModel = require('../../../models/UserModel')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const { resolveShareableLinkDisplay } = require('../../../utils/shareableLinkDisplay')
const KINDS = ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking']
const getMyShareableLinks = async (req, res, next) => {
try {
const rows = await ShareableLinkModel.find({ user_id: req.user._id })
.select('kind ref_id')
.lean()
return res.json({ links: rows.map((r) => ({ kind: r.kind, ref_id: r.ref_id })) })
} catch (error) {
next(error)
}
}
const toggleShareableLink = async (req, res, next) => {
try {
const { kind, ref_id: refId, shared } = req.body
if (!KINDS.includes(kind) || !refId) {
return res.status(422).json({ error: true, message: 'ورودی نامعتبر است' })
}
if (shared) {
// صفحه اشتراک‌گذاری لینک: فقط مالکیت کافی است (نه الزام وضعیت عمومی)
const resolvedId = await resolveLinkedEntity(kind, refId, req.user._id, {
requirePublic: false,
})
if (!resolvedId) {
return res.status(403).json({
error: true,
message: 'شما مالک این محتوا نیستید یا آیتم یافت نشد',
kind,
ref_id: String(refId),
})
}
await ShareableLinkModel.updateOne(
{ user_id: req.user._id, kind, ref_id: String(resolvedId) },
{ $setOnInsert: { user_id: req.user._id, kind, ref_id: String(resolvedId) } },
{ upsert: true }
)
} else {
await ShareableLinkModel.deleteOne({ user_id: req.user._id, kind, ref_id: String(refId) })
}
return res.json({ message: 'به‌روزرسانی شد' })
} catch (error) {
next(error)
}
}
const getShareableLinksForUsers = async (req, res, next) => {
try {
const raw = req.query.userIds
const userIds = (typeof raw === 'string' ? raw.split(',') : Array.isArray(raw) ? raw : [])
.map((id) => String(id).trim())
.filter((id) => mongoose.Types.ObjectId.isValid(id))
.slice(0, 50)
if (!userIds.length) return res.json({ users: [] })
const [rows, users] = await Promise.all([
ShareableLinkModel.find({ user_id: { $in: userIds } }).select('user_id kind ref_id').lean(),
UserModel.find({ _id: { $in: userIds } }).select('user_name').lean()
])
const usernameById = Object.fromEntries(users.map((u) => [String(u._id), u.user_name]))
const byUser = {}
for (const row of rows) {
const uid = String(row.user_id)
if (!byUser[uid]) byUser[uid] = []
byUser[uid].push(row)
}
const result = []
for (const uid of Object.keys(byUser)) {
const links = []
for (const row of byUser[uid]) {
const display = await resolveShareableLinkDisplay(row.kind, row.ref_id)
if (!display) continue
links.push({ kind: row.kind, ref_id: row.ref_id, title: display.title, image: display.image })
}
if (links.length) {
result.push({ userId: uid, username: usernameById[uid] || '', links })
}
}
return res.json({ users: result })
} catch (error) {
next(error)
}
}
module.exports = {
getMyShareableLinks,
toggleShareableLink,
getShareableLinksForUsers
}

View File

@@ -20,6 +20,8 @@ const { viewerIsBlockedBy } = require('../../../utils/blockVisibility')
const ShopCategoryModel = require('../../../models/ShopCategoryModel')
const { buildUnifiedInterestScore } = require('../../../utils/exploreAlgorithm')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
const { findActiveAd, buildAdShopProductItem, trackAdEvent } = require('../../../utils/adDelivery')
const AdModel = require('../../../models/AdModel')
/** Best-effort JWT decode for public browsing endpoints — never throws, never blocks guests. */
function getOptionalUserId (req) {
@@ -361,6 +363,29 @@ const getCatalogListings = async (req, res, next) => {
const visible = listings.filter((listing) => listing.shop?.status === 'active')
visible.sort((a, b) => cheapestVariantPrice(a) - cheapestVariantPrice(b))
// طبق خواسته‌ی کاربر: اگه یکی از همین لیستینگ‌ها (یه فروشگاهِ خاص که این کالا رو
// می‌فروشه) تبلیغِ فعالِ نوع product داره، اول از همه (قبلِ مرتب‌سازیِ ارزان‌ترین)
// نشون داده بشه — نه تزریقِ یه لیستینگِ جدید، فقط اولویت‌دهیِ دوباره به همین‌ها
try {
const activeAds = await AdModel.find({
ad_type: 'product',
status: 'active',
expires_at: { $gt: new Date() },
content_shop_product_id: { $in: visible.map((l) => l._id) }
}).select('content_shop_product_id').lean()
if (activeAds.length) {
const adListingIds = new Set(activeAds.map((a) => String(a.content_shop_product_id)))
visible.sort((a, b) => {
const aIsAd = adListingIds.has(String(a._id)) ? 1 : 0
const bIsAd = adListingIds.has(String(b._id)) ? 1 : 0
return bIsAd - aIsAd
})
}
} catch (adError) {
console.error('catalog listing ad sort error:', adError.message)
}
return res.json({ listings: visible })
} catch (error) {
next(error)
@@ -508,10 +533,30 @@ const getDiscoverFeed = async (req, res, next) => {
}
})
const merged = [...withShop.map((d) => ({ ...d, tileId: String(d._id) })), ...postTiles].sort(
let merged = [...withShop.map((d) => ({ ...d, tileId: String(d._id) })), ...postTiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
// تبلیغِ نوع product — عینِ الگوی first_post (adDelivery.js): فقط صفحه‌ی اول،
// فقط برای کاربرِ واردشده، خطاش هیچ‌وقت نباید گریدِ اکسپلورِ فروشگاه‌ها رو بشکنه
if (currentUserId && page === 1 && !q && !category && !pinterestOnly) {
try {
const viewer = await UserModel.findById(currentUserId)
.select('country province city gender expertise sub_expertise')
.lean()
const sponsoredAd = await findActiveAd('product', { ...viewer, _id: currentUserId })
if (sponsoredAd) {
const adProduct = await buildAdShopProductItem(sponsoredAd)
if (adProduct) {
merged = [adProduct, ...merged]
trackAdEvent(sponsoredAd._id, 'view').catch(() => {})
}
}
} catch (adError) {
console.error('product ad injection error:', adError.message)
}
}
return res.json({
docs: merged,
totalDocs,
@@ -690,7 +735,10 @@ const sendProductToUsers = async (req, res, next) => {
if (io) {
const formatted = await enrichMessage(newMessage)
emitChatEvents(io, formatted, senderId, receiverId)
emitChatEvents(io, formatted, senderId, receiverId, {
type: 'product_shared',
description: 'یک کالا برای شما ارسال کرد'
})
}
sentCount += 1
}

View File

@@ -13,6 +13,8 @@ const { filterStoryUserIdsByPrivateProfile } = require('../../../utils/privatePr
const { sendStoryReactionMessage } = require('../../../utils/storyChatMessage')
const { enrichMessage, emitChatEvents } = require('../../../helpers/chatHelpers')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const { findActiveAd, buildAdStoryGroup, trackAdEvent } = require('../../../utils/adDelivery')
const { notifyStoryMentions } = require('../../../utils/storyMentionNotification')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
@@ -240,6 +242,10 @@ const createStoryBase64 = async (req, res) => {
viewers: []
})
notifyStoryMentions(story, userId).catch((err) => {
console.error('[notifyStoryMentions] background error:', err.message)
})
return res.status(201).json({
message: 'استوری با موفقیت منتشر شد',
storyId: story._id
@@ -286,6 +292,10 @@ const createStoryMultipart = async (req, res) => {
viewers: []
})
notifyStoryMentions(story, userId).catch((err) => {
console.error('[notifyStoryMentions] background error:', err.message)
})
return res.status(201).json({
message: 'استوری با موفقیت منتشر شد',
storyId: story._id
@@ -400,6 +410,25 @@ const getStoriesFeed = async (req, res, next) => {
if (mine) myActiveStory = mine
}
// تبلیغِ نوع first_story — همیشه اولِ نوار، فقط برای کاربرِ واردشده (utils/adDelivery.js)
if (viewerId) {
try {
const viewer = await UserModel.findById(viewerId)
.select('country province city gender expertise sub_expertise')
.lean()
const ad = await findActiveAd('first_story', { ...viewer, _id: viewerId })
if (ad) {
const adGroup = await buildAdStoryGroup(ad)
if (adGroup) {
feed = [adGroup, ...feed]
trackAdEvent(ad._id, 'view').catch(() => {})
}
}
} catch (adError) {
console.error('first_story ad injection error:', adError.message)
}
}
return res.status(200).json({
feed,
my_story: myActiveStory,

View File

@@ -15,6 +15,8 @@ const {
const { resolveCommentModerationStatus } = require('../../../utils/contentModeration')
const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave')
const { createCommentNotification, createRatingNotification } = require('../../../utils/commentNotification')
const { runAutoDirectRules } = require('../../../utils/autoDirectTrigger')
const { findActiveAd, buildAdPostItem, trackAdEvent } = require('../../../utils/adDelivery')
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
const OfferModel = require('../../../models/OfferModel')
const PostModel = require('../../../models/PostModel')
@@ -49,6 +51,7 @@ const { buildAppearanceUserFilter } = require('../../../utils/appearanceFilter')
const { paginatePersonalizedExplore } = require('../../../utils/exploreAlgorithm')
const ExploreInteractionModel = require('../../../models/ExploreInteractionModel')
const { attachPostEngagementFields } = require('../../../utils/postEngagement')
const { resolveStoryFlags } = require('../../../utils/storyRingFlags')
const {
getFollowingReelsFeed,
getSavedReelsFeed,
@@ -1353,7 +1356,7 @@ const getPostsWeb = async (req, res, next) => {
explorePosts = await mapPostsWithUsers(explorePosts, usersMap, decodedToken)
const { posts: paginatedPosts, totalItems, isColdStart } =
let { posts: paginatedPosts, totalItems, isColdStart } =
await paginatePersonalizedExplore({
posts: explorePosts,
usersById,
@@ -1366,6 +1369,26 @@ const getPostsWeb = async (req, res, next) => {
cacheScope: reelsMode ? 'reels' : 'explore'
})
// تبلیغِ نوع first_post — فقط صفحه‌ی اول، فقط برای کاربرِ واردشده، خطاش هیچ‌وقت
// نباید فیدِ اصلی رو بشکنه (utils/adDelivery.js)
if (decodedToken?.id && parseInt(page, 10) === 1) {
try {
const viewer = await UserModel.findById(decodedToken.id)
.select('country province city gender expertise sub_expertise')
.lean()
const ad = await findActiveAd('first_post', { ...viewer, _id: decodedToken.id })
if (ad) {
const adPost = await buildAdPostItem(ad)
if (adPost) {
paginatedPosts = [adPost, ...paginatedPosts]
trackAdEvent(ad._id, 'view').catch(() => {})
}
}
} catch (adError) {
console.error('first_post ad injection error:', adError.message)
}
}
return res.status(200).json({
posts: paginatedPosts,
totalPages: Math.ceil(totalItems / limit) || 1,
@@ -2130,6 +2153,12 @@ const getComments = async (req, res, next) => {
return acc
}, {})
const commenterIds = [
...comments.docs.map((c) => c.creator._id),
...repliesRaw.map((r) => r.creator._id)
]
const storyFlags = await resolveStoryFlags(commenterIds, decodedToken.id)
const earliestRatedComments = await CommentModel.find({
user: user_id,
rating: { $ne: null, $gt: 0 }
@@ -2164,7 +2193,9 @@ const getComments = async (req, res, next) => {
user_name: comment.creator.user_name,
is_verified: comment.creator.is_verified,
verify_badge: comment.creator.verify_badge,
_id: comment.creator._id
_id: comment.creator._id,
has_story: storyFlags[creatorId]?.has_story || false,
has_unviewed_story: storyFlags[creatorId]?.has_unviewed_story || false
},
project: comment.project ? comment.project.project_name : null,
post_id: comment.post ? comment.post.toString() : null,
@@ -2307,6 +2338,17 @@ const createUserComment = async (req, res, next) => {
type: commentType
})
if (targetPostId) {
void runAutoDirectRules({
io: req.app.get('io'),
postOwnerId: ownerId,
commenterId: creatorId,
commentId: newComment._id,
postId: targetPostId,
commentText: comment
})
}
if (isNewRating && ratingValue > 0) {
await createRatingNotification({
io: req.app.get('io'),

View File

@@ -0,0 +1,175 @@
const AdModel = require('../../../models/AdModel')
const AdPricingRuleModel = require('../../../models/AdPricingRuleModel')
const UserModel = require('../../../models/UserModel')
const { ProvinceModel, CityModel, CountryModel } = require('../../../models/StateCity')
const AD_TYPES = AdModel.AD_TYPES
/** GET /panel/ads — تبِ اول: لیستِ تبلیغ‌های ثبت‌شده */
const listAds = async (req, res, next) => {
try {
const { page = 1, limit = 20, status, ad_type: adType } = req.query
const filter = {}
if (status) filter.status = status
if (adType) filter.ad_type = adType
const [ads, totalItems] = await Promise.all([
AdModel.find(filter)
.sort({ createdAt: -1 })
.skip((Number(page) - 1) * Number(limit))
.limit(Number(limit))
.lean(),
AdModel.countDocuments(filter)
])
const userIds = [...new Set(ads.map((a) => String(a.user_id)))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select('user_name first_name last_name profile_image')
.lean()
const usersById = Object.fromEntries(users.map((u) => [String(u._id), u]))
const now = Date.now()
const rows = ads.map((ad) => ({
...ad,
advertiser: usersById[String(ad.user_id)] || null,
effective_status:
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
}))
return res.json({
ads: rows,
totalItems,
totalPages: Math.ceil(totalItems / Number(limit)) || 1
})
} catch (error) {
next(error)
}
}
/** GET /panel/ads/:id — جزئیات کامل + آنالیتیکس یه تبلیغ */
const getAdDetailPanel = async (req, res, next) => {
try {
const ad = await AdModel.findById(req.params.id).lean()
if (!ad) {
return res.status(404).json({ error: true, message: 'تبلیغ یافت نشد' })
}
const [advertiser, country, province, city] = await Promise.all([
UserModel.findById(ad.user_id).select('user_name first_name last_name profile_image').lean(),
ad.target_country_id != null ? CountryModel.findOne({ id: ad.target_country_id }).lean() : null,
ad.target_province_id != null ? ProvinceModel.findOne({ id: ad.target_province_id }).lean() : null,
ad.target_city_id != null ? CityModel.findOne({ id: ad.target_city_id }).lean() : null
])
const now = Date.now()
const effectiveStatus =
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
return res.json({
ad: {
...ad,
effective_status: effectiveStatus,
advertiser,
target_country_name: country?.name || null,
target_province_name: province?.name || null,
target_city_name: city?.name || null,
click_through_rate: ad.views_count > 0 ? Number(((ad.clicks_count / ad.views_count) * 100).toFixed(2)) : 0
}
})
} catch (error) {
next(error)
}
}
/** GET /panel/ads/pricing — تبِ دوم: لیستِ قانون‌های قیمت */
const listPricingRules = async (req, res, next) => {
try {
const rules = await AdPricingRuleModel.find({}).sort({ createdAt: -1 }).lean()
const provinceIds = [...new Set(rules.map((r) => r.province_id).filter((v) => v != null))]
const provinces = provinceIds.length
? await ProvinceModel.find({ id: { $in: provinceIds } }).lean()
: []
const provincesById = Object.fromEntries(provinces.map((p) => [p.id, p.name]))
const rows = rules.map((r) => ({
...r,
province_name: r.province_id != null ? provincesById[r.province_id] || null : null
}))
return res.json({ rules: rows })
} catch (error) {
next(error)
}
}
/** POST /panel/ads/pricing — افزودنِ قانونِ قیمتِ یه سناریوی خاص */
const createPricingRule = async (req, res, next) => {
try {
const { ad_type: adType, province_id: provinceId, gender, expertise, sub_expertise: subExpertise, price } = req.body
if (!AD_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
if (!price || Number(price) <= 0) {
return res.status(422).json({ error: true, message: 'قیمت نامعتبر است' })
}
if (gender && !['female', 'male'].includes(gender)) {
return res.status(422).json({ error: true, message: 'جنسیت نامعتبر است' })
}
const rule = await AdPricingRuleModel.create({
ad_type: adType,
province_id: provinceId != null && provinceId !== '' ? Number(provinceId) : null,
gender: gender || null,
expertise: expertise ? String(expertise).trim() : null,
sub_expertise: subExpertise ? String(subExpertise).trim() : null,
price: Number(price)
})
return res.status(201).json({ rule })
} catch (error) {
next(error)
}
}
/** PATCH /panel/ads/pricing/:id */
const updatePricingRule = async (req, res, next) => {
try {
const rule = await AdPricingRuleModel.findById(req.params.id)
if (!rule) {
return res.status(404).json({ error: true, message: 'قانون یافت نشد' })
}
const { price, gender, expertise, sub_expertise: subExpertise, province_id: provinceId } = req.body
if (price !== undefined) rule.price = Number(price)
if (gender !== undefined) rule.gender = gender || null
if (expertise !== undefined) rule.expertise = expertise ? String(expertise).trim() : null
if (subExpertise !== undefined) rule.sub_expertise = subExpertise ? String(subExpertise).trim() : null
if (provinceId !== undefined) rule.province_id = provinceId != null && provinceId !== '' ? Number(provinceId) : null
await rule.save()
return res.json({ rule })
} catch (error) {
next(error)
}
}
/** DELETE /panel/ads/pricing/:id */
const deletePricingRule = async (req, res, next) => {
try {
await AdPricingRuleModel.deleteOne({ _id: req.params.id })
return res.json({ success: true })
} catch (error) {
next(error)
}
}
module.exports = {
listAds,
getAdDetailPanel,
listPricingRules,
createPricingRule,
updatePricingRule,
deletePricingRule
}

View File

@@ -3,8 +3,12 @@ const NotificationModel = require('../../../models/NotificationModel')
const UserModel = require('../../../models/UserModel')
const jMoment = require('moment-jalaali')
const { sendPushToTokens } = require('../../../utils/pushNotification')
const { buildStorageUrl } = require('../../../utils/storageUrl')
/** ارسال اعلان همگانی — مثل ارسال پیامک، به همه‌ی کاربران */
/** ارسال اعلان همگانی — مثل ارسال پیامک، به همه‌ی کاربران. بنر اختیاریه (multer:
* routes/panel/notifications/index.js) — هم توی خودِ پوشِ FCM (notification.image،
* از قبل توی utils/pushNotification.js پشتیبانی می‌شد) و هم توی ردیفِ دیتابیسِ هر
* کاربر (برای لیستِ داخلِ اپ) ذخیره می‌شه */
const createBroadcast = async (req, res, next) => {
try {
const { title, body } = req.body
@@ -12,11 +16,14 @@ const createBroadcast = async (req, res, next) => {
return res.status(400).json({ message: 'عنوان و متن اعلان الزامی است' })
}
const image = req.file ? buildStorageUrl(`/broadcasts/${req.file.filename}`) : null
const users = await UserModel.find({}).select('_id fcm_tokens fcm_web_tokens').lean()
const broadcast = await AdminBroadcastModel.create({
title,
body,
image,
recipientCount: users.length,
sentBy: req.admin?._id
})
@@ -28,6 +35,7 @@ const createBroadcast = async (req, res, next) => {
type: 'admin_broadcast',
title,
description: body,
image,
broadcast_id: broadcast._id
}))
)
@@ -39,7 +47,8 @@ const createBroadcast = async (req, res, next) => {
title,
description: body,
type: 'admin_broadcast',
data: { broadcastId: String(broadcast._id) }
data: { broadcastId: String(broadcast._id) },
image
}).catch((err) => console.error('[notificationsController] ارسال پوش همگانی خطا:', err.message))
}

View File

@@ -68,7 +68,7 @@ async function enrichMessage(message) {
})
}
function emitChatEvents(io, message, senderId, receiverId) {
function emitChatEvents(io, message, senderId, receiverId, pushOverride) {
const sid = normalizeId(senderId)
const rid = normalizeId(receiverId)
const payload = normalizeMessagePayload(
@@ -86,21 +86,22 @@ function emitChatEvents(io, message, senderId, receiverId) {
message: payload
})
sendChatPushNotification(sid, rid).catch(() => {})
sendChatPushNotification(sid, rid, pushOverride).catch(() => {})
}
// پیام‌ها E2EE هستن (رمزنگاری سرتاسر) — سرور محتوا رو نمی‌بینه و نباید توی پوش هم نشونش بده،
// فقط اسم فرستنده + یه متن ثابت. عین همین تابع برای همه‌ی مسیرهای emitChatEvents (چت مستقیم،
// پاسخ استوری، اشتراک‌گذاری پست/محصول توی چت) صدا زده می‌شه
async function sendChatPushNotification(senderId, receiverId) {
// پیام‌های متنیِ معمولی E2EE هستن (رمزنگاری سرتاسر) — سرور محتوا رو نمی‌بینه و نباید توی
// پوش هم نشونش بده، فقط اسم فرستنده + یه متن ثابت. اما اشتراک‌گذاریِ پست/محصول (که خودِ
// سرور با دادهٔ واقعیِ دیتابیس می‌سازه، نه متنِ رمزنگاری‌شدهٔ کاربر) می‌تونه با pushOverride
// نوع/متنِ دقیق‌تری بفرسته (نگاه کن به فراخوانی‌ها توی engagementController.js/shopProductController.js)
async function sendChatPushNotification(senderId, receiverId, pushOverride = {}) {
const sender = await UserModel.findById(senderId).select('first_name last_name user_name profile_image').lean()
if (!sender) return
const name = `${sender.first_name || ''} ${sender.last_name || ''}`.trim() || sender.user_name || 'کاربر'
await sendPushToUser(receiverId, {
title: name,
description: 'یک پیام جدید برای شما ارسال کرد',
type: 'chat_message',
data: { project_post_id: senderId },
description: pushOverride.description || 'یک پیام جدید برای شما ارسال کرد',
type: pushOverride.type || 'chat_message',
data: { project_post_id: senderId, link: `/settings/chats/${sender.user_name || ''}/${senderId}` },
image: buildStorageUrl(sender.profile_image) || undefined
})
}

View File

@@ -161,6 +161,8 @@ app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/
app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage/broadcasts', express.static(path.join(__dirname, '../storage/broadcasts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/broadcasts', express.static(path.join(__dirname, 'storage/broadcasts'), STATIC_MEDIA_OPTIONS));
app.use('/storage', express.static(path.join(__dirname, 'storage'), STATIC_MEDIA_OPTIONS));
require('./boot');
@@ -684,6 +686,9 @@ require('./services/SmartNotificationsCron').setIo(io)
require('./services/InstagramMediaSyncCron')
require('./utils/notifyFollowers').setIo(io)
require('./utils/partnerMatch').setIo(io)
require('./utils/tagNotification').setIo(io)
require('./utils/academyNotification').setIo(io)
require('./utils/storyMentionNotification').setIo(io)
cron.schedule('*/30 * * * * *', () => {
purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err))

View File

@@ -14,6 +14,9 @@ const modelSchema = new mongoose.Schema({
payment_authority: { type: String },
payment_ref_id: { type: String },
status: { type: String, enum: ["pending", "success", "failed", "settled"], default: "success" },
// فقط «قصدِ» استفاده از کیف پول رو نگه می‌داره تا لحظه‌ی تاییدِ verify — کسرِ واقعی
// اونجا انجام می‌شه (utils/walletSplit.js)
walletAmountUsed: { type: Number, default: 0 },
});

72
models/AdModel.js Normal file
View File

@@ -0,0 +1,72 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const mongoosePaginate = require('mongoose-paginate-v2')
// تبلیغِ کاربری — نوعش تعیین می‌کنه محتواش کجا تزریق می‌شه: first_post (اول فید)،
// first_story (اول نوار استوری)، notification (پوش مستقیم به کاربرهای هدف)، project/
// product/shop/billboard (اول لیستِ مربوطه، از utils/adDelivery.js:findActiveAd با همون
// ad_type resolve می‌شه). هدف‌گذاری (لوکیشن/تخصص/جنسیت) روی همون فیلدهایی که
// User/Post/exploreAlgorithm.js از قبل استفاده می‌کنن سوار شده (province.id عددیه، نه
// ObjectId — utils/adPricing.js همین رو resolve می‌کنه)
const AD_TYPES = ['first_post', 'first_story', 'notification', 'project', 'product', 'shop', 'billboard']
const adSchema = new mongoose.Schema({
user_id: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
ad_type: { type: String, enum: AD_TYPES, required: true },
// محتوا — بسته به ad_type فقط یکی از این پر می‌شه
content_post_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', default: null },
content_project_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Project', default: null },
content_shop_product_id: { type: mongoose.Schema.Types.ObjectId, ref: 'ShopProductListing', default: null },
content_shop_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Shop', default: null },
content_billboard_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Advertising', default: null },
// استوری‌های واقعی معمولاً ۲۴ساعته منقضی می‌شن (StoryModel: expires_at) و از فیدِ
// استوریِ معمولی حذف می‌شن، ولی یه کمپینِ تبلیغاتی می‌تونه چند روز فعال بمونه — پس
// مدیا/اورلی‌ها رو موقعِ ساختِ تبلیغ اینجا کپی/اسنپ‌شات می‌کنیم تا مستقل از عمرِ
// طبیعیِ خودِ استوری باشه (content_story_id فقط برای رفرنسِ منبع نگه داشته می‌شه)
content_story_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Story', default: null },
content_media_path: { type: String, default: null },
content_media_type: { type: String, default: null },
content_overlays: { type: mongoose.Schema.Types.Mixed, default: null },
notification_title: { type: String, trim: true, default: null },
notification_body: { type: String, trim: true, default: null },
// هدف‌گذاری
target_country_id: { type: Number, default: null },
target_province_id: { type: Number, default: null },
target_city_id: { type: Number, default: null },
target_expertise: { type: String, trim: true, default: null },
target_sub_expertise: { type: [String], default: [] },
target_gender: { type: String, enum: ['female', 'male', 'any'], default: 'any' },
// مالی
price: { type: Number, required: true },
walletAmountUsed: { type: Number, default: 0 },
payment_authority: { type: String, default: null },
status: {
type: String,
enum: ['pending_payment', 'active', 'expired', 'failed'],
default: 'pending_payment'
},
started_at: { type: Date, default: null },
// عین بقیه‌ی کمپین‌های اپ، یه مدت‌زمانِ ثابت (AD_CAMPAIGN_DURATION_DAYS توی
// adsController.js) بعد از فعال‌شدن — بعدش دیگه توی تزریقِ فید/استوری/نوتیف لحاظ نمی‌شه
expires_at: { type: Date, default: null },
views_count: { type: Number, default: 0 },
clicks_count: { type: Number, default: 0 }
})
// تزریق (سرچِ یه تبلیغِ فعالِ منطبق روی فید/استوری) و لیستِ «تبلیغ‌های من» دو مصرفِ
// اصلیِ ایندکسن
adSchema.index({ status: 1, ad_type: 1, expires_at: 1 })
adSchema.index({ user_id: 1, createdAt: -1 })
adSchema.plugin(timestamp)
adSchema.plugin(mongoosePaginate)
const AdModel = mongoose.model('Ad', adSchema)
// روی خودِ مدل به‌عنوان استاتیک — تا adsController.js/adsPanelController.js بدونِ
// require جدا به لیستِ معتبرِ ad_type دسترسی داشته باشن (module.exports همون مدله، نه یه آبجکت)
AdModel.AD_TYPES = AD_TYPES
module.exports = AdModel

View File

@@ -0,0 +1,24 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
// قانونِ قیمت‌گذاریِ ادمین برای یه «سناریو»ی تبلیغ — هر فیلدِ null یعنی وایلدکارت
// (روی هر مقداری از اون بعد صدق می‌کنه)؛ utils/adPricing.js دقیق‌ترین قانونِ منطبق
// (بیشترین فیلدِ غیرِnull) رو انتخاب می‌کنه
const adPricingRuleSchema = new mongoose.Schema({
ad_type: {
type: String,
enum: ['first_post', 'first_story', 'notification', 'project', 'product', 'shop', 'billboard'],
required: true
},
province_id: { type: Number, default: null },
gender: { type: String, enum: ['female', 'male'], default: null },
expertise: { type: String, trim: true, default: null },
sub_expertise: { type: String, trim: true, default: null },
price: { type: Number, required: true }
})
adPricingRuleSchema.index({ ad_type: 1 })
adPricingRuleSchema.plugin(timestamp)
const AdPricingRuleModel = mongoose.model('AdPricingRule', adPricingRuleSchema)
module.exports = AdPricingRuleModel

View File

@@ -19,6 +19,11 @@ const adminBroadcastSchema = new mongoose.Schema({
type: Number,
default: 0
},
image: {
type: String,
required: false,
default: null
},
sentBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Admin',

View File

@@ -18,6 +18,11 @@ const advertisingCommentSchema = new mongoose.Schema({
required: true,
trim: true
},
rate: {
type: Number,
required: false,
default: null
},
status: {
type: String,
enum: ['pending', 'temp_accept', 'accepted', 'rejected'],

View File

@@ -0,0 +1,24 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
const autoDirectRuleSchema = new mongoose.Schema({
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
trigger_text: { type: String, required: true, trim: true },
direct_message: { type: String, required: true, trim: true },
reply_caption: { type: String, required: true, trim: true },
is_active: { type: Boolean, default: true },
// هر بار این قانون واقعاً اجرا بشه (دایرکت ارسال + کامنتِ پاسخ ثبت بشه) یکی زیاد می‌شه —
// برای نمایشِ «تعداد کامنت‌های ارسالی» توی لیستِ AutoDirectScreen.tsx
sent_count: { type: Number, default: 0 }
})
// autoDirectTrigger.js هر کامنتِ تازه رو در برابرِ همه‌ی قانون‌های فعالِ صاحبِ پست چک می‌کنه
autoDirectRuleSchema.index({ user_id: 1, is_active: 1 })
autoDirectRuleSchema.plugin(timestamp)
const AutoDirectRuleModel = mongoose.model('AutoDirectRule', autoDirectRuleSchema)
module.exports = AutoDirectRuleModel

View File

@@ -33,6 +33,13 @@ const notificationSchema = new mongoose.Schema({
ref: 'User',
required: false
},
// آواتار فرستنده (برای نوتیف‌های مربوط به یه کاربر خاص) یا بنرِ دستیِ ادمین
// (برای admin_broadcast) — هم توی لیستِ داخلِ اپ و هم توی خودِ پوش FCM استفاده می‌شه
image: {
type: String,
required: false,
default: null
},
// فقط برای اعلان‌های همگانی پنل — جدا از read (که یعنی صفحه اعلان‌ها باز شده)،
// seen یعنی پوش واقعاً به دستگاه رسیده و clicked یعنی کاربر روش ضربه زده
broadcast_id: {

View File

@@ -0,0 +1,28 @@
const mongoose = require('mongoose')
const timestamp = require('mongoose-timestamp')
// اشتراک‌گذاری لینک: کاربر مشخص می‌کنه کدوم محتوای خودش (پکیج/پروژه/بیلبورد/فروشگاه/
// کالا/رزرو) وقتی یکی دیگه توی ثبت پست تگش می‌کنه، توی لیست لینک‌های قابل‌انتخاب اون
// کاربر (بلاگر) نشون داده بشه — shareableLinksController.js/for-users همین رکوردها رو می‌خونه
const shareableLinkSchema = new mongoose.Schema({
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
kind: {
type: String,
enum: ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking'],
required: true
},
ref_id: {
type: String,
required: true
}
})
shareableLinkSchema.index({ user_id: 1, kind: 1, ref_id: 1 }, { unique: true })
shareableLinkSchema.plugin(timestamp)
const ShareableLinkModel = mongoose.model('ShareableLink', shareableLinkSchema)
module.exports = ShareableLinkModel

View File

@@ -0,0 +1,23 @@
const express = require('express')
const {
getAdPricing,
createAd,
verifyAdPayment,
getMyAds,
getAdDetail,
getSponsoredAd,
trackAdEventEndpoint
} = require('../../../controllers/application/ads/adsController')
const blockCheck = require('../../../middlewares/blockCheck')
const router = express.Router()
router.get('/pricing', [blockCheck], getAdPricing)
router.post('/', [blockCheck], createAd)
router.get('/payment/verify', verifyAdPayment)
router.get('/mine', [blockCheck], getMyAds)
router.get('/sponsored', [blockCheck], getSponsoredAd)
router.get('/:id', [blockCheck], getAdDetail)
router.post('/:id/track', [blockCheck], trackAdEventEndpoint)
module.exports = router

View File

@@ -0,0 +1,17 @@
const express = require('express')
const {
getMyAutoDirectRules,
createAutoDirectRule,
updateAutoDirectRule,
deleteAutoDirectRule
} = require('../../../controllers/application/autoDirect/autoDirectController')
const blockCheck = require('../../../middlewares/blockCheck')
const router = express.Router()
router.get('/mine', [blockCheck], getMyAutoDirectRules)
router.post('/', [blockCheck], createAutoDirectRule)
router.patch('/:id', [blockCheck], updateAutoDirectRule)
router.delete('/:id', [blockCheck], deleteAutoDirectRule)
module.exports = router

View File

@@ -0,0 +1,6 @@
const express = require('express')
const { checkGeo } = require('../../../controllers/application/geo/geoController')
const router = express.Router()
router.get('/check', checkGeo)
module.exports = router

View File

@@ -0,0 +1,15 @@
const express = require('express')
const {
getMyShareableLinks,
toggleShareableLink,
getShareableLinksForUsers
} = require('../../../controllers/application/shareableLinks/shareableLinksController')
const blockCheck = require('../../../middlewares/blockCheck')
const router = express.Router()
router.get('/mine', [blockCheck], getMyShareableLinks)
router.post('/toggle', [blockCheck], toggleShareableLink)
router.get('/for-users', [blockCheck], getShareableLinksForUsers)
module.exports = router

View File

@@ -16,6 +16,9 @@ const ordersRouter = require('./application/orders')
const shopOrderPaymentRouter = require('./application/payment/shop-order')
const walletRouter = require('./application/wallet')
const bookingsRouter = require('./application/bookings')
const autoDirectRouter = require('./application/autoDirect')
const shareableLinksRouter = require('./application/shareableLinks')
const adsRouter = require('./application/ads')
const profileRouter = require('./application/profile')
const workroomRouter = require('./application/workroom')
const messagesRouter = require('./application/messages')
@@ -36,10 +39,12 @@ const giftsRouter = require('./application/gifts')
const chatRoomsRouter = require('./application/chat-rooms')
const suggestionsRouter = require('./application/suggestions')
const analyticsRouter = require('./application/analytics')
const geoRouter = require('./application/geo')
const projectsPanelRouter = require('./panel/projects')
const postsPanelRouter = require('./panel/posts')
const expertisePanelRouter = require('./panel/expertise')
const adsPanelRouter = require('./panel/ads')
const shopCategoriesPanelRouter = require('./panel/shop-categories')
const marketplaceShopsPanelRouter = require('./panel/marketplace-shops')
const walletRequestsPanelRouter = require('./panel/wallet-requests')
@@ -89,6 +94,9 @@ module.exports = (app) => {
app.use('/api/v1/payment/shop-order', shopOrderPaymentRouter)
app.use('/api/v1/wallet', walletRouter)
app.use('/api/v1/bookings', bookingsRouter)
app.use('/api/v1/auto-direct', autoDirectRouter)
app.use('/api/v1/shareable-links', shareableLinksRouter)
app.use('/api/v1/ads', adsRouter)
app.use('/api/v1/profile', profileRouter)
app.use('/api/v1/account', accountRouter)
app.use('/api/v1/gifts', giftsRouter)
@@ -106,6 +114,7 @@ module.exports = (app) => {
app.use('/api/v1/advertising', advertisingRouter)
app.use('/api/v1/settings', settingsRouter)
app.use('/api/v1/version', versionRouter)
app.use('/api/v1/geo', geoRouter)
app.use('/api/v1/academy', academyRoute)
app.use('/api/v1/stories', storiesRouter)
@@ -118,6 +127,7 @@ module.exports = (app) => {
app.use('/api/v1/panel/projects', [adminAuth, requirePermission('projects')], projectsPanelRouter)
app.use('/api/v1/panel/posts', [adminAuth, requirePermission('posts')], postsPanelRouter)
app.use('/api/v1/panel/expertise', [adminAuth, requirePermission('expertise')], expertisePanelRouter)
app.use('/api/v1/panel/ads', [adminAuth, requirePermission('ads')], adsPanelRouter)
app.use('/api/v1/panel/shop-categories', [adminAuth, requirePermission('shop-categories')], shopCategoriesPanelRouter)
app.use('/api/v1/panel/marketplace-shops', [adminAuth, requirePermission('marketplace-shops')], marketplaceShopsPanelRouter)
app.use('/api/v1/panel/wallet-requests', [adminAuth, requirePermission('wallet-requests')], walletRequestsPanelRouter)

21
routes/panel/ads/index.js Normal file
View File

@@ -0,0 +1,21 @@
const express = require('express')
const {
listAds,
getAdDetailPanel,
listPricingRules,
createPricingRule,
updatePricingRule,
deletePricingRule
} = require('../../../controllers/panel/ads/adsPanelController')
const router = express.Router()
router.get('/pricing', listPricingRules)
router.post('/pricing', createPricingRule)
router.patch('/pricing/:id', updatePricingRule)
router.delete('/pricing/:id', deletePricingRule)
router.get('/', listAds)
router.get('/:id', getAdDetailPanel)
module.exports = router

View File

@@ -1,7 +1,31 @@
const express = require('express')
const path = require('path')
const fs = require('fs')
const multer = require('multer')
const { createBroadcast, getBroadcasts, getBroadcastDetail } = require('../../../controllers/panel/notifications/notificationsController')
const router = express.Router()
const broadcastStorageDir = path.join(__dirname, '../../../storage/broadcasts')
if (!fs.existsSync(broadcastStorageDir)) {
fs.mkdirSync(broadcastStorageDir, { recursive: true })
}
const broadcastUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, broadcastStorageDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg'
cb(null, `${Date.now()}-broadcast${ext}`)
}
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) return cb(null, true)
cb(new Error('فقط تصویر مجاز است'), false)
}
}).single('image')
router.get('/', getBroadcasts)
router.get('/:id', getBroadcastDetail)
router.post('/broadcast', createBroadcast)
router.post('/broadcast', broadcastUpload, createBroadcast)
module.exports = router

View File

@@ -1,7 +1,15 @@
const NotificationModel = require("../models/NotificationModel");
const UserModel = require("../models/UserModel");
const { notifyUser } = require("./notifyUser");
const { buildStorageUrl } = require("./storageUrl");
const PURCHASE_TITLE = "پکیج شما خریداری شد";
const PURCHASE_DESCRIPTION = "یک کاربر پکیج آموزشی شما را خریداری کرد";
const PURCHASE_DESCRIPTION = "دوره‌ی آموزشی شما را خریداری کرد";
// همون الگوی tagNotification.js/notifyFollowers.js — چون این تابع مستقیم از کنترلر
// صدا زده می‌شه (نه یه جاب زمان‌بندی‌شده)، io از index.js با همین setIo تزریق می‌شه
let ioInstance = null;
function setIo(io) {
ioInstance = io;
}
const createCoursePurchaseNotification = async ({
ownerId,
@@ -12,12 +20,19 @@ const createCoursePurchaseNotification = async ({
if (!ownerId || !buyerId || !courseId) return;
if (String(ownerId) === String(buyerId)) return;
await NotificationModel.create({
user_id: ownerId,
project_post_id: courseId,
const buyer = await UserModel.findById(buyerId).select(
"user_name profile_image"
);
const buyerUsername = buyer?.user_name || "کاربر";
const buyerImage = buildStorageUrl(buyer?.profile_image) || undefined;
await notifyUser(ioInstance, ownerId, {
type: "academy_purchase",
title: PURCHASE_TITLE,
title: buyerUsername,
description: PURCHASE_DESCRIPTION,
project_post_id: courseId,
actor_id: buyerId,
image: buyerImage,
});
} catch (error) {
console.error("Error creating course purchase notification:", error);
@@ -26,4 +41,5 @@ const createCoursePurchaseNotification = async ({
module.exports = {
createCoursePurchaseNotification,
setIo,
};

255
utils/adDelivery.js Normal file
View File

@@ -0,0 +1,255 @@
const AdModel = require('../models/AdModel')
const PostModel = require('../models/PostModel')
const UserModel = require('../models/UserModel')
const CommentModel = require('../models/CommentModel')
const ProjectModel = require('../models/ProjectModel')
const ShopProductListingModel = require('../models/ShopProductListingModel')
const ShopModel = require('../models/ShopModel')
const AdvertisingModel = require('../models/AdvertisingModel')
const AdvertisingProfileModel = require('../models/AdvertisingProfile')
const { sendPushToTokens } = require('./pushNotification')
const { resolveShareableLinkDisplay } = require('./shareableLinkDisplay')
const LINKED_CONTENT_KIND = {
project: { kind: 'project', field: 'content_project_id' },
product: { kind: 'shopproduct', field: 'content_shop_product_id' },
shop: { kind: 'shop', field: 'content_shop_id' },
billboard: { kind: 'billboard', field: 'content_billboard_id' }
}
/**
* یه تبلیغِ فعالِ منطبق با پروفایلِ بیننده پیدا می‌کنه — هر فیلدِ هدف‌گذاری که تبلیغ‌کننده
* خالی گذاشته (null/آرایه‌ی خالی) یعنی وایلدکارت (روی هر بیننده‌ای صدق می‌کنه). خودِ
* تبلیغ‌کننده تبلیغِ خودش رو نمی‌بینه. اگه چند تبلیغِ منطبق بود، یکی رندوم انتخاب می‌شه
* (چرخشِ ساده، به‌جای همیشه یکی رو نشون دادن)
*/
async function findActiveAd (adType, viewer) {
if (!viewer?._id) return null
const query = {
status: 'active',
ad_type: adType,
expires_at: { $gt: new Date() },
user_id: { $ne: viewer._id },
$and: [
{ $or: [{ target_country_id: null }, { target_country_id: viewer.country?.id ?? null }] },
{ $or: [{ target_province_id: null }, { target_province_id: viewer.province?.id ?? null }] },
{ $or: [{ target_city_id: null }, { target_city_id: viewer.city?.id ?? null }] },
{ $or: [{ target_gender: 'any' }, { target_gender: viewer.gender || '__none__' }] },
{ $or: [{ target_expertise: null }, { target_expertise: viewer.expertise || '__none__' }] },
{
$or: [
{ target_sub_expertise: { $size: 0 } },
{ target_sub_expertise: { $in: viewer.sub_expertise || [] } }
]
}
]
}
const candidates = await AdModel.find(query).limit(20).lean()
if (!candidates.length) return null
return candidates[Math.floor(Math.random() * candidates.length)]
}
/** عینِ shape خروجیِ mapPostsWithUsers (getUserController.js) — تا کارتِ پست توی فرانت
* بدونِ نیاز به شرطِ جدا، پستِ تبلیغاتی رو هم درست رندر کنه */
async function buildAdPostItem (ad) {
if (!ad?.content_post_id) return null
const post = await PostModel.findById(ad.content_post_id).lean()
if (!post) return null
const user = await UserModel.findById(post.user_id).lean()
if (!user) return null
const commentsCount = await CommentModel.countDocuments({
post: post._id,
status: { $in: ['accepted', 'temp_accept'] }
})
return {
...post,
user_name: user.user_name || '',
first_name: user.first_name || '',
last_name: user.last_name || '',
expertise: user.expertise || '',
sub_expertise: user.sub_expertise || [],
province: user.show_location ? user.province || {} : {},
city: user.show_location ? user.city || {} : {},
show_location: user.show_location,
user_level: user.user_level || '',
profile_image: user.profile_image || '',
is_verified: user.is_verified || '',
verify_badge: user.verify_badge || '',
user_score: user.user_score || 0,
rate: user.rate || 0,
likesCount: post.likes ? post.likes.length : 0,
is_liked: false,
commentsCount,
is_ad: true,
ad_id: String(ad._id)
}
}
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')
}
/** عینِ buildAdPostItem ولی برای استوری — getStoriesFeed هر گروه رو با user+stories
* برمی‌گردونه. از مدیای اسنپ‌شات‌شده‌ی خودِ AdModel استفاده می‌کنه (نه StoryModel زنده) —
* چون استوریِ اصلی معمولاً ۲۴ساعته منقضی می‌شه ولی کمپینِ تبلیغاتی چند روزه‌ست */
async function buildAdStoryGroup (ad) {
if (!ad?.content_media_path) return null
const user = await UserModel.findById(ad.user_id).lean()
if (!user) return null
return {
user: {
_id: user._id,
user_name: user.user_name,
profile_image: user.profile_image,
is_verified: user.is_verified,
verify_badge: user.verify_badge
},
stories: [{
_id: ad.content_story_id,
media_path: storyMediaUrl(ad.content_media_path),
media_type: ad.content_media_type,
overlays: ad.content_overlays || [],
linked_post_id: null,
createdAt: ad.started_at,
expires_at: ad.expires_at,
viewed: false,
liked: false,
likes_count: 0,
is_ad: true,
ad_id: String(ad._id)
}],
has_unviewed: true,
is_following: false,
is_ad: true,
ad_id: String(ad._id),
latest_at: ad.started_at
}
}
/** برای چهار نوعِ «تبلیغِ محتوای موجود» (پروژه/کالا/فروشگاه/بیلبورد) — دقیقاً همون
* resolveShareableLinkDisplay که برای لینک‌های اشتراکی‌ِ کپشن استفاده می‌شه (title+image) */
async function buildAdLinkedContentItem (ad) {
const entry = LINKED_CONTENT_KIND[ad?.ad_type]
if (!entry) return null
const contentId = ad[entry.field]
if (!contentId) return null
const display = await resolveShareableLinkDisplay(entry.kind, contentId)
if (!display) return null
return {
ad_id: String(ad._id),
ad_type: ad.ad_type,
content_id: String(contentId),
title: display.title,
image: display.image,
is_ad: true
}
}
/** عینِ shape خروجیِ getProjects (enrichProjectsWithRequests) — یه سندِ خامِ ProjectModel،
* بدونِ projectRequests (پروژه‌ی تبلیغاتی نیازی به درخواست‌ها نداره) */
async function buildAdProjectItem (ad) {
if (!ad?.content_project_id) return null
const project = await ProjectModel.findById(ad.content_project_id).lean()
if (!project) return null
return { ...project, is_ad: true, ad_id: String(ad._id) }
}
/** عینِ shape خروجیِ getAdvertisingsWeb (لیستِ بیلبوردها) */
async function buildAdBillboardItem (ad) {
if (!ad?.content_billboard_id) return null
const billboard = await AdvertisingModel.findById(ad.content_billboard_id).lean()
if (!billboard) return null
const creator = await AdvertisingProfileModel.findOne({ user: billboard.creator_id }).lean()
return {
_id: billboard._id,
category: billboard.category,
title: billboard.title,
province: billboard.province,
city: billboard.city,
neighbourhood: billboard.neighbourhood,
type: billboard.type,
mostDiscountPercentage: billboard.mostDiscountPercentage,
images: billboard.images,
likedByUser: false,
likesCount: billboard.likesCount,
commentsCount: billboard.commentsCount,
showDiscount: billboard.showDiscount,
viewCount: billboard.viewCount,
createdAt: billboard.createdAt,
rate: { adTotalRatings: creator?.adTotalRatings, adAverageRating: creator?.adAverageRating },
is_ad: true,
ad_id: String(ad._id)
}
}
/** عینِ shape خروجیِ getDiscoverFeed (گریدِ اکسپلورِ فروشگاه‌ها) — لیستینگ + shop پاپیوله‌شده */
async function buildAdShopProductItem (ad) {
if (!ad?.content_shop_product_id) return null
const listing = await ShopProductListingModel.findById(ad.content_shop_product_id).lean()
if (!listing || listing.status !== 'active') return null
const shop = await ShopModel.findById(listing.shop).populate('owner', 'verify_badge').lean()
if (!shop || shop.status !== 'active') return null
return {
...listing,
shop,
shop_count: 1,
tileId: `ad-${ad._id}`,
is_ad: true,
ad_id: String(ad._id)
}
}
async function trackAdEvent (adId, type) {
if (!adId || !['view', 'click'].includes(type)) return
const field = type === 'view' ? 'views_count' : 'clicks_count'
await AdModel.updateOne({ _id: adId }, { $inc: { [field]: 1 } })
}
/** تبلیغِ نوع notification — به‌جای تزریق توی فید/استوری، مستقیم پوش می‌فرسته به هر
* کاربری که با هدف‌گذاریِ تبلیغ جور دربیاد؛ views_count رو با تعدادِ گیرنده‌ها پر می‌کنه */
async function deliverNotificationAd (ad) {
if (ad.ad_type !== 'notification') return
const query = { _id: { $ne: ad.user_id } }
if (ad.target_country_id != null) query['country.id'] = ad.target_country_id
if (ad.target_province_id != null) query['province.id'] = ad.target_province_id
if (ad.target_city_id != null) query['city.id'] = ad.target_city_id
if (ad.target_gender && ad.target_gender !== 'any') query.gender = ad.target_gender
if (ad.target_expertise) query.expertise = ad.target_expertise
if (ad.target_sub_expertise?.length) query.sub_expertise = { $in: ad.target_sub_expertise }
const users = await UserModel.find(query).select('fcm_tokens fcm_web_tokens').lean()
const tokens = users.flatMap((u) => [...(u.fcm_tokens || []), ...(u.fcm_web_tokens || [])])
if (tokens.length) {
await sendPushToTokens(tokens, {
title: ad.notification_title || 'تبلیغ',
description: ad.notification_body || '',
type: 'sponsored_notification',
data: { ad_id: String(ad._id) }
})
}
await AdModel.updateOne({ _id: ad._id }, { $inc: { views_count: users.length } })
}
module.exports = {
findActiveAd,
buildAdPostItem,
buildAdStoryGroup,
buildAdLinkedContentItem,
buildAdProjectItem,
buildAdBillboardItem,
buildAdShopProductItem,
trackAdEvent,
deliverNotificationAd
}

39
utils/adPricing.js Normal file
View File

@@ -0,0 +1,39 @@
const AdPricingRuleModel = require('../models/AdPricingRuleModel')
/**
* دقیق‌ترین قانونِ منطبق رو پیدا می‌کنه — یه قانون منطبقه اگه هر فیلدِ غیرِnullش دقیقاً با
* سناریوی داده‌شده یکی باشه (فیلدِ null همیشه صدق می‌کنه، یعنی وایلدکارت). بینِ قانون‌های
* منطبق، اونی که بیشترین فیلدِ غیرِnull (دقیق‌ترین) رو داره برنده‌ست.
* gender/expertise/provinceId که خودِ کاربر انتخاب نکرده (یعنی «هرکسی»/«فرقی نمی‌کنه») باید
* null پاس داده بشه، نه رشته‌ی خالی — وگرنه هیچ قانونِ وایلدکاردی هم منطبق نمی‌شه.
* subExpertise آرایه‌ست (تبلیغ می‌تونه چند زیرتخصص هدف‌گذاری کنه) — قانونِ subExpertise
* وقتی منطبقه که همون یکی مقدارش توی این آرایه باشه
*/
async function resolveAdPrice (adType, { provinceId, gender, expertise, subExpertise } = {}) {
const rules = await AdPricingRuleModel.find({ ad_type: adType }).lean()
const subExpertiseList = Array.isArray(subExpertise) ? subExpertise : []
let best = null
let bestScore = -1
for (const rule of rules) {
if (rule.province_id != null && rule.province_id !== provinceId) continue
if (rule.gender != null && rule.gender !== gender) continue
if (rule.expertise != null && rule.expertise !== expertise) continue
if (rule.sub_expertise != null && !subExpertiseList.includes(rule.sub_expertise)) continue
const score =
(rule.province_id != null ? 1 : 0) +
(rule.gender != null ? 1 : 0) +
(rule.expertise != null ? 1 : 0) +
(rule.sub_expertise != null ? 1 : 0)
if (score > bestScore) {
bestScore = score
best = rule
}
}
return best ? best.price : null
}
module.exports = { resolveAdPrice }

View File

@@ -0,0 +1,55 @@
const AutoDirectRuleModel = require('../models/AutoDirectRuleModel')
const CommentModel = require('../models/CommentModel')
const MessageModel = require('../models/MessageModel')
const { emitChatEvents, enrichMessage } = require('../helpers/chatHelpers')
// دایرکت اتوماتیک: وقتی کسی زیر پست یه کاربر، یکی از متن‌های تریگرِ خودش رو کامنت می‌کنه،
// بدون نیاز به هیچ تاییدی هم یه دایرکت براش می‌فرسته هم زیرِ همون کامنت جواب می‌ده.
// content پیامِ دایرکت رو عینِ sharedPost (engagementController.js) plain-text ذخیره می‌کنیم —
// چون خودِ سرور ساخته، نه متنِ رمزنگاری‌شدهٔ کاربر، محدودیتِ E2EE (chatHelpers.js) اینجا صدق نمی‌کنه
async function runAutoDirectRules ({ io, postOwnerId, commenterId, commentId, postId, commentText }) {
try {
if (!postOwnerId || !commenterId || !postId || !commentText) return
if (String(postOwnerId) === String(commenterId)) return
const rules = await AutoDirectRuleModel.find({ user_id: postOwnerId, is_active: true }).lean()
if (!rules.length) return
const normalized = String(commentText).toLowerCase()
const matched = rules.find(
(r) => r.trigger_text && normalized.includes(String(r.trigger_text).toLowerCase().trim())
)
if (!matched) return
const newMessage = new MessageModel({
senderId: postOwnerId,
receiverId: commenterId,
content: matched.direct_message
})
await newMessage.save()
if (io) {
const formatted = await enrichMessage(newMessage)
emitChatEvents(io, formatted, postOwnerId, commenterId, {
type: 'chat_message',
description: String(matched.direct_message).slice(0, 40)
})
}
await CommentModel.create({
user: postOwnerId,
creator: postOwnerId,
comment_for: 'post',
post: postId,
parent: commentId,
comment: matched.reply_caption,
status: 'accepted'
})
await AutoDirectRuleModel.updateOne({ _id: matched._id }, { $inc: { sent_count: 1 } })
} catch (error) {
console.error('Error running auto-direct rules:', error)
}
}
module.exports = { runAutoDirectRules }

View File

@@ -8,20 +8,30 @@ const { sendPushToUser } = require('./pushNotification')
* همچنین یه پوش 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 })
async function notifyUser (io, userId, { type, title, description, project_post_id, actor_id, image, link, actorUsername } = {}) {
await NotificationModel.create({ user_id: userId, type, title, description, project_post_id, actor_id, image })
if (io) {
try {
io.to(`user:${String(userId)}`).emit('notification', { type, title, description })
io.to(`user:${String(userId)}`).emit('notification', { type, title, description, image })
} catch (_) {}
}
const data = {}
if (project_post_id) data.project_post_id = String(project_post_id)
// مسیرِ نسبیِ آماده (وقتی خودِ فراخوان از قبل username/id لازم رو داره) — سرویس‌ورکرِ
// وب دیگه لازم نیست خودش لوکاپِ اضافه بزنه تا بفهمه دقیقاً کجا باز کنه
if (link) data.link = link
// یوزرنیمِ عاملِ نوتیف (مثلاً صاحبِ استوری برای story_mention) — اندروید بدونِ این
// مجبور بود یه درخواستِ اضافه بزنه تا از رویِ آیدی به یوزرنیم برسه
if (actorUsername) data.actorUsername = actorUsername
sendPushToUser(userId, {
title,
description,
type,
data: project_post_id ? { project_post_id: String(project_post_id) } : undefined
data: Object.keys(data).length ? data : undefined,
image
}).catch(() => {})
}

View File

@@ -34,18 +34,33 @@ function getFirebaseApp () {
}
}
/** ارسال پوش به یه لیست توکن مشخص — پایه‌ی مسیرهای دسته‌جمعی (فالوورها، پیام‌های همگانی پنل) */
async function sendPushToTokens (tokens, { title, description, type, data, image } = {}) {
/**
* ارسال پوش به یه لیست توکن مشخص — پایه‌ی مسیرهای دسته‌جمعی (فالوورها، پیام‌های همگانی پنل)
* dataOnly: اگه true باشه، هیچ بلاک notification فرستاده نمی‌شه — فقط data (رشته‌ای‌شده).
* دلیلش: react-native-firebase وقتی پیام یه notification داشته باشه، خودِ سیستم‌عامل
* (نه کدِ اپ) پیش‌فرض نمایشش می‌ده و setBackgroundMessageHandler برای اون پیام اصلاً صدا
* زده نمی‌شه — یعنی هیچ‌وقت نمی‌شه آیکونِ بزرگ (آواتار) رو ازش کنترل کرد. فقط برای
* توکن‌های موبایل (sendPushToUser) استفاده می‌شه؛ وب دست‌نخورده می‌مونه (already working)
*/
async function sendPushToTokens (tokens, { title, description, type, data, image, dataOnly } = {}) {
const app = getFirebaseApp()
const cleanTokens = (tokens || []).filter(Boolean)
if (!app || cleanTokens.length === 0) return { successCount: 0, invalidTokens: [] }
const dataPayload = { type, ...data }
if (dataOnly) {
// notifee (index.js پس‌زمینه/foreground) از همین فیلدها نوتیف غنی می‌سازه
dataPayload.title = title || ''
dataPayload.description = description || ''
if (image) dataPayload.image = image
}
const message = {
// notification.image: عکس/آواتار نمایش داده‌شده توی خودِ نوتیف (اندروید: بزرگ‌آیکون/
// big-picture، iOS: پیوست) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام
notification: { title, body: description, ...(image ? { image } : {}) },
// notification.image: عکس/آواتار نمایش داده‌شده توی خودِ نوتیف (وب/آی‌اواس، و اندرویدِ
// dataOnly:false) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام
...(dataOnly ? {} : { notification: { title, body: description, ...(image ? { image } : {}) } }),
data: Object.fromEntries(
Object.entries({ type, ...data }).map(([k, v]) => [k, String(v ?? '')])
Object.entries(dataPayload).map(([k, v]) => [k, String(v ?? '')])
),
tokens: cleanTokens
}
@@ -73,21 +88,34 @@ async function sendPushToTokens (tokens, { title, description, type, data, image
return { successCount, invalidTokens }
}
/** ارسال پوش به یک کاربر مشخص — توکن‌هاش رو از دیتابیس می‌خونه و توکن‌های نامعتبر رو پاک می‌کنه */
/**
* ارسال پوش به یک کاربر مشخص — توکن‌هاش رو از دیتابیس می‌خونه و توکن‌های نامعتبر رو پاک می‌کنه.
* موبایل و وب جدا ارسال می‌شن: موبایل dataOnly (نوتیفِ غنی رو notifee توی index.js/App
* می‌سازه)، وب عین قبل با بلاک notification (رفتارِ فعلاً درستِ وب دست‌نخورده می‌مونه)
*/
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 mobileTokens = user?.fcm_tokens || []
const webTokens = user?.fcm_web_tokens || []
if (mobileTokens.length === 0 && webTokens.length === 0) return
const { invalidTokens } = await sendPushToTokens(tokens, { title, description, type, data, image })
if (invalidTokens.length > 0) {
const [mobileResult, webResult] = await Promise.all([
mobileTokens.length
? sendPushToTokens(mobileTokens, { title, description, type, data, image, dataOnly: true })
: Promise.resolve({ invalidTokens: [] }),
webTokens.length
? sendPushToTokens(webTokens, { title, description, type, data, image })
: Promise.resolve({ invalidTokens: [] })
])
if (mobileResult.invalidTokens.length > 0 || webResult.invalidTokens.length > 0) {
await UserModel.updateOne(
{ _id: userId },
{
$pull: {
fcm_tokens: { $in: invalidTokens },
fcm_web_tokens: { $in: invalidTokens }
fcm_tokens: { $in: mobileResult.invalidTokens },
fcm_web_tokens: { $in: webResult.invalidTokens }
}
}
)

View File

@@ -1,88 +1,91 @@
const mongoose = require('mongoose');
const CourseModel = require('../models/CourseModel');
const AcademyModel = require('../models/AcademyModel');
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, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus');
const {
PUBLIC_COURSE_STATUSES,
PUBLIC_BILLBOARD_STATUSES,
PUBLIC_PROJECT_STATUSES,
} = require('./moderationStatus');
// اعتبارسنجی مشترک «آیتم قابل‌لینک‌شدن» — هم پست (linked_*_id) هم استوری (لینک‌اورلی) از
// همین یه تابع استفاده می‌کنن تا شرط مالکیت+وضعیت هرکدوم دوبار نوشته نشه. خروجی: شناسه‌ی
// معتبری که باید ذخیره بشه، یا null اگه کاربر مالک نیست/وضعیتش پذیرفته‌شده نیست.
async function resolveLinkedEntity(kind, id, userId) {
function sameId(a, b) {
if (a == null || b == null) return false;
const sa = typeof a === 'object' && a._id != null ? String(a._id) : String(a);
const sb = typeof b === 'object' && b._id != null ? String(b._id) : String(b);
return sa === sb;
}
// اعتبارسنجی مشترک «آیتم قابل‌لینک‌شدن»
// options.requirePublic (پیش‌فرض true): برای لینک داخل پست/استوری وضعیت عمومی لازم است.
// برای صفحه «اشتراک‌گذاری لینک» فقط مالکیت کافی است (requirePublic: false).
async function resolveLinkedEntity(kind, id, userId, options = {}) {
const requirePublic = options.requirePublic !== false;
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);
const course = await CourseModel.findById(id)
.select('_id user_id academyId status')
.lean();
if (!course) return null;
if (requirePublic && !PUBLIC_COURSE_STATUSES.includes(course.status)) return null;
// مالکیت: user_id مستقیم روی دوره، یا مالک آکادمیِ دوره
if (sameId(course.user_id, userId)) return String(course._id);
if (course.academyId) {
const academy = await AcademyModel.findById(course.academyId).select('userId').lean();
if (academy && sameId(academy.userId, userId)) return String(course._id);
}
return null;
}
case 'project': {
const project = await ProjectModel.findById(id).select('_id creator_id status').lean();
// عین pkg/billboard — هر وضعیتِ واقعی/متعهدشده و غیرِ رد/لغوشده رو می‌پذیریم، نه فقط
// done/accepted؛ وگرنه تقریباً هیچ پروژه‌ی واقعی‌ای (که بیشترِ عمرش pending/ongoing/
// temp_accept هست، نه دقیقاً done/accepted) قابل‌لینک‌شدن نبود
if (
project &&
String(project.creator_id) === String(userId) &&
PUBLIC_PROJECT_STATUSES.includes(project.status)
) {
return String(project._id);
}
const project = await ProjectModel.findById(id)
.select('_id creator_id status')
.lean();
if (!project) return null;
if (requirePublic && !PUBLIC_PROJECT_STATUSES.includes(project.status)) return null;
if (sameId(project.creator_id, userId)) 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);
}
const ad = await AdvertisingModel.findById(id)
.select('_id creator_id status')
.lean();
if (!ad) return null;
if (requirePublic && !PUBLIC_BILLBOARD_STATUSES.includes(ad.status)) return null;
if (sameId(ad.creator_id, userId)) return String(ad._id);
return null;
}
case 'shop': {
const shop = await ShopModel.findById(id).select('_id owner status').lean();
// draft/rejected هنوز واقعاً وجود عمومی ندارن، ولی pending_review (ثبت‌شده، منتظرِ
// تایید) هم مثل بقیه‌ی موارد بالا باید قابل‌لینک باشه — فقط active بیش‌ازحد سخت‌گیرانه بود
if (
shop &&
String(shop.owner) === String(userId) &&
['active', 'pending_review'].includes(shop.status)
) {
return String(shop._id);
}
if (!shop) return null;
if (requirePublic && !['active', 'pending_review'].includes(shop.status)) return null;
if (sameId(shop.owner, userId)) return String(shop._id);
return null;
}
case 'shopproduct': {
const listing = await ShopProductListingModel.findById(id).select('_id shop status').lean();
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null;
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
if (shop && String(shop.owner) === String(userId)) {
return String(listing._id);
const listing = await ShopProductListingModel.findById(id)
.select('_id shop status')
.lean();
if (!listing) return null;
if (requirePublic && !['active', 'pending_shop_approval'].includes(listing.status)) {
return null;
}
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
if (shop && sameId(shop.owner, 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();
// ref_id برای رزرو = userId مالک کانفیگ (نه _id خودِ BookingConfig)
if (!sameId(id, userId)) return null;
const query = requirePublic
? { owner: userId, status: 'active' }
: { owner: userId };
const config = await BookingConfigModel.findOne(query).select('_id').lean();
return config ? String(userId) : null;
}
default:
@@ -90,4 +93,4 @@ async function resolveLinkedEntity(kind, id, userId) {
}
}
module.exports = { resolveLinkedEntity };
module.exports = { resolveLinkedEntity };

View File

@@ -0,0 +1,49 @@
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, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus')
// عینِ resolveLinkedEntity.js ولی به‌جای اعتبارسنجیِ مالکیت، فقط عنوان/عکسِ نمایشی رو
// برمی‌گردونه — برای وقتی که بلاگر داره لینک‌های اشتراکی‌ِ یه کاربرِ دیگه (نه خودش) رو می‌بینه
async function resolveShareableLinkDisplay (kind, id) {
switch (kind) {
case 'pkg': {
const course = await CourseModel.findById(id).select('cuorse_name course_image status').lean()
if (!course || !PUBLIC_COURSE_STATUSES.includes(course.status)) return null
return { title: course.cuorse_name || 'پکیج آموزشی', image: course.course_image || null }
}
case 'project': {
const project = await ProjectModel.findById(id).select('title status').lean()
if (!project || !PUBLIC_PROJECT_STATUSES.includes(project.status)) return null
return { title: project.title || 'پروژه', image: null }
}
case 'billboard': {
const ad = await AdvertisingModel.findById(id).select('title images status').lean()
if (!ad || !PUBLIC_BILLBOARD_STATUSES.includes(ad.status)) return null
return { title: ad.title || 'بیلبورد', image: ad.images?.[0] || null }
}
case 'shop': {
const shop = await ShopModel.findById(id).select('name logo status').lean()
if (!shop || !['active', 'pending_review'].includes(shop.status)) return null
return { title: shop.name, image: shop.logo || null }
}
case 'shopproduct': {
const listing = await ShopProductListingModel.findById(id).select('title images primaryImageIndex status').lean()
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null
const image = listing.images?.[listing.primaryImageIndex ?? 0] ?? listing.images?.[0] ?? null
return { title: listing.title, image }
}
case 'booking': {
const config = await BookingConfigModel.findOne({ owner: id, status: 'active' }).select('name').lean()
if (!config) return null
return { title: config.name || 'رزرو نوبت', image: null }
}
default:
return null
}
}
module.exports = { resolveShareableLinkDisplay }

View File

@@ -0,0 +1,48 @@
const UserModel = require('./../models/UserModel')
const { notifyUser } = require('./notifyUser')
const { buildStorageUrl } = require('./storageUrl')
// همون الگوی tagNotification.js — قبلاً منشن‌کردنِ کاربر توی استوری هیچ نوتیفی
// (نه دیتابیس، نه پوش) تولید نمی‌کرد؛ این تابع بعد از ساختِ استوری، برای هر اورلیِ
// mention یه notifyUser صدا می‌زنه
let ioInstance = null
function setIo (io) {
ioInstance = io
}
async function notifyStoryMentions (story, ownerId) {
try {
const overlays = Array.isArray(story?.overlays) ? story.overlays : []
const mentionedIds = [
...new Set(
overlays
.filter((o) => o?.type === 'mention' && o.mention_user_id)
.map((o) => String(o.mention_user_id))
)
].filter((id) => id !== String(ownerId))
if (!mentionedIds.length) return
const owner = await UserModel.findById(ownerId).select('user_name profile_image')
const ownerUsername = owner?.user_name || 'کاربر'
const ownerImage = buildStorageUrl(owner?.profile_image) || undefined
await Promise.all(
mentionedIds.map((userId) =>
notifyUser(ioInstance, userId, {
type: 'story_mention',
title: ownerUsername,
description: 'شما را در استوری‌اش نام برد',
project_post_id: story._id,
actor_id: ownerId,
image: ownerImage,
actorUsername: owner?.user_name || undefined
})
)
)
} catch (error) {
console.error('Error creating story mention notifications:', error)
}
}
module.exports = { notifyStoryMentions, setIo }

38
utils/storyRingFlags.js Normal file
View File

@@ -0,0 +1,38 @@
const StoryModel = require('../models/StoryModel')
/**
* برای یه لیست از userId (مثلاً نویسنده‌های کامنت‌های یه صفحه)، نقشه‌ی has_story/
* has_unviewed_story برمی‌گردونه — همون منطقِ حلقه‌ی استوری که postEngagement.js
* برای نویسنده‌ی پست‌ها محاسبه می‌کنه، جدا شده تا هر جای دیگه (مثلاً کامنت‌ها) هم
* قابل استفاده باشه بدون وابستگی به فیلدهای مخصوصِ پست
*/
async function resolveStoryFlags (userIds, viewerId) {
const ids = [...new Set((userIds || []).filter(Boolean).map(String))]
if (!ids.length) return {}
const activeStories = await StoryModel.find({ user_id: { $in: ids }, expires_at: { $gt: new Date() } })
.select('user_id viewers')
.lean()
const storiesByAuthor = {}
activeStories.forEach((s) => {
const key = String(s.user_id)
if (!storiesByAuthor[key]) storiesByAuthor[key] = []
storiesByAuthor[key].push(s)
})
const flags = {}
for (const id of Object.keys(storiesByAuthor)) {
const stories = storiesByAuthor[id]
const hasUnviewed = stories.some(
(s) => !s.viewers?.some((v) => String(v.user_id) === String(viewerId))
)
flags[id] = {
has_story: true,
has_unviewed_story: viewerId ? hasUnviewed : true
}
}
return flags
}
module.exports = { resolveStoryFlags }

View File

@@ -1,5 +1,14 @@
const NotificationModel = require('../models/NotificationModel')
const UserModel = require('../models/UserModel')
const { notifyUser } = require('./notifyUser')
const { buildStorageUrl } = require('./storageUrl')
// همون الگوی services/*Cron.js — چون این تابع مستقیم از کنترلر صدا زده می‌شه (نه یه
// جاب زمان‌بندی‌شده)، io از index.js با همین setIo تزریق می‌شه تا notifyUser بتونه
// رویداد سوکت لحظه‌ای هم بفرسته، نه فقط ردیف دیتابیس/پوش
let ioInstance = null
function setIo (io) {
ioInstance = io
}
const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
try {
@@ -10,19 +19,20 @@ const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
)
if (!uniqueIds.length) return
const tagger = await UserModel.findById(taggerId).select('user_name')
const tagger = await UserModel.findById(taggerId).select('user_name profile_image')
const taggerUsername = tagger?.user_name || 'کاربر'
const message = `${taggerUsername} شما را در پستی نشانه‌گذاری کرد.`
const taggerImage = buildStorageUrl(tagger?.profile_image) || undefined
await Promise.all(
uniqueIds.map((userId) =>
NotificationModel.create({
user_id: userId,
notifyUser(ioInstance, userId, {
type: 'post_tag',
title: taggerUsername,
description: 'در پستی از شما نام برد',
project_post_id: postId,
actor_id: taggerId,
type: 'post_tag',
title: message,
description: message
image: taggerImage,
link: `/posts/${postId}`
})
)
)
@@ -32,5 +42,6 @@ const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
}
module.exports = {
createTagNotifications
createTagNotifications,
setIo
}

37
utils/vpnCheck.js Normal file
View File

@@ -0,0 +1,37 @@
const axios = require('axios')
// ip-api.com (رایگان، بدون نیاز به API key) — fields=proxy هم پراکسی/VPNِ شناخته‌شده و
// هم هاستینگ/دیتاسنتر (که معمولاً پشتِ VPNهای تجاریه) رو با یه پرچم برمی‌گردونه.
// روی IPِ لوکال/خصوصی (توسعه‌ی محلی) status:"fail" برمی‌گرده که باید نادیده گرفته بشه
// (نباید هشدار غلط نشون بده)
const IP_API_BASE = 'http://ip-api.com/json'
const ALLOWED_COUNTRY_CODE = 'IR'
/**
* @param {string} ip
* @returns {Promise<{ shouldWarn: boolean, countryCode: string|null, isProxy: boolean }>}
*/
async function checkClientRegion (ip) {
const fallback = { shouldWarn: false, countryCode: null, isProxy: false }
if (!ip) return fallback
try {
const { data } = await axios.get(
`${IP_API_BASE}/${encodeURIComponent(ip)}`,
{ params: { fields: 'status,message,countryCode,proxy,hosting,query' }, timeout: 4000 }
)
if (!data || data.status !== 'success') return fallback
const isProxy = Boolean(data.proxy) || Boolean(data.hosting)
const countryCode = data.countryCode || null
const shouldWarn = isProxy || (countryCode != null && countryCode !== ALLOWED_COUNTRY_CODE)
return { shouldWarn, countryCode, isProxy }
} catch (error) {
console.error('checkClientRegion error:', error.message)
return fallback
}
}
module.exports = { checkClientRegion }