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))
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ async function enrichMessage(message) {
|
||||
})
|
||||
}
|
||||
|
||||
function emitChatEvents(io, message, senderId, receiverId) {
|
||||
function emitChatEvents(io, message, senderId, receiverId, pushOverride) {
|
||||
const sid = normalizeId(senderId)
|
||||
const rid = normalizeId(receiverId)
|
||||
const payload = normalizeMessagePayload(
|
||||
@@ -86,21 +86,22 @@ function emitChatEvents(io, message, senderId, receiverId) {
|
||||
message: payload
|
||||
})
|
||||
|
||||
sendChatPushNotification(sid, rid).catch(() => {})
|
||||
sendChatPushNotification(sid, rid, pushOverride).catch(() => {})
|
||||
}
|
||||
|
||||
// پیامها E2EE هستن (رمزنگاری سرتاسر) — سرور محتوا رو نمیبینه و نباید توی پوش هم نشونش بده،
|
||||
// فقط اسم فرستنده + یه متن ثابت. عین همین تابع برای همهی مسیرهای emitChatEvents (چت مستقیم،
|
||||
// پاسخ استوری، اشتراکگذاری پست/محصول توی چت) صدا زده میشه
|
||||
async function sendChatPushNotification(senderId, receiverId) {
|
||||
// پیامهای متنیِ معمولی E2EE هستن (رمزنگاری سرتاسر) — سرور محتوا رو نمیبینه و نباید توی
|
||||
// پوش هم نشونش بده، فقط اسم فرستنده + یه متن ثابت. اما اشتراکگذاریِ پست/محصول (که خودِ
|
||||
// سرور با دادهٔ واقعیِ دیتابیس میسازه، نه متنِ رمزنگاریشدهٔ کاربر) میتونه با pushOverride
|
||||
// نوع/متنِ دقیقتری بفرسته (نگاه کن به فراخوانیها توی engagementController.js/shopProductController.js)
|
||||
async function sendChatPushNotification(senderId, receiverId, pushOverride = {}) {
|
||||
const sender = await UserModel.findById(senderId).select('first_name last_name user_name profile_image').lean()
|
||||
if (!sender) return
|
||||
const name = `${sender.first_name || ''} ${sender.last_name || ''}`.trim() || sender.user_name || 'کاربر'
|
||||
await sendPushToUser(receiverId, {
|
||||
title: name,
|
||||
description: 'یک پیام جدید برای شما ارسال کرد',
|
||||
type: 'chat_message',
|
||||
data: { project_post_id: senderId },
|
||||
description: pushOverride.description || 'یک پیام جدید برای شما ارسال کرد',
|
||||
type: pushOverride.type || 'chat_message',
|
||||
data: { project_post_id: senderId, link: `/settings/chats/${sender.user_name || ''}/${senderId}` },
|
||||
image: buildStorageUrl(sender.profile_image) || undefined
|
||||
})
|
||||
}
|
||||
|
||||
5
index.js
5
index.js
@@ -161,6 +161,8 @@ app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/
|
||||
app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'), STATIC_MEDIA_OPTIONS));
|
||||
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'), STATIC_MEDIA_OPTIONS));
|
||||
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'), STATIC_MEDIA_OPTIONS));
|
||||
app.use('/storage/broadcasts', express.static(path.join(__dirname, '../storage/broadcasts'), STATIC_MEDIA_OPTIONS));
|
||||
app.use('/storage/broadcasts', express.static(path.join(__dirname, 'storage/broadcasts'), STATIC_MEDIA_OPTIONS));
|
||||
app.use('/storage', express.static(path.join(__dirname, 'storage'), STATIC_MEDIA_OPTIONS));
|
||||
|
||||
require('./boot');
|
||||
@@ -684,6 +686,9 @@ require('./services/SmartNotificationsCron').setIo(io)
|
||||
require('./services/InstagramMediaSyncCron')
|
||||
require('./utils/notifyFollowers').setIo(io)
|
||||
require('./utils/partnerMatch').setIo(io)
|
||||
require('./utils/tagNotification').setIo(io)
|
||||
require('./utils/academyNotification').setIo(io)
|
||||
require('./utils/storyMentionNotification').setIo(io)
|
||||
|
||||
cron.schedule('*/30 * * * * *', () => {
|
||||
purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err))
|
||||
|
||||
@@ -14,6 +14,9 @@ const modelSchema = new mongoose.Schema({
|
||||
payment_authority: { type: String },
|
||||
payment_ref_id: { type: String },
|
||||
status: { type: String, enum: ["pending", "success", "failed", "settled"], default: "success" },
|
||||
// فقط «قصدِ» استفاده از کیف پول رو نگه میداره تا لحظهی تاییدِ verify — کسرِ واقعی
|
||||
// اونجا انجام میشه (utils/walletSplit.js)
|
||||
walletAmountUsed: { type: Number, default: 0 },
|
||||
|
||||
|
||||
});
|
||||
|
||||
72
models/AdModel.js
Normal file
72
models/AdModel.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const mongoose = require('mongoose')
|
||||
const timestamp = require('mongoose-timestamp')
|
||||
const mongoosePaginate = require('mongoose-paginate-v2')
|
||||
|
||||
// تبلیغِ کاربری — نوعش تعیین میکنه محتواش کجا تزریق میشه: first_post (اول فید)،
|
||||
// first_story (اول نوار استوری)، notification (پوش مستقیم به کاربرهای هدف)، project/
|
||||
// product/shop/billboard (اول لیستِ مربوطه، از utils/adDelivery.js:findActiveAd با همون
|
||||
// ad_type resolve میشه). هدفگذاری (لوکیشن/تخصص/جنسیت) روی همون فیلدهایی که
|
||||
// User/Post/exploreAlgorithm.js از قبل استفاده میکنن سوار شده (province.id عددیه، نه
|
||||
// ObjectId — utils/adPricing.js همین رو resolve میکنه)
|
||||
const AD_TYPES = ['first_post', 'first_story', 'notification', 'project', 'product', 'shop', 'billboard']
|
||||
|
||||
const adSchema = new mongoose.Schema({
|
||||
user_id: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
ad_type: { type: String, enum: AD_TYPES, required: true },
|
||||
|
||||
// محتوا — بسته به ad_type فقط یکی از این پر میشه
|
||||
content_post_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', default: null },
|
||||
content_project_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Project', default: null },
|
||||
content_shop_product_id: { type: mongoose.Schema.Types.ObjectId, ref: 'ShopProductListing', default: null },
|
||||
content_shop_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Shop', default: null },
|
||||
content_billboard_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Advertising', default: null },
|
||||
// استوریهای واقعی معمولاً ۲۴ساعته منقضی میشن (StoryModel: expires_at) و از فیدِ
|
||||
// استوریِ معمولی حذف میشن، ولی یه کمپینِ تبلیغاتی میتونه چند روز فعال بمونه — پس
|
||||
// مدیا/اورلیها رو موقعِ ساختِ تبلیغ اینجا کپی/اسنپشات میکنیم تا مستقل از عمرِ
|
||||
// طبیعیِ خودِ استوری باشه (content_story_id فقط برای رفرنسِ منبع نگه داشته میشه)
|
||||
content_story_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Story', default: null },
|
||||
content_media_path: { type: String, default: null },
|
||||
content_media_type: { type: String, default: null },
|
||||
content_overlays: { type: mongoose.Schema.Types.Mixed, default: null },
|
||||
notification_title: { type: String, trim: true, default: null },
|
||||
notification_body: { type: String, trim: true, default: null },
|
||||
|
||||
// هدفگذاری
|
||||
target_country_id: { type: Number, default: null },
|
||||
target_province_id: { type: Number, default: null },
|
||||
target_city_id: { type: Number, default: null },
|
||||
target_expertise: { type: String, trim: true, default: null },
|
||||
target_sub_expertise: { type: [String], default: [] },
|
||||
target_gender: { type: String, enum: ['female', 'male', 'any'], default: 'any' },
|
||||
|
||||
// مالی
|
||||
price: { type: Number, required: true },
|
||||
walletAmountUsed: { type: Number, default: 0 },
|
||||
payment_authority: { type: String, default: null },
|
||||
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending_payment', 'active', 'expired', 'failed'],
|
||||
default: 'pending_payment'
|
||||
},
|
||||
started_at: { type: Date, default: null },
|
||||
// عین بقیهی کمپینهای اپ، یه مدتزمانِ ثابت (AD_CAMPAIGN_DURATION_DAYS توی
|
||||
// adsController.js) بعد از فعالشدن — بعدش دیگه توی تزریقِ فید/استوری/نوتیف لحاظ نمیشه
|
||||
expires_at: { type: Date, default: null },
|
||||
|
||||
views_count: { type: Number, default: 0 },
|
||||
clicks_count: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
// تزریق (سرچِ یه تبلیغِ فعالِ منطبق روی فید/استوری) و لیستِ «تبلیغهای من» دو مصرفِ
|
||||
// اصلیِ ایندکسن
|
||||
adSchema.index({ status: 1, ad_type: 1, expires_at: 1 })
|
||||
adSchema.index({ user_id: 1, createdAt: -1 })
|
||||
|
||||
adSchema.plugin(timestamp)
|
||||
adSchema.plugin(mongoosePaginate)
|
||||
const AdModel = mongoose.model('Ad', adSchema)
|
||||
// روی خودِ مدل بهعنوان استاتیک — تا adsController.js/adsPanelController.js بدونِ
|
||||
// require جدا به لیستِ معتبرِ ad_type دسترسی داشته باشن (module.exports همون مدله، نه یه آبجکت)
|
||||
AdModel.AD_TYPES = AD_TYPES
|
||||
module.exports = AdModel
|
||||
24
models/AdPricingRuleModel.js
Normal file
24
models/AdPricingRuleModel.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const mongoose = require('mongoose')
|
||||
const timestamp = require('mongoose-timestamp')
|
||||
|
||||
// قانونِ قیمتگذاریِ ادمین برای یه «سناریو»ی تبلیغ — هر فیلدِ null یعنی وایلدکارت
|
||||
// (روی هر مقداری از اون بعد صدق میکنه)؛ utils/adPricing.js دقیقترین قانونِ منطبق
|
||||
// (بیشترین فیلدِ غیرِnull) رو انتخاب میکنه
|
||||
const adPricingRuleSchema = new mongoose.Schema({
|
||||
ad_type: {
|
||||
type: String,
|
||||
enum: ['first_post', 'first_story', 'notification', 'project', 'product', 'shop', 'billboard'],
|
||||
required: true
|
||||
},
|
||||
province_id: { type: Number, default: null },
|
||||
gender: { type: String, enum: ['female', 'male'], default: null },
|
||||
expertise: { type: String, trim: true, default: null },
|
||||
sub_expertise: { type: String, trim: true, default: null },
|
||||
price: { type: Number, required: true }
|
||||
})
|
||||
|
||||
adPricingRuleSchema.index({ ad_type: 1 })
|
||||
|
||||
adPricingRuleSchema.plugin(timestamp)
|
||||
const AdPricingRuleModel = mongoose.model('AdPricingRule', adPricingRuleSchema)
|
||||
module.exports = AdPricingRuleModel
|
||||
@@ -19,6 +19,11 @@ const adminBroadcastSchema = new mongoose.Schema({
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
sentBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
|
||||
@@ -18,6 +18,11 @@ const advertisingCommentSchema = new mongoose.Schema({
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
rate: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'temp_accept', 'accepted', 'rejected'],
|
||||
|
||||
24
models/AutoDirectRuleModel.js
Normal file
24
models/AutoDirectRuleModel.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const mongoose = require('mongoose')
|
||||
const timestamp = require('mongoose-timestamp')
|
||||
|
||||
const autoDirectRuleSchema = new mongoose.Schema({
|
||||
user_id: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
trigger_text: { type: String, required: true, trim: true },
|
||||
direct_message: { type: String, required: true, trim: true },
|
||||
reply_caption: { type: String, required: true, trim: true },
|
||||
is_active: { type: Boolean, default: true },
|
||||
// هر بار این قانون واقعاً اجرا بشه (دایرکت ارسال + کامنتِ پاسخ ثبت بشه) یکی زیاد میشه —
|
||||
// برای نمایشِ «تعداد کامنتهای ارسالی» توی لیستِ AutoDirectScreen.tsx
|
||||
sent_count: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
// autoDirectTrigger.js هر کامنتِ تازه رو در برابرِ همهی قانونهای فعالِ صاحبِ پست چک میکنه
|
||||
autoDirectRuleSchema.index({ user_id: 1, is_active: 1 })
|
||||
|
||||
autoDirectRuleSchema.plugin(timestamp)
|
||||
const AutoDirectRuleModel = mongoose.model('AutoDirectRule', autoDirectRuleSchema)
|
||||
module.exports = AutoDirectRuleModel
|
||||
@@ -33,6 +33,13 @@ const notificationSchema = new mongoose.Schema({
|
||||
ref: 'User',
|
||||
required: false
|
||||
},
|
||||
// آواتار فرستنده (برای نوتیفهای مربوط به یه کاربر خاص) یا بنرِ دستیِ ادمین
|
||||
// (برای admin_broadcast) — هم توی لیستِ داخلِ اپ و هم توی خودِ پوش FCM استفاده میشه
|
||||
image: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
// فقط برای اعلانهای همگانی پنل — جدا از read (که یعنی صفحه اعلانها باز شده)،
|
||||
// seen یعنی پوش واقعاً به دستگاه رسیده و clicked یعنی کاربر روش ضربه زده
|
||||
broadcast_id: {
|
||||
|
||||
28
models/ShareableLinkModel.js
Normal file
28
models/ShareableLinkModel.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const mongoose = require('mongoose')
|
||||
const timestamp = require('mongoose-timestamp')
|
||||
|
||||
// اشتراکگذاری لینک: کاربر مشخص میکنه کدوم محتوای خودش (پکیج/پروژه/بیلبورد/فروشگاه/
|
||||
// کالا/رزرو) وقتی یکی دیگه توی ثبت پست تگش میکنه، توی لیست لینکهای قابلانتخاب اون
|
||||
// کاربر (بلاگر) نشون داده بشه — shareableLinksController.js/for-users همین رکوردها رو میخونه
|
||||
const shareableLinkSchema = new mongoose.Schema({
|
||||
user_id: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
kind: {
|
||||
type: String,
|
||||
enum: ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking'],
|
||||
required: true
|
||||
},
|
||||
ref_id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
shareableLinkSchema.index({ user_id: 1, kind: 1, ref_id: 1 }, { unique: true })
|
||||
|
||||
shareableLinkSchema.plugin(timestamp)
|
||||
const ShareableLinkModel = mongoose.model('ShareableLink', shareableLinkSchema)
|
||||
module.exports = ShareableLinkModel
|
||||
23
routes/application/ads/index.js
Normal file
23
routes/application/ads/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const express = require('express')
|
||||
const {
|
||||
getAdPricing,
|
||||
createAd,
|
||||
verifyAdPayment,
|
||||
getMyAds,
|
||||
getAdDetail,
|
||||
getSponsoredAd,
|
||||
trackAdEventEndpoint
|
||||
} = require('../../../controllers/application/ads/adsController')
|
||||
const blockCheck = require('../../../middlewares/blockCheck')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/pricing', [blockCheck], getAdPricing)
|
||||
router.post('/', [blockCheck], createAd)
|
||||
router.get('/payment/verify', verifyAdPayment)
|
||||
router.get('/mine', [blockCheck], getMyAds)
|
||||
router.get('/sponsored', [blockCheck], getSponsoredAd)
|
||||
router.get('/:id', [blockCheck], getAdDetail)
|
||||
router.post('/:id/track', [blockCheck], trackAdEventEndpoint)
|
||||
|
||||
module.exports = router
|
||||
17
routes/application/autoDirect/index.js
Normal file
17
routes/application/autoDirect/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require('express')
|
||||
const {
|
||||
getMyAutoDirectRules,
|
||||
createAutoDirectRule,
|
||||
updateAutoDirectRule,
|
||||
deleteAutoDirectRule
|
||||
} = require('../../../controllers/application/autoDirect/autoDirectController')
|
||||
const blockCheck = require('../../../middlewares/blockCheck')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/mine', [blockCheck], getMyAutoDirectRules)
|
||||
router.post('/', [blockCheck], createAutoDirectRule)
|
||||
router.patch('/:id', [blockCheck], updateAutoDirectRule)
|
||||
router.delete('/:id', [blockCheck], deleteAutoDirectRule)
|
||||
|
||||
module.exports = router
|
||||
6
routes/application/geo/index.js
Normal file
6
routes/application/geo/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const express = require('express')
|
||||
const { checkGeo } = require('../../../controllers/application/geo/geoController')
|
||||
const router = express.Router()
|
||||
router.get('/check', checkGeo)
|
||||
|
||||
module.exports = router
|
||||
15
routes/application/shareableLinks/index.js
Normal file
15
routes/application/shareableLinks/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const express = require('express')
|
||||
const {
|
||||
getMyShareableLinks,
|
||||
toggleShareableLink,
|
||||
getShareableLinksForUsers
|
||||
} = require('../../../controllers/application/shareableLinks/shareableLinksController')
|
||||
const blockCheck = require('../../../middlewares/blockCheck')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/mine', [blockCheck], getMyShareableLinks)
|
||||
router.post('/toggle', [blockCheck], toggleShareableLink)
|
||||
router.get('/for-users', [blockCheck], getShareableLinksForUsers)
|
||||
|
||||
module.exports = router
|
||||
@@ -16,6 +16,9 @@ const ordersRouter = require('./application/orders')
|
||||
const shopOrderPaymentRouter = require('./application/payment/shop-order')
|
||||
const walletRouter = require('./application/wallet')
|
||||
const bookingsRouter = require('./application/bookings')
|
||||
const autoDirectRouter = require('./application/autoDirect')
|
||||
const shareableLinksRouter = require('./application/shareableLinks')
|
||||
const adsRouter = require('./application/ads')
|
||||
const profileRouter = require('./application/profile')
|
||||
const workroomRouter = require('./application/workroom')
|
||||
const messagesRouter = require('./application/messages')
|
||||
@@ -36,10 +39,12 @@ const giftsRouter = require('./application/gifts')
|
||||
const chatRoomsRouter = require('./application/chat-rooms')
|
||||
const suggestionsRouter = require('./application/suggestions')
|
||||
const analyticsRouter = require('./application/analytics')
|
||||
const geoRouter = require('./application/geo')
|
||||
|
||||
const projectsPanelRouter = require('./panel/projects')
|
||||
const postsPanelRouter = require('./panel/posts')
|
||||
const expertisePanelRouter = require('./panel/expertise')
|
||||
const adsPanelRouter = require('./panel/ads')
|
||||
const shopCategoriesPanelRouter = require('./panel/shop-categories')
|
||||
const marketplaceShopsPanelRouter = require('./panel/marketplace-shops')
|
||||
const walletRequestsPanelRouter = require('./panel/wallet-requests')
|
||||
@@ -89,6 +94,9 @@ module.exports = (app) => {
|
||||
app.use('/api/v1/payment/shop-order', shopOrderPaymentRouter)
|
||||
app.use('/api/v1/wallet', walletRouter)
|
||||
app.use('/api/v1/bookings', bookingsRouter)
|
||||
app.use('/api/v1/auto-direct', autoDirectRouter)
|
||||
app.use('/api/v1/shareable-links', shareableLinksRouter)
|
||||
app.use('/api/v1/ads', adsRouter)
|
||||
app.use('/api/v1/profile', profileRouter)
|
||||
app.use('/api/v1/account', accountRouter)
|
||||
app.use('/api/v1/gifts', giftsRouter)
|
||||
@@ -106,6 +114,7 @@ module.exports = (app) => {
|
||||
app.use('/api/v1/advertising', advertisingRouter)
|
||||
app.use('/api/v1/settings', settingsRouter)
|
||||
app.use('/api/v1/version', versionRouter)
|
||||
app.use('/api/v1/geo', geoRouter)
|
||||
app.use('/api/v1/academy', academyRoute)
|
||||
app.use('/api/v1/stories', storiesRouter)
|
||||
|
||||
@@ -118,6 +127,7 @@ module.exports = (app) => {
|
||||
app.use('/api/v1/panel/projects', [adminAuth, requirePermission('projects')], projectsPanelRouter)
|
||||
app.use('/api/v1/panel/posts', [adminAuth, requirePermission('posts')], postsPanelRouter)
|
||||
app.use('/api/v1/panel/expertise', [adminAuth, requirePermission('expertise')], expertisePanelRouter)
|
||||
app.use('/api/v1/panel/ads', [adminAuth, requirePermission('ads')], adsPanelRouter)
|
||||
app.use('/api/v1/panel/shop-categories', [adminAuth, requirePermission('shop-categories')], shopCategoriesPanelRouter)
|
||||
app.use('/api/v1/panel/marketplace-shops', [adminAuth, requirePermission('marketplace-shops')], marketplaceShopsPanelRouter)
|
||||
app.use('/api/v1/panel/wallet-requests', [adminAuth, requirePermission('wallet-requests')], walletRequestsPanelRouter)
|
||||
|
||||
21
routes/panel/ads/index.js
Normal file
21
routes/panel/ads/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const express = require('express')
|
||||
const {
|
||||
listAds,
|
||||
getAdDetailPanel,
|
||||
listPricingRules,
|
||||
createPricingRule,
|
||||
updatePricingRule,
|
||||
deletePricingRule
|
||||
} = require('../../../controllers/panel/ads/adsPanelController')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/pricing', listPricingRules)
|
||||
router.post('/pricing', createPricingRule)
|
||||
router.patch('/pricing/:id', updatePricingRule)
|
||||
router.delete('/pricing/:id', deletePricingRule)
|
||||
|
||||
router.get('/', listAds)
|
||||
router.get('/:id', getAdDetailPanel)
|
||||
|
||||
module.exports = router
|
||||
@@ -1,7 +1,31 @@
|
||||
const express = require('express')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const multer = require('multer')
|
||||
const { createBroadcast, getBroadcasts, getBroadcastDetail } = require('../../../controllers/panel/notifications/notificationsController')
|
||||
const router = express.Router()
|
||||
|
||||
const broadcastStorageDir = path.join(__dirname, '../../../storage/broadcasts')
|
||||
if (!fs.existsSync(broadcastStorageDir)) {
|
||||
fs.mkdirSync(broadcastStorageDir, { recursive: true })
|
||||
}
|
||||
|
||||
const broadcastUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, broadcastStorageDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.jpg'
|
||||
cb(null, `${Date.now()}-broadcast${ext}`)
|
||||
}
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) return cb(null, true)
|
||||
cb(new Error('فقط تصویر مجاز است'), false)
|
||||
}
|
||||
}).single('image')
|
||||
|
||||
router.get('/', getBroadcasts)
|
||||
router.get('/:id', getBroadcastDetail)
|
||||
router.post('/broadcast', createBroadcast)
|
||||
router.post('/broadcast', broadcastUpload, createBroadcast)
|
||||
module.exports = router
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
const NotificationModel = require("../models/NotificationModel");
|
||||
const UserModel = require("../models/UserModel");
|
||||
const { notifyUser } = require("./notifyUser");
|
||||
const { buildStorageUrl } = require("./storageUrl");
|
||||
|
||||
const PURCHASE_TITLE = "پکیج شما خریداری شد";
|
||||
const PURCHASE_DESCRIPTION = "یک کاربر پکیج آموزشی شما را خریداری کرد";
|
||||
const PURCHASE_DESCRIPTION = "دورهی آموزشی شما را خریداری کرد";
|
||||
|
||||
// همون الگوی tagNotification.js/notifyFollowers.js — چون این تابع مستقیم از کنترلر
|
||||
// صدا زده میشه (نه یه جاب زمانبندیشده)، io از index.js با همین setIo تزریق میشه
|
||||
let ioInstance = null;
|
||||
function setIo(io) {
|
||||
ioInstance = io;
|
||||
}
|
||||
|
||||
const createCoursePurchaseNotification = async ({
|
||||
ownerId,
|
||||
@@ -12,12 +20,19 @@ const createCoursePurchaseNotification = async ({
|
||||
if (!ownerId || !buyerId || !courseId) return;
|
||||
if (String(ownerId) === String(buyerId)) return;
|
||||
|
||||
await NotificationModel.create({
|
||||
user_id: ownerId,
|
||||
project_post_id: courseId,
|
||||
const buyer = await UserModel.findById(buyerId).select(
|
||||
"user_name profile_image"
|
||||
);
|
||||
const buyerUsername = buyer?.user_name || "کاربر";
|
||||
const buyerImage = buildStorageUrl(buyer?.profile_image) || undefined;
|
||||
|
||||
await notifyUser(ioInstance, ownerId, {
|
||||
type: "academy_purchase",
|
||||
title: PURCHASE_TITLE,
|
||||
title: buyerUsername,
|
||||
description: PURCHASE_DESCRIPTION,
|
||||
project_post_id: courseId,
|
||||
actor_id: buyerId,
|
||||
image: buyerImage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating course purchase notification:", error);
|
||||
@@ -26,4 +41,5 @@ const createCoursePurchaseNotification = async ({
|
||||
|
||||
module.exports = {
|
||||
createCoursePurchaseNotification,
|
||||
setIo,
|
||||
};
|
||||
|
||||
255
utils/adDelivery.js
Normal file
255
utils/adDelivery.js
Normal file
@@ -0,0 +1,255 @@
|
||||
const AdModel = require('../models/AdModel')
|
||||
const PostModel = require('../models/PostModel')
|
||||
const UserModel = require('../models/UserModel')
|
||||
const CommentModel = require('../models/CommentModel')
|
||||
const ProjectModel = require('../models/ProjectModel')
|
||||
const ShopProductListingModel = require('../models/ShopProductListingModel')
|
||||
const ShopModel = require('../models/ShopModel')
|
||||
const AdvertisingModel = require('../models/AdvertisingModel')
|
||||
const AdvertisingProfileModel = require('../models/AdvertisingProfile')
|
||||
const { sendPushToTokens } = require('./pushNotification')
|
||||
const { resolveShareableLinkDisplay } = require('./shareableLinkDisplay')
|
||||
|
||||
const LINKED_CONTENT_KIND = {
|
||||
project: { kind: 'project', field: 'content_project_id' },
|
||||
product: { kind: 'shopproduct', field: 'content_shop_product_id' },
|
||||
shop: { kind: 'shop', field: 'content_shop_id' },
|
||||
billboard: { kind: 'billboard', field: 'content_billboard_id' }
|
||||
}
|
||||
|
||||
/**
|
||||
* یه تبلیغِ فعالِ منطبق با پروفایلِ بیننده پیدا میکنه — هر فیلدِ هدفگذاری که تبلیغکننده
|
||||
* خالی گذاشته (null/آرایهی خالی) یعنی وایلدکارت (روی هر بینندهای صدق میکنه). خودِ
|
||||
* تبلیغکننده تبلیغِ خودش رو نمیبینه. اگه چند تبلیغِ منطبق بود، یکی رندوم انتخاب میشه
|
||||
* (چرخشِ ساده، بهجای همیشه یکی رو نشون دادن)
|
||||
*/
|
||||
async function findActiveAd (adType, viewer) {
|
||||
if (!viewer?._id) return null
|
||||
|
||||
const query = {
|
||||
status: 'active',
|
||||
ad_type: adType,
|
||||
expires_at: { $gt: new Date() },
|
||||
user_id: { $ne: viewer._id },
|
||||
$and: [
|
||||
{ $or: [{ target_country_id: null }, { target_country_id: viewer.country?.id ?? null }] },
|
||||
{ $or: [{ target_province_id: null }, { target_province_id: viewer.province?.id ?? null }] },
|
||||
{ $or: [{ target_city_id: null }, { target_city_id: viewer.city?.id ?? null }] },
|
||||
{ $or: [{ target_gender: 'any' }, { target_gender: viewer.gender || '__none__' }] },
|
||||
{ $or: [{ target_expertise: null }, { target_expertise: viewer.expertise || '__none__' }] },
|
||||
{
|
||||
$or: [
|
||||
{ target_sub_expertise: { $size: 0 } },
|
||||
{ target_sub_expertise: { $in: viewer.sub_expertise || [] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const candidates = await AdModel.find(query).limit(20).lean()
|
||||
if (!candidates.length) return null
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]
|
||||
}
|
||||
|
||||
/** عینِ shape خروجیِ mapPostsWithUsers (getUserController.js) — تا کارتِ پست توی فرانت
|
||||
* بدونِ نیاز به شرطِ جدا، پستِ تبلیغاتی رو هم درست رندر کنه */
|
||||
async function buildAdPostItem (ad) {
|
||||
if (!ad?.content_post_id) return null
|
||||
const post = await PostModel.findById(ad.content_post_id).lean()
|
||||
if (!post) return null
|
||||
const user = await UserModel.findById(post.user_id).lean()
|
||||
if (!user) return null
|
||||
|
||||
const commentsCount = await CommentModel.countDocuments({
|
||||
post: post._id,
|
||||
status: { $in: ['accepted', 'temp_accept'] }
|
||||
})
|
||||
|
||||
return {
|
||||
...post,
|
||||
user_name: user.user_name || '',
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
expertise: user.expertise || '',
|
||||
sub_expertise: user.sub_expertise || [],
|
||||
province: user.show_location ? user.province || {} : {},
|
||||
city: user.show_location ? user.city || {} : {},
|
||||
show_location: user.show_location,
|
||||
user_level: user.user_level || '',
|
||||
profile_image: user.profile_image || '',
|
||||
is_verified: user.is_verified || '',
|
||||
verify_badge: user.verify_badge || '',
|
||||
user_score: user.user_score || 0,
|
||||
rate: user.rate || 0,
|
||||
likesCount: post.likes ? post.likes.length : 0,
|
||||
is_liked: false,
|
||||
commentsCount,
|
||||
is_ad: true,
|
||||
ad_id: String(ad._id)
|
||||
}
|
||||
}
|
||||
|
||||
function storyMediaUrl (storagePath) {
|
||||
if (!storagePath) return ''
|
||||
const normalized = storagePath.replace(/\\/g, '/')
|
||||
const idx = normalized.indexOf('/storage/')
|
||||
if (idx >= 0) return normalized.slice(idx)
|
||||
return normalized.replace('/root/modstagram-back/storage', '/storage')
|
||||
}
|
||||
|
||||
/** عینِ buildAdPostItem ولی برای استوری — getStoriesFeed هر گروه رو با user+stories
|
||||
* برمیگردونه. از مدیای اسنپشاتشدهی خودِ AdModel استفاده میکنه (نه StoryModel زنده) —
|
||||
* چون استوریِ اصلی معمولاً ۲۴ساعته منقضی میشه ولی کمپینِ تبلیغاتی چند روزهست */
|
||||
async function buildAdStoryGroup (ad) {
|
||||
if (!ad?.content_media_path) return null
|
||||
const user = await UserModel.findById(ad.user_id).lean()
|
||||
if (!user) return null
|
||||
|
||||
return {
|
||||
user: {
|
||||
_id: user._id,
|
||||
user_name: user.user_name,
|
||||
profile_image: user.profile_image,
|
||||
is_verified: user.is_verified,
|
||||
verify_badge: user.verify_badge
|
||||
},
|
||||
stories: [{
|
||||
_id: ad.content_story_id,
|
||||
media_path: storyMediaUrl(ad.content_media_path),
|
||||
media_type: ad.content_media_type,
|
||||
overlays: ad.content_overlays || [],
|
||||
linked_post_id: null,
|
||||
createdAt: ad.started_at,
|
||||
expires_at: ad.expires_at,
|
||||
viewed: false,
|
||||
liked: false,
|
||||
likes_count: 0,
|
||||
is_ad: true,
|
||||
ad_id: String(ad._id)
|
||||
}],
|
||||
has_unviewed: true,
|
||||
is_following: false,
|
||||
is_ad: true,
|
||||
ad_id: String(ad._id),
|
||||
latest_at: ad.started_at
|
||||
}
|
||||
}
|
||||
|
||||
/** برای چهار نوعِ «تبلیغِ محتوای موجود» (پروژه/کالا/فروشگاه/بیلبورد) — دقیقاً همون
|
||||
* resolveShareableLinkDisplay که برای لینکهای اشتراکیِ کپشن استفاده میشه (title+image) */
|
||||
async function buildAdLinkedContentItem (ad) {
|
||||
const entry = LINKED_CONTENT_KIND[ad?.ad_type]
|
||||
if (!entry) return null
|
||||
const contentId = ad[entry.field]
|
||||
if (!contentId) return null
|
||||
const display = await resolveShareableLinkDisplay(entry.kind, contentId)
|
||||
if (!display) return null
|
||||
return {
|
||||
ad_id: String(ad._id),
|
||||
ad_type: ad.ad_type,
|
||||
content_id: String(contentId),
|
||||
title: display.title,
|
||||
image: display.image,
|
||||
is_ad: true
|
||||
}
|
||||
}
|
||||
|
||||
/** عینِ shape خروجیِ getProjects (enrichProjectsWithRequests) — یه سندِ خامِ ProjectModel،
|
||||
* بدونِ projectRequests (پروژهی تبلیغاتی نیازی به درخواستها نداره) */
|
||||
async function buildAdProjectItem (ad) {
|
||||
if (!ad?.content_project_id) return null
|
||||
const project = await ProjectModel.findById(ad.content_project_id).lean()
|
||||
if (!project) return null
|
||||
return { ...project, is_ad: true, ad_id: String(ad._id) }
|
||||
}
|
||||
|
||||
/** عینِ shape خروجیِ getAdvertisingsWeb (لیستِ بیلبوردها) */
|
||||
async function buildAdBillboardItem (ad) {
|
||||
if (!ad?.content_billboard_id) return null
|
||||
const billboard = await AdvertisingModel.findById(ad.content_billboard_id).lean()
|
||||
if (!billboard) return null
|
||||
const creator = await AdvertisingProfileModel.findOne({ user: billboard.creator_id }).lean()
|
||||
return {
|
||||
_id: billboard._id,
|
||||
category: billboard.category,
|
||||
title: billboard.title,
|
||||
province: billboard.province,
|
||||
city: billboard.city,
|
||||
neighbourhood: billboard.neighbourhood,
|
||||
type: billboard.type,
|
||||
mostDiscountPercentage: billboard.mostDiscountPercentage,
|
||||
images: billboard.images,
|
||||
likedByUser: false,
|
||||
likesCount: billboard.likesCount,
|
||||
commentsCount: billboard.commentsCount,
|
||||
showDiscount: billboard.showDiscount,
|
||||
viewCount: billboard.viewCount,
|
||||
createdAt: billboard.createdAt,
|
||||
rate: { adTotalRatings: creator?.adTotalRatings, adAverageRating: creator?.adAverageRating },
|
||||
is_ad: true,
|
||||
ad_id: String(ad._id)
|
||||
}
|
||||
}
|
||||
|
||||
/** عینِ shape خروجیِ getDiscoverFeed (گریدِ اکسپلورِ فروشگاهها) — لیستینگ + shop پاپیولهشده */
|
||||
async function buildAdShopProductItem (ad) {
|
||||
if (!ad?.content_shop_product_id) return null
|
||||
const listing = await ShopProductListingModel.findById(ad.content_shop_product_id).lean()
|
||||
if (!listing || listing.status !== 'active') return null
|
||||
const shop = await ShopModel.findById(listing.shop).populate('owner', 'verify_badge').lean()
|
||||
if (!shop || shop.status !== 'active') return null
|
||||
return {
|
||||
...listing,
|
||||
shop,
|
||||
shop_count: 1,
|
||||
tileId: `ad-${ad._id}`,
|
||||
is_ad: true,
|
||||
ad_id: String(ad._id)
|
||||
}
|
||||
}
|
||||
|
||||
async function trackAdEvent (adId, type) {
|
||||
if (!adId || !['view', 'click'].includes(type)) return
|
||||
const field = type === 'view' ? 'views_count' : 'clicks_count'
|
||||
await AdModel.updateOne({ _id: adId }, { $inc: { [field]: 1 } })
|
||||
}
|
||||
|
||||
/** تبلیغِ نوع notification — بهجای تزریق توی فید/استوری، مستقیم پوش میفرسته به هر
|
||||
* کاربری که با هدفگذاریِ تبلیغ جور دربیاد؛ views_count رو با تعدادِ گیرندهها پر میکنه */
|
||||
async function deliverNotificationAd (ad) {
|
||||
if (ad.ad_type !== 'notification') return
|
||||
|
||||
const query = { _id: { $ne: ad.user_id } }
|
||||
if (ad.target_country_id != null) query['country.id'] = ad.target_country_id
|
||||
if (ad.target_province_id != null) query['province.id'] = ad.target_province_id
|
||||
if (ad.target_city_id != null) query['city.id'] = ad.target_city_id
|
||||
if (ad.target_gender && ad.target_gender !== 'any') query.gender = ad.target_gender
|
||||
if (ad.target_expertise) query.expertise = ad.target_expertise
|
||||
if (ad.target_sub_expertise?.length) query.sub_expertise = { $in: ad.target_sub_expertise }
|
||||
|
||||
const users = await UserModel.find(query).select('fcm_tokens fcm_web_tokens').lean()
|
||||
const tokens = users.flatMap((u) => [...(u.fcm_tokens || []), ...(u.fcm_web_tokens || [])])
|
||||
|
||||
if (tokens.length) {
|
||||
await sendPushToTokens(tokens, {
|
||||
title: ad.notification_title || 'تبلیغ',
|
||||
description: ad.notification_body || '',
|
||||
type: 'sponsored_notification',
|
||||
data: { ad_id: String(ad._id) }
|
||||
})
|
||||
}
|
||||
|
||||
await AdModel.updateOne({ _id: ad._id }, { $inc: { views_count: users.length } })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findActiveAd,
|
||||
buildAdPostItem,
|
||||
buildAdStoryGroup,
|
||||
buildAdLinkedContentItem,
|
||||
buildAdProjectItem,
|
||||
buildAdBillboardItem,
|
||||
buildAdShopProductItem,
|
||||
trackAdEvent,
|
||||
deliverNotificationAd
|
||||
}
|
||||
39
utils/adPricing.js
Normal file
39
utils/adPricing.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const AdPricingRuleModel = require('../models/AdPricingRuleModel')
|
||||
|
||||
/**
|
||||
* دقیقترین قانونِ منطبق رو پیدا میکنه — یه قانون منطبقه اگه هر فیلدِ غیرِnullش دقیقاً با
|
||||
* سناریوی دادهشده یکی باشه (فیلدِ null همیشه صدق میکنه، یعنی وایلدکارت). بینِ قانونهای
|
||||
* منطبق، اونی که بیشترین فیلدِ غیرِnull (دقیقترین) رو داره برندهست.
|
||||
* gender/expertise/provinceId که خودِ کاربر انتخاب نکرده (یعنی «هرکسی»/«فرقی نمیکنه») باید
|
||||
* null پاس داده بشه، نه رشتهی خالی — وگرنه هیچ قانونِ وایلدکاردی هم منطبق نمیشه.
|
||||
* subExpertise آرایهست (تبلیغ میتونه چند زیرتخصص هدفگذاری کنه) — قانونِ subExpertise
|
||||
* وقتی منطبقه که همون یکی مقدارش توی این آرایه باشه
|
||||
*/
|
||||
async function resolveAdPrice (adType, { provinceId, gender, expertise, subExpertise } = {}) {
|
||||
const rules = await AdPricingRuleModel.find({ ad_type: adType }).lean()
|
||||
const subExpertiseList = Array.isArray(subExpertise) ? subExpertise : []
|
||||
|
||||
let best = null
|
||||
let bestScore = -1
|
||||
for (const rule of rules) {
|
||||
if (rule.province_id != null && rule.province_id !== provinceId) continue
|
||||
if (rule.gender != null && rule.gender !== gender) continue
|
||||
if (rule.expertise != null && rule.expertise !== expertise) continue
|
||||
if (rule.sub_expertise != null && !subExpertiseList.includes(rule.sub_expertise)) continue
|
||||
|
||||
const score =
|
||||
(rule.province_id != null ? 1 : 0) +
|
||||
(rule.gender != null ? 1 : 0) +
|
||||
(rule.expertise != null ? 1 : 0) +
|
||||
(rule.sub_expertise != null ? 1 : 0)
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = rule
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.price : null
|
||||
}
|
||||
|
||||
module.exports = { resolveAdPrice }
|
||||
55
utils/autoDirectTrigger.js
Normal file
55
utils/autoDirectTrigger.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const AutoDirectRuleModel = require('../models/AutoDirectRuleModel')
|
||||
const CommentModel = require('../models/CommentModel')
|
||||
const MessageModel = require('../models/MessageModel')
|
||||
const { emitChatEvents, enrichMessage } = require('../helpers/chatHelpers')
|
||||
|
||||
// دایرکت اتوماتیک: وقتی کسی زیر پست یه کاربر، یکی از متنهای تریگرِ خودش رو کامنت میکنه،
|
||||
// بدون نیاز به هیچ تاییدی هم یه دایرکت براش میفرسته هم زیرِ همون کامنت جواب میده.
|
||||
// content پیامِ دایرکت رو عینِ sharedPost (engagementController.js) plain-text ذخیره میکنیم —
|
||||
// چون خودِ سرور ساخته، نه متنِ رمزنگاریشدهٔ کاربر، محدودیتِ E2EE (chatHelpers.js) اینجا صدق نمیکنه
|
||||
async function runAutoDirectRules ({ io, postOwnerId, commenterId, commentId, postId, commentText }) {
|
||||
try {
|
||||
if (!postOwnerId || !commenterId || !postId || !commentText) return
|
||||
if (String(postOwnerId) === String(commenterId)) return
|
||||
|
||||
const rules = await AutoDirectRuleModel.find({ user_id: postOwnerId, is_active: true }).lean()
|
||||
if (!rules.length) return
|
||||
|
||||
const normalized = String(commentText).toLowerCase()
|
||||
const matched = rules.find(
|
||||
(r) => r.trigger_text && normalized.includes(String(r.trigger_text).toLowerCase().trim())
|
||||
)
|
||||
if (!matched) return
|
||||
|
||||
const newMessage = new MessageModel({
|
||||
senderId: postOwnerId,
|
||||
receiverId: commenterId,
|
||||
content: matched.direct_message
|
||||
})
|
||||
await newMessage.save()
|
||||
|
||||
if (io) {
|
||||
const formatted = await enrichMessage(newMessage)
|
||||
emitChatEvents(io, formatted, postOwnerId, commenterId, {
|
||||
type: 'chat_message',
|
||||
description: String(matched.direct_message).slice(0, 40)
|
||||
})
|
||||
}
|
||||
|
||||
await CommentModel.create({
|
||||
user: postOwnerId,
|
||||
creator: postOwnerId,
|
||||
comment_for: 'post',
|
||||
post: postId,
|
||||
parent: commentId,
|
||||
comment: matched.reply_caption,
|
||||
status: 'accepted'
|
||||
})
|
||||
|
||||
await AutoDirectRuleModel.updateOne({ _id: matched._id }, { $inc: { sent_count: 1 } })
|
||||
} catch (error) {
|
||||
console.error('Error running auto-direct rules:', error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runAutoDirectRules }
|
||||
@@ -8,20 +8,30 @@ const { sendPushToUser } = require('./pushNotification')
|
||||
* همچنین یه پوش FCM هم برای گوشی کاربر میفرسته — این تنها نقطهایه که باعث میشه
|
||||
* همهی نوتیفیکیشنهایی که از این تابع رد میشن بهصورت پوش هم نمایش داده بشن
|
||||
*/
|
||||
async function notifyUser (io, userId, { type, title, description, project_post_id, actor_id } = {}) {
|
||||
await NotificationModel.create({ user_id: userId, type, title, description, project_post_id, actor_id })
|
||||
async function notifyUser (io, userId, { type, title, description, project_post_id, actor_id, image, link, actorUsername } = {}) {
|
||||
await NotificationModel.create({ user_id: userId, type, title, description, project_post_id, actor_id, image })
|
||||
|
||||
if (io) {
|
||||
try {
|
||||
io.to(`user:${String(userId)}`).emit('notification', { type, title, description })
|
||||
io.to(`user:${String(userId)}`).emit('notification', { type, title, description, image })
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const data = {}
|
||||
if (project_post_id) data.project_post_id = String(project_post_id)
|
||||
// مسیرِ نسبیِ آماده (وقتی خودِ فراخوان از قبل username/id لازم رو داره) — سرویسورکرِ
|
||||
// وب دیگه لازم نیست خودش لوکاپِ اضافه بزنه تا بفهمه دقیقاً کجا باز کنه
|
||||
if (link) data.link = link
|
||||
// یوزرنیمِ عاملِ نوتیف (مثلاً صاحبِ استوری برای story_mention) — اندروید بدونِ این
|
||||
// مجبور بود یه درخواستِ اضافه بزنه تا از رویِ آیدی به یوزرنیم برسه
|
||||
if (actorUsername) data.actorUsername = actorUsername
|
||||
|
||||
sendPushToUser(userId, {
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
data: project_post_id ? { project_post_id: String(project_post_id) } : undefined
|
||||
data: Object.keys(data).length ? data : undefined,
|
||||
image
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,18 +34,33 @@ function getFirebaseApp () {
|
||||
}
|
||||
}
|
||||
|
||||
/** ارسال پوش به یه لیست توکن مشخص — پایهی مسیرهای دستهجمعی (فالوورها، پیامهای همگانی پنل) */
|
||||
async function sendPushToTokens (tokens, { title, description, type, data, image } = {}) {
|
||||
/**
|
||||
* ارسال پوش به یه لیست توکن مشخص — پایهی مسیرهای دستهجمعی (فالوورها، پیامهای همگانی پنل)
|
||||
* dataOnly: اگه true باشه، هیچ بلاک notification فرستاده نمیشه — فقط data (رشتهایشده).
|
||||
* دلیلش: react-native-firebase وقتی پیام یه notification داشته باشه، خودِ سیستمعامل
|
||||
* (نه کدِ اپ) پیشفرض نمایشش میده و setBackgroundMessageHandler برای اون پیام اصلاً صدا
|
||||
* زده نمیشه — یعنی هیچوقت نمیشه آیکونِ بزرگ (آواتار) رو ازش کنترل کرد. فقط برای
|
||||
* توکنهای موبایل (sendPushToUser) استفاده میشه؛ وب دستنخورده میمونه (already working)
|
||||
*/
|
||||
async function sendPushToTokens (tokens, { title, description, type, data, image, dataOnly } = {}) {
|
||||
const app = getFirebaseApp()
|
||||
const cleanTokens = (tokens || []).filter(Boolean)
|
||||
if (!app || cleanTokens.length === 0) return { successCount: 0, invalidTokens: [] }
|
||||
|
||||
const dataPayload = { type, ...data }
|
||||
if (dataOnly) {
|
||||
// notifee (index.js پسزمینه/foreground) از همین فیلدها نوتیف غنی میسازه
|
||||
dataPayload.title = title || ''
|
||||
dataPayload.description = description || ''
|
||||
if (image) dataPayload.image = image
|
||||
}
|
||||
|
||||
const message = {
|
||||
// notification.image: عکس/آواتار نمایش دادهشده توی خودِ نوتیف (اندروید: بزرگآیکون/
|
||||
// big-picture، iOS: پیوست) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام
|
||||
notification: { title, body: description, ...(image ? { image } : {}) },
|
||||
// notification.image: عکس/آواتار نمایش دادهشده توی خودِ نوتیف (وب/آیاواس، و اندرویدِ
|
||||
// dataOnly:false) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام
|
||||
...(dataOnly ? {} : { notification: { title, body: description, ...(image ? { image } : {}) } }),
|
||||
data: Object.fromEntries(
|
||||
Object.entries({ type, ...data }).map(([k, v]) => [k, String(v ?? '')])
|
||||
Object.entries(dataPayload).map(([k, v]) => [k, String(v ?? '')])
|
||||
),
|
||||
tokens: cleanTokens
|
||||
}
|
||||
@@ -73,21 +88,34 @@ async function sendPushToTokens (tokens, { title, description, type, data, image
|
||||
return { successCount, invalidTokens }
|
||||
}
|
||||
|
||||
/** ارسال پوش به یک کاربر مشخص — توکنهاش رو از دیتابیس میخونه و توکنهای نامعتبر رو پاک میکنه */
|
||||
/**
|
||||
* ارسال پوش به یک کاربر مشخص — توکنهاش رو از دیتابیس میخونه و توکنهای نامعتبر رو پاک میکنه.
|
||||
* موبایل و وب جدا ارسال میشن: موبایل dataOnly (نوتیفِ غنی رو notifee توی index.js/App
|
||||
* میسازه)، وب عین قبل با بلاک notification (رفتارِ فعلاً درستِ وب دستنخورده میمونه)
|
||||
*/
|
||||
async function sendPushToUser (userId, { title, description, type, data, image } = {}) {
|
||||
try {
|
||||
const user = await UserModel.findById(userId).select('fcm_tokens fcm_web_tokens').lean()
|
||||
const tokens = [...(user?.fcm_tokens || []), ...(user?.fcm_web_tokens || [])]
|
||||
if (tokens.length === 0) return
|
||||
const mobileTokens = user?.fcm_tokens || []
|
||||
const webTokens = user?.fcm_web_tokens || []
|
||||
if (mobileTokens.length === 0 && webTokens.length === 0) return
|
||||
|
||||
const { invalidTokens } = await sendPushToTokens(tokens, { title, description, type, data, image })
|
||||
if (invalidTokens.length > 0) {
|
||||
const [mobileResult, webResult] = await Promise.all([
|
||||
mobileTokens.length
|
||||
? sendPushToTokens(mobileTokens, { title, description, type, data, image, dataOnly: true })
|
||||
: Promise.resolve({ invalidTokens: [] }),
|
||||
webTokens.length
|
||||
? sendPushToTokens(webTokens, { title, description, type, data, image })
|
||||
: Promise.resolve({ invalidTokens: [] })
|
||||
])
|
||||
|
||||
if (mobileResult.invalidTokens.length > 0 || webResult.invalidTokens.length > 0) {
|
||||
await UserModel.updateOne(
|
||||
{ _id: userId },
|
||||
{
|
||||
$pull: {
|
||||
fcm_tokens: { $in: invalidTokens },
|
||||
fcm_web_tokens: { $in: invalidTokens }
|
||||
fcm_tokens: { $in: mobileResult.invalidTokens },
|
||||
fcm_web_tokens: { $in: webResult.invalidTokens }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,88 +1,91 @@
|
||||
const mongoose = require('mongoose');
|
||||
const CourseModel = require('../models/CourseModel');
|
||||
const AcademyModel = require('../models/AcademyModel');
|
||||
const ProjectModel = require('../models/ProjectModel');
|
||||
const AdvertisingModel = require('../models/AdvertisingModel');
|
||||
const ShopModel = require('../models/ShopModel');
|
||||
const ShopProductListingModel = require('../models/ShopProductListingModel');
|
||||
const BookingConfigModel = require('../models/BookingConfigModel');
|
||||
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus');
|
||||
const {
|
||||
PUBLIC_COURSE_STATUSES,
|
||||
PUBLIC_BILLBOARD_STATUSES,
|
||||
PUBLIC_PROJECT_STATUSES,
|
||||
} = require('./moderationStatus');
|
||||
|
||||
// اعتبارسنجی مشترک «آیتم قابللینکشدن» — هم پست (linked_*_id) هم استوری (لینکاورلی) از
|
||||
// همین یه تابع استفاده میکنن تا شرط مالکیت+وضعیت هرکدوم دوبار نوشته نشه. خروجی: شناسهی
|
||||
// معتبری که باید ذخیره بشه، یا null اگه کاربر مالک نیست/وضعیتش پذیرفتهشده نیست.
|
||||
async function resolveLinkedEntity(kind, id, userId) {
|
||||
function sameId(a, b) {
|
||||
if (a == null || b == null) return false;
|
||||
const sa = typeof a === 'object' && a._id != null ? String(a._id) : String(a);
|
||||
const sb = typeof b === 'object' && b._id != null ? String(b._id) : String(b);
|
||||
return sa === sb;
|
||||
}
|
||||
|
||||
// اعتبارسنجی مشترک «آیتم قابللینکشدن»
|
||||
// options.requirePublic (پیشفرض true): برای لینک داخل پست/استوری وضعیت عمومی لازم است.
|
||||
// برای صفحه «اشتراکگذاری لینک» فقط مالکیت کافی است (requirePublic: false).
|
||||
async function resolveLinkedEntity(kind, id, userId, options = {}) {
|
||||
const requirePublic = options.requirePublic !== false;
|
||||
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'pkg': {
|
||||
const course = await CourseModel.findById(id).select('_id user_id status').lean();
|
||||
// با همون آستانهی «عمومی قابلنمایش» که همهجای اپ برای پکیج آموزشی استفاده میشه
|
||||
// (temp_accept هم شامل میشه) — نه فقط accept نهایی؛ وگرنه هر پکیجی که کاربر تازه
|
||||
// ثبت کرده (وضعیت پیشفرضش temp_accept) توی پیکر قابلانتخاب بود ولی همیشه رد میشد
|
||||
if (
|
||||
course &&
|
||||
String(course.user_id) === String(userId) &&
|
||||
PUBLIC_COURSE_STATUSES.includes(course.status)
|
||||
) {
|
||||
return String(course._id);
|
||||
const course = await CourseModel.findById(id)
|
||||
.select('_id user_id academyId status')
|
||||
.lean();
|
||||
if (!course) return null;
|
||||
if (requirePublic && !PUBLIC_COURSE_STATUSES.includes(course.status)) return null;
|
||||
|
||||
// مالکیت: user_id مستقیم روی دوره، یا مالک آکادمیِ دوره
|
||||
if (sameId(course.user_id, userId)) return String(course._id);
|
||||
if (course.academyId) {
|
||||
const academy = await AcademyModel.findById(course.academyId).select('userId').lean();
|
||||
if (academy && sameId(academy.userId, userId)) return String(course._id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'project': {
|
||||
const project = await ProjectModel.findById(id).select('_id creator_id status').lean();
|
||||
// عین pkg/billboard — هر وضعیتِ واقعی/متعهدشده و غیرِ رد/لغوشده رو میپذیریم، نه فقط
|
||||
// done/accepted؛ وگرنه تقریباً هیچ پروژهی واقعیای (که بیشترِ عمرش pending/ongoing/
|
||||
// temp_accept هست، نه دقیقاً done/accepted) قابللینکشدن نبود
|
||||
if (
|
||||
project &&
|
||||
String(project.creator_id) === String(userId) &&
|
||||
PUBLIC_PROJECT_STATUSES.includes(project.status)
|
||||
) {
|
||||
return String(project._id);
|
||||
}
|
||||
const project = await ProjectModel.findById(id)
|
||||
.select('_id creator_id status')
|
||||
.lean();
|
||||
if (!project) return null;
|
||||
if (requirePublic && !PUBLIC_PROJECT_STATUSES.includes(project.status)) return null;
|
||||
if (sameId(project.creator_id, userId)) return String(project._id);
|
||||
return null;
|
||||
}
|
||||
case 'billboard': {
|
||||
const ad = await AdvertisingModel.findById(id).select('_id creator_id status').lean();
|
||||
// عین pkg — همون آستانهی «عمومی قابلنمایش» بیلبورد (شامل temp_accept/paid هم میشه)
|
||||
if (
|
||||
ad &&
|
||||
String(ad.creator_id) === String(userId) &&
|
||||
PUBLIC_BILLBOARD_STATUSES.includes(ad.status)
|
||||
) {
|
||||
return String(ad._id);
|
||||
}
|
||||
const ad = await AdvertisingModel.findById(id)
|
||||
.select('_id creator_id status')
|
||||
.lean();
|
||||
if (!ad) return null;
|
||||
if (requirePublic && !PUBLIC_BILLBOARD_STATUSES.includes(ad.status)) return null;
|
||||
if (sameId(ad.creator_id, userId)) return String(ad._id);
|
||||
return null;
|
||||
}
|
||||
case 'shop': {
|
||||
const shop = await ShopModel.findById(id).select('_id owner status').lean();
|
||||
// draft/rejected هنوز واقعاً وجود عمومی ندارن، ولی pending_review (ثبتشده، منتظرِ
|
||||
// تایید) هم مثل بقیهی موارد بالا باید قابللینک باشه — فقط active بیشازحد سختگیرانه بود
|
||||
if (
|
||||
shop &&
|
||||
String(shop.owner) === String(userId) &&
|
||||
['active', 'pending_review'].includes(shop.status)
|
||||
) {
|
||||
return String(shop._id);
|
||||
}
|
||||
if (!shop) return null;
|
||||
if (requirePublic && !['active', 'pending_review'].includes(shop.status)) return null;
|
||||
if (sameId(shop.owner, userId)) return String(shop._id);
|
||||
return null;
|
||||
}
|
||||
case 'shopproduct': {
|
||||
const listing = await ShopProductListingModel.findById(id).select('_id shop status').lean();
|
||||
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null;
|
||||
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
|
||||
if (shop && String(shop.owner) === String(userId)) {
|
||||
return String(listing._id);
|
||||
const listing = await ShopProductListingModel.findById(id)
|
||||
.select('_id shop status')
|
||||
.lean();
|
||||
if (!listing) return null;
|
||||
if (requirePublic && !['active', 'pending_shop_approval'].includes(listing.status)) {
|
||||
return null;
|
||||
}
|
||||
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
|
||||
if (shop && sameId(shop.owner, userId)) return String(listing._id);
|
||||
return null;
|
||||
}
|
||||
case 'booking': {
|
||||
// برای رزرو، id همون userId مالک کانفیگه (نه _id خودِ BookingConfig) — چون صفحهی
|
||||
// عمومی رزرو (/booking/[userId]) با شناسهی کاربر مسیردهی میشه، نه شناسهی کانفیگ
|
||||
if (String(id) !== String(userId)) return null;
|
||||
const config = await BookingConfigModel.findOne({ owner: userId, status: 'active' })
|
||||
.select('_id')
|
||||
.lean();
|
||||
// ref_id برای رزرو = userId مالک کانفیگ (نه _id خودِ BookingConfig)
|
||||
if (!sameId(id, userId)) return null;
|
||||
const query = requirePublic
|
||||
? { owner: userId, status: 'active' }
|
||||
: { owner: userId };
|
||||
const config = await BookingConfigModel.findOne(query).select('_id').lean();
|
||||
return config ? String(userId) : null;
|
||||
}
|
||||
default:
|
||||
@@ -90,4 +93,4 @@ async function resolveLinkedEntity(kind, id, userId) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { resolveLinkedEntity };
|
||||
module.exports = { resolveLinkedEntity };
|
||||
49
utils/shareableLinkDisplay.js
Normal file
49
utils/shareableLinkDisplay.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const CourseModel = require('../models/CourseModel')
|
||||
const ProjectModel = require('../models/ProjectModel')
|
||||
const AdvertisingModel = require('../models/AdvertisingModel')
|
||||
const ShopModel = require('../models/ShopModel')
|
||||
const ShopProductListingModel = require('../models/ShopProductListingModel')
|
||||
const BookingConfigModel = require('../models/BookingConfigModel')
|
||||
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus')
|
||||
|
||||
// عینِ resolveLinkedEntity.js ولی بهجای اعتبارسنجیِ مالکیت، فقط عنوان/عکسِ نمایشی رو
|
||||
// برمیگردونه — برای وقتی که بلاگر داره لینکهای اشتراکیِ یه کاربرِ دیگه (نه خودش) رو میبینه
|
||||
async function resolveShareableLinkDisplay (kind, id) {
|
||||
switch (kind) {
|
||||
case 'pkg': {
|
||||
const course = await CourseModel.findById(id).select('cuorse_name course_image status').lean()
|
||||
if (!course || !PUBLIC_COURSE_STATUSES.includes(course.status)) return null
|
||||
return { title: course.cuorse_name || 'پکیج آموزشی', image: course.course_image || null }
|
||||
}
|
||||
case 'project': {
|
||||
const project = await ProjectModel.findById(id).select('title status').lean()
|
||||
if (!project || !PUBLIC_PROJECT_STATUSES.includes(project.status)) return null
|
||||
return { title: project.title || 'پروژه', image: null }
|
||||
}
|
||||
case 'billboard': {
|
||||
const ad = await AdvertisingModel.findById(id).select('title images status').lean()
|
||||
if (!ad || !PUBLIC_BILLBOARD_STATUSES.includes(ad.status)) return null
|
||||
return { title: ad.title || 'بیلبورد', image: ad.images?.[0] || null }
|
||||
}
|
||||
case 'shop': {
|
||||
const shop = await ShopModel.findById(id).select('name logo status').lean()
|
||||
if (!shop || !['active', 'pending_review'].includes(shop.status)) return null
|
||||
return { title: shop.name, image: shop.logo || null }
|
||||
}
|
||||
case 'shopproduct': {
|
||||
const listing = await ShopProductListingModel.findById(id).select('title images primaryImageIndex status').lean()
|
||||
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null
|
||||
const image = listing.images?.[listing.primaryImageIndex ?? 0] ?? listing.images?.[0] ?? null
|
||||
return { title: listing.title, image }
|
||||
}
|
||||
case 'booking': {
|
||||
const config = await BookingConfigModel.findOne({ owner: id, status: 'active' }).select('name').lean()
|
||||
if (!config) return null
|
||||
return { title: config.name || 'رزرو نوبت', image: null }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { resolveShareableLinkDisplay }
|
||||
48
utils/storyMentionNotification.js
Normal file
48
utils/storyMentionNotification.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const UserModel = require('./../models/UserModel')
|
||||
const { notifyUser } = require('./notifyUser')
|
||||
const { buildStorageUrl } = require('./storageUrl')
|
||||
|
||||
// همون الگوی tagNotification.js — قبلاً منشنکردنِ کاربر توی استوری هیچ نوتیفی
|
||||
// (نه دیتابیس، نه پوش) تولید نمیکرد؛ این تابع بعد از ساختِ استوری، برای هر اورلیِ
|
||||
// mention یه notifyUser صدا میزنه
|
||||
let ioInstance = null
|
||||
function setIo (io) {
|
||||
ioInstance = io
|
||||
}
|
||||
|
||||
async function notifyStoryMentions (story, ownerId) {
|
||||
try {
|
||||
const overlays = Array.isArray(story?.overlays) ? story.overlays : []
|
||||
const mentionedIds = [
|
||||
...new Set(
|
||||
overlays
|
||||
.filter((o) => o?.type === 'mention' && o.mention_user_id)
|
||||
.map((o) => String(o.mention_user_id))
|
||||
)
|
||||
].filter((id) => id !== String(ownerId))
|
||||
|
||||
if (!mentionedIds.length) return
|
||||
|
||||
const owner = await UserModel.findById(ownerId).select('user_name profile_image')
|
||||
const ownerUsername = owner?.user_name || 'کاربر'
|
||||
const ownerImage = buildStorageUrl(owner?.profile_image) || undefined
|
||||
|
||||
await Promise.all(
|
||||
mentionedIds.map((userId) =>
|
||||
notifyUser(ioInstance, userId, {
|
||||
type: 'story_mention',
|
||||
title: ownerUsername,
|
||||
description: 'شما را در استوریاش نام برد',
|
||||
project_post_id: story._id,
|
||||
actor_id: ownerId,
|
||||
image: ownerImage,
|
||||
actorUsername: owner?.user_name || undefined
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error creating story mention notifications:', error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { notifyStoryMentions, setIo }
|
||||
38
utils/storyRingFlags.js
Normal file
38
utils/storyRingFlags.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const StoryModel = require('../models/StoryModel')
|
||||
|
||||
/**
|
||||
* برای یه لیست از userId (مثلاً نویسندههای کامنتهای یه صفحه)، نقشهی has_story/
|
||||
* has_unviewed_story برمیگردونه — همون منطقِ حلقهی استوری که postEngagement.js
|
||||
* برای نویسندهی پستها محاسبه میکنه، جدا شده تا هر جای دیگه (مثلاً کامنتها) هم
|
||||
* قابل استفاده باشه بدون وابستگی به فیلدهای مخصوصِ پست
|
||||
*/
|
||||
async function resolveStoryFlags (userIds, viewerId) {
|
||||
const ids = [...new Set((userIds || []).filter(Boolean).map(String))]
|
||||
if (!ids.length) return {}
|
||||
|
||||
const activeStories = await StoryModel.find({ user_id: { $in: ids }, expires_at: { $gt: new Date() } })
|
||||
.select('user_id viewers')
|
||||
.lean()
|
||||
|
||||
const storiesByAuthor = {}
|
||||
activeStories.forEach((s) => {
|
||||
const key = String(s.user_id)
|
||||
if (!storiesByAuthor[key]) storiesByAuthor[key] = []
|
||||
storiesByAuthor[key].push(s)
|
||||
})
|
||||
|
||||
const flags = {}
|
||||
for (const id of Object.keys(storiesByAuthor)) {
|
||||
const stories = storiesByAuthor[id]
|
||||
const hasUnviewed = stories.some(
|
||||
(s) => !s.viewers?.some((v) => String(v.user_id) === String(viewerId))
|
||||
)
|
||||
flags[id] = {
|
||||
has_story: true,
|
||||
has_unviewed_story: viewerId ? hasUnviewed : true
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
module.exports = { resolveStoryFlags }
|
||||
@@ -1,5 +1,14 @@
|
||||
const NotificationModel = require('../models/NotificationModel')
|
||||
const UserModel = require('../models/UserModel')
|
||||
const { notifyUser } = require('./notifyUser')
|
||||
const { buildStorageUrl } = require('./storageUrl')
|
||||
|
||||
// همون الگوی services/*Cron.js — چون این تابع مستقیم از کنترلر صدا زده میشه (نه یه
|
||||
// جاب زمانبندیشده)، io از index.js با همین setIo تزریق میشه تا notifyUser بتونه
|
||||
// رویداد سوکت لحظهای هم بفرسته، نه فقط ردیف دیتابیس/پوش
|
||||
let ioInstance = null
|
||||
function setIo (io) {
|
||||
ioInstance = io
|
||||
}
|
||||
|
||||
const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
|
||||
try {
|
||||
@@ -10,19 +19,20 @@ const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
|
||||
)
|
||||
if (!uniqueIds.length) return
|
||||
|
||||
const tagger = await UserModel.findById(taggerId).select('user_name')
|
||||
const tagger = await UserModel.findById(taggerId).select('user_name profile_image')
|
||||
const taggerUsername = tagger?.user_name || 'کاربر'
|
||||
const message = `${taggerUsername} شما را در پستی نشانهگذاری کرد.`
|
||||
const taggerImage = buildStorageUrl(tagger?.profile_image) || undefined
|
||||
|
||||
await Promise.all(
|
||||
uniqueIds.map((userId) =>
|
||||
NotificationModel.create({
|
||||
user_id: userId,
|
||||
notifyUser(ioInstance, userId, {
|
||||
type: 'post_tag',
|
||||
title: taggerUsername,
|
||||
description: 'در پستی از شما نام برد',
|
||||
project_post_id: postId,
|
||||
actor_id: taggerId,
|
||||
type: 'post_tag',
|
||||
title: message,
|
||||
description: message
|
||||
image: taggerImage,
|
||||
link: `/posts/${postId}`
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -32,5 +42,6 @@ const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTagNotifications
|
||||
createTagNotifications,
|
||||
setIo
|
||||
}
|
||||
|
||||
37
utils/vpnCheck.js
Normal file
37
utils/vpnCheck.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const axios = require('axios')
|
||||
|
||||
// ip-api.com (رایگان، بدون نیاز به API key) — fields=proxy هم پراکسی/VPNِ شناختهشده و
|
||||
// هم هاستینگ/دیتاسنتر (که معمولاً پشتِ VPNهای تجاریه) رو با یه پرچم برمیگردونه.
|
||||
// روی IPِ لوکال/خصوصی (توسعهی محلی) status:"fail" برمیگرده که باید نادیده گرفته بشه
|
||||
// (نباید هشدار غلط نشون بده)
|
||||
const IP_API_BASE = 'http://ip-api.com/json'
|
||||
const ALLOWED_COUNTRY_CODE = 'IR'
|
||||
|
||||
/**
|
||||
* @param {string} ip
|
||||
* @returns {Promise<{ shouldWarn: boolean, countryCode: string|null, isProxy: boolean }>}
|
||||
*/
|
||||
async function checkClientRegion (ip) {
|
||||
const fallback = { shouldWarn: false, countryCode: null, isProxy: false }
|
||||
if (!ip) return fallback
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${IP_API_BASE}/${encodeURIComponent(ip)}`,
|
||||
{ params: { fields: 'status,message,countryCode,proxy,hosting,query' }, timeout: 4000 }
|
||||
)
|
||||
|
||||
if (!data || data.status !== 'success') return fallback
|
||||
|
||||
const isProxy = Boolean(data.proxy) || Boolean(data.hosting)
|
||||
const countryCode = data.countryCode || null
|
||||
const shouldWarn = isProxy || (countryCode != null && countryCode !== ALLOWED_COUNTRY_CODE)
|
||||
|
||||
return { shouldWarn, countryCode, isProxy }
|
||||
} catch (error) {
|
||||
console.error('checkClientRegion error:', error.message)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkClientRegion }
|
||||
Reference in New Issue
Block a user