This commit is contained in:
payacom
2026-09-24 21:03:27 +03:30
parent 7f0ddfde70
commit 806471241e
43 changed files with 2166 additions and 144 deletions

View 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
}

View File

@@ -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))
}