256 lines
10 KiB
JavaScript
256 lines
10 KiB
JavaScript
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
|
|
}
|