full app
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user