Files
modstagram-back/utils/adDelivery.js
2026-09-24 21:03:27 +03:30

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
}