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 }