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'),