full app
This commit is contained in:
@@ -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',
|
||||
|
||||
388
controllers/application/ads/adsController.js
Normal file
388
controllers/application/ads/adsController.js
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
91
controllers/application/autoDirect/autoDirectController.js
Normal file
91
controllers/application/autoDirect/autoDirectController.js
Normal 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
|
||||
}
|
||||
14
controllers/application/geo/geoController.js
Normal file
14
controllers/application/geo/geoController.js
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
175
controllers/panel/ads/adsPanelController.js
Normal file
175
controllers/panel/ads/adsPanelController.js
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user