This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

View File

@@ -2,7 +2,8 @@
/* eslint-disable camelcase */
const jwt = require('jsonwebtoken')
const { check, validationResult } = require('express-validator')
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
const { billboardPublicStatusFilter } = require('../../../utils/moderationStatus')
const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity')
const UserModel = require('../../../models/UserModel')
const AdvertisingModel = require('../../../models/AdvertisingModel')
const fs = require('fs-extra')
@@ -12,13 +13,41 @@ const AdvertisingCategoryModel = require('../../../models/AdvertisingCategoryMod
const AdvertisingFeaturesModel = require('../../../models/AdvertisingFeaturesModel')
const AdvertisingLikeModel = require('../../../models/AdvertisingLikeModel')
const AdvertisingComment = require('../../../models/AdvertisingCommentModel')
const { resolveCommentModerationStatus } = require('../../../utils/contentModeration')
const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave')
const { commentStatusFilter } = require('../../../utils/moderationStatus')
const AdvertisingRatingModel = require('../../../models/AdvertisingRatingModel')
const AdvertisingProfileModel = require('../../../models/AdvertisingProfile')
const { createLikeNotification } = require('../../../utils/likeNotification')
const PaymentModel = require('../../../models/PaymentModel')
const { createLikeNotification, resolveAdvertisingOwnerId } = require('../../../utils/likeNotification')
const {
createCommentNotification,
createRatingNotification
} = require('../../../utils/commentNotification')
const { buildUnifiedInterestScore, blendFreshIntoRanked } = require('../../../utils/exploreAlgorithm')
/** Billboards matching the viewer's cross-content interest profile, for blending into the default (unsorted, unfiltered) listing. */
async function getInterestMatchedAds (userId, filter, excludeIds, limit = 5) {
if (!userId) return []
const interestWeights = await buildUnifiedInterestScore(userId)
const tags = Object.keys(interestWeights).filter((tag) => interestWeights[tag] > 0)
if (!tags.length) return []
const interestCategories = await AdvertisingCategoryModel.find({ relatedExpertise: { $in: tags } })
.select('title')
.lean()
const categoryTitles = interestCategories.map((c) => c.title)
if (!categoryTitles.length) return []
return AdvertisingModel.find({
...filter,
category: { $in: categoryTitles },
_id: { $nin: excludeIds }
})
.sort({ createdAt: -1 })
.limit(limit)
.lean()
}
const createAdvertisingValidationRules = () => {
return [
@@ -66,6 +95,7 @@ const createAdvertising = async (req, res, next) => {
category,
title,
description,
country,
province,
city,
neighbourhood,
@@ -84,6 +114,7 @@ const createAdvertising = async (req, res, next) => {
return res.status(422).json({ error: true, message: 'اطلاعات ارسالی اشتباه است' })
}
const countryFind = country ? await CountryModel.findOne({ id: country }, { _id: 0 }) : null
const provinceFind = await ProvinceModel.findOne({ id: province }, { _id: 0 })
const cityFind = await CityModel.findOne({ id: city })
@@ -131,6 +162,7 @@ const createAdvertising = async (req, res, next) => {
title,
category,
description,
country: countryFind ? { id: countryFind.id, name: countryFind.name_fa, iso2: countryFind.iso2, slug: countryFind.slug } : null,
province: provinceFind,
city: cityFind,
neighbourhood,
@@ -258,19 +290,16 @@ const createAdvertising = async (req, res, next) => {
// }
// }
const getAdvertisings = async (req, res, next) => {
let previousPage = 0 // تعریف متغیر سراسری برای نگهداری شماره صفحه قبلی
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const { page = 1, limit = 10, province, city, category, search, sort } = req.query
const currentPage = parseInt(page) // شماره صفحه جاری
const filter = {
payment_status: 'done',
status: 'accepted'
status: billboardPublicStatusFilter()
}
if (province) {
const provinceFind = await ProvinceModel.findOne({ id: province }, { _id: 0 })
@@ -310,21 +339,13 @@ const getAdvertisings = async (req, res, next) => {
}
const advertisings = await AdvertisingModel.paginate(filter, options)
// اگر شماره صفحه تغییر کرده بود، تعداد بازدیدها را به روز رسانی کنید
if (currentPage !== previousPage) {
await AdvertisingModel.updateMany(
{ _id: { $in: advertisings.docs.map(ad => ad._id) } },
{ $inc: { viewCount: 1 } }
)
previousPage = currentPage // به‌روزرسانی شماره صفحه قبلی
}
// دریافت لیست لایک هر ویترین و اضافه کردن آن به اطلاعات تبلیغات
const advertisingIds = advertisings.docs.map(ad => ad._id)
const advertisingLikes = await AdvertisingLikeModel.find({ advertisingId: { $in: advertisingIds } })
const creatorIds = advertisings.docs.map(ad => ad.creator_id)
const creators = await AdvertisingProfileModel.find({ user: { $in: creatorIds } })
const newAds = advertisings.docs.map(ad => {
let newAds = advertisings.docs.map(ad => {
const creator = creators.find(user => user.user.toString() === ad.creator_id.toString())
const likedByUser = advertisingLikes.some(like => like.advertisingId.toString() === ad._id.toString() && like.userId.toString() === decodedToken.id)
return {
@@ -343,12 +364,48 @@ const getAdvertisings = async (req, res, next) => {
commentsCount: ad._doc.commentsCount,
showDiscount: ad._doc.showDiscount,
viewCount: ad._doc.viewCount,
createdAt: ad._doc.createdAt,
rate: {
adTotalRatings: creator?.adTotalRatings,
adAverageRating: creator?.adAverageRating
}
}
})
const isDefaultBrowsePath = !sort && !category && !search && parseInt(page) === 1
if (isDefaultBrowsePath) {
try {
const interestAds = await getInterestMatchedAds(decodedToken.id, filter, newAds.map((a) => a._id))
if (interestAds.length) {
const interestCreators = await AdvertisingProfileModel.find({ user: { $in: interestAds.map((ad) => ad.creator_id) } })
const mappedInterestAds = interestAds.map((ad) => {
const creator = interestCreators.find((user) => user.user.toString() === ad.creator_id.toString())
return {
_id: ad._id,
category: ad.category,
title: ad.title,
province: ad.province,
city: ad.city,
neighbourhood: ad.neighbourhood,
type: ad.type,
mostDiscountPercentage: ad.mostDiscountPercentage,
images: ad.images,
likedByUser: false,
likesCount: ad.likesCount,
commentsCount: ad.commentsCount,
showDiscount: ad.showDiscount,
viewCount: ad.viewCount,
createdAt: ad.createdAt,
rate: { adTotalRatings: creator?.adTotalRatings, adAverageRating: creator?.adAverageRating }
}
})
newAds = blendFreshIntoRanked(newAds, [...newAds, ...mappedInterestAds], 0.25).slice(0, parseInt(limit))
}
} catch {
/* non-blocking: personalization is a bonus, never break the listing */
}
}
res.status(200).json({
advertisings: newAds,
totalPages: advertisings.totalPages,
@@ -360,7 +417,6 @@ const getAdvertisings = async (req, res, next) => {
}
const getAdvertisingsWeb = async (req, res, next) => {
let previousPage = 0
let userId = null
try {
@@ -375,11 +431,9 @@ const getAdvertisingsWeb = async (req, res, next) => {
}
const { page = 1, limit = 10, province, city, category, search, sort } = req.query
const currentPage = parseInt(page)
const filter = {
// payment_status: 'done',
status: 'accepted'
status: billboardPublicStatusFilter(),
}
if (province) {
const provinceFind = await ProvinceModel.findOne({ id: province }, { _id: 0 })
@@ -418,14 +472,6 @@ const getAdvertisingsWeb = async (req, res, next) => {
}
const advertisings = await AdvertisingModel.paginate(filter, options)
if (currentPage !== previousPage) {
await AdvertisingModel.updateMany(
{ _id: { $in: advertisings.docs.map(ad => ad._id) } },
{ $inc: { viewCount: 1 } }
)
previousPage = currentPage
}
const advertisingIds = advertisings.docs.map(ad => ad._id)
const creatorIds = advertisings.docs.map(ad => ad.creator_id)
const creators = await AdvertisingProfileModel.find({ user: { $in: creatorIds } })
@@ -435,7 +481,7 @@ const getAdvertisingsWeb = async (req, res, next) => {
advertisingLikes = await AdvertisingLikeModel.find({ advertisingId: { $in: advertisingIds }, userId })
}
const newAds = advertisings.docs.map(ad => {
let newAds = advertisings.docs.map(ad => {
const creator = creators.find(user => user.user.toString() === ad.creator_id.toString())
const likedByUser = userId ? advertisingLikes.some(like => like.advertisingId.toString() === ad._id.toString()) : false
@@ -454,6 +500,7 @@ const getAdvertisingsWeb = async (req, res, next) => {
commentsCount: ad._doc.commentsCount,
showDiscount: ad._doc.showDiscount,
viewCount: ad._doc.viewCount,
createdAt: ad._doc.createdAt,
rate: {
adTotalRatings: creator?.adTotalRatings,
adAverageRating: creator?.adAverageRating
@@ -461,6 +508,40 @@ const getAdvertisingsWeb = async (req, res, next) => {
}
})
const isDefaultBrowsePath = !sort && !category && !search && parseInt(page) === 1
if (isDefaultBrowsePath && userId) {
try {
const interestAds = await getInterestMatchedAds(userId, filter, newAds.map((a) => a._id))
if (interestAds.length) {
const interestCreators = await AdvertisingProfileModel.find({ user: { $in: interestAds.map((ad) => ad.creator_id) } })
const mappedInterestAds = interestAds.map((ad) => {
const creator = interestCreators.find((user) => user.user.toString() === ad.creator_id.toString())
return {
_id: ad._id,
category: ad.category,
title: ad.title,
province: ad.province,
city: ad.city,
neighbourhood: ad.neighbourhood,
type: ad.type,
mostDiscountPercentage: ad.mostDiscountPercentage,
images: ad.images,
likedByUser: false,
likesCount: ad.likesCount,
commentsCount: ad.commentsCount,
showDiscount: ad.showDiscount,
viewCount: ad.viewCount,
createdAt: ad.createdAt,
rate: { adTotalRatings: creator?.adTotalRatings, adAverageRating: creator?.adAverageRating }
}
})
newAds = blendFreshIntoRanked(newAds, [...newAds, ...mappedInterestAds], 0.25).slice(0, parseInt(limit))
}
} catch {
/* non-blocking: personalization is a bonus, never break the listing */
}
}
res.status(200).json({
advertisings: newAds,
totalPages: advertisings.totalPages,
@@ -471,6 +552,29 @@ const getAdvertisingsWeb = async (req, res, next) => {
}
}
const recordAdvertisingView = async (req, res, next) => {
try {
const { advertisingId } = req.params
const advertising = await AdvertisingModel.findByIdAndUpdate(
advertisingId,
{ $inc: { viewCount: 1 } },
{ new: true }
).select('viewCount status')
if (!advertising) {
return res.status(404).json({ message: 'ویترین مورد نظر یافت نشد' })
}
if (advertising.status !== 'accepted') {
return res.status(404).json({ message: 'ویترین مورد نظر یافت نشد' })
}
res.status(200).json({ viewCount: advertising.viewCount ?? 0 })
} catch (error) {
next(error)
}
}
const getSingleAdvertising = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
@@ -492,7 +596,7 @@ const getSingleAdvertising = async (req, res, next) => {
const likedByUser = await AdvertisingLikeModel.findOne({ advertisingId, userId })
// دریافت تعداد کامنت‌ها
const commentsCount = await AdvertisingComment.countDocuments({ advertisingId, status: 'accepted' })
const commentsCount = await AdvertisingComment.countDocuments({ advertisingId, status: commentStatusFilter() })
const creator = await AdvertisingProfileModel.findOne({ user: advertising.creator_id._id })
// اضافه کردن تعداد لایک‌ها، تعداد کامنت‌ها و وضعیت لایک به تبلیغ
@@ -546,7 +650,7 @@ const getSingleAdvertisingWeb = async (req, res, next) => {
const likesCount = await AdvertisingLikeModel.countDocuments({ advertisingId })
// دریافت تعداد کامنت‌ها
const commentsCount = await AdvertisingComment.countDocuments({ advertisingId, status: 'accepted' })
const commentsCount = await AdvertisingComment.countDocuments({ advertisingId, status: commentStatusFilter() })
// دریافت اطلاعات سازنده تبلیغ
const creator = await AdvertisingProfileModel.findOne({ user: advertising.creator_id._id })
@@ -705,7 +809,8 @@ const toggleLike = async (req, res, next) => {
await advertising.save()
await createLikeNotification({
ownerId: advertising.creator_id,
io: req.app.get('io'),
ownerId: resolveAdvertisingOwnerId(advertising),
likerId: userId,
entityId: advertising._id,
type: 'billboard_like'
@@ -733,18 +838,21 @@ const addComment = async (req, res, next) => {
// message: 'مدارک شما تایید نشده است'
// })
// }
const newComment = new AdvertisingComment({
advertisingId,
userId,
text
})
await newComment.save()
const newComment = await saveCommentWithModerationStatus(
AdvertisingComment,
{
advertisingId,
userId,
text,
},
'text'
)
const advertising = await AdvertisingModel.findById(advertisingId)
if (advertising) {
await createCommentNotification({
ownerId: advertising.creator_id,
io: req.app.get('io'),
ownerId: resolveAdvertisingOwnerId(advertising),
commenterId: userId,
entityId: advertising._id,
type: 'billboard_comment'
@@ -753,6 +861,10 @@ const addComment = async (req, res, next) => {
res.status(201).json({ message: 'کامنت با موفقیت اضافه شد', comment: newComment })
} catch (error) {
if (error?.name === 'ValidationError') {
console.error('addComment validation:', error.message)
return res.status(500).json({ message: 'خطا در ثبت کامنت. مدل کامنت روی سرور به‌روز نیست.' })
}
next(error)
}
}
@@ -763,10 +875,10 @@ const getComments = async (req, res, next) => {
page: parseInt(page),
limit: parseInt(limit),
sort: { createdAt: -1 },
populate: [{ path: 'userId', select: '_id profile_image user_type user_name first_name last_name is_verified' }]
populate: [{ path: 'userId', select: '_id profile_image user_type user_name first_name last_name is_verified verify_badge' }]
}
const comments = await AdvertisingComment.paginate({ advertisingId, status: 'accepted' }, options)
const comments = await AdvertisingComment.paginate({ advertisingId, status: commentStatusFilter() }, options)
res.status(200).json({
comments: comments.docs,
totalPages: comments.totalPages,
@@ -806,7 +918,8 @@ const rateAdvertising = async (req, res, next) => {
await newRating.save()
await createRatingNotification({
ownerId: advertising.creator_id,
io: req.app.get('io'),
ownerId: resolveAdvertisingOwnerId(advertising),
raterId: userId,
entityId: advertising._id,
type: 'billboard_rating'
@@ -939,6 +1052,91 @@ const getUserAdvertisings = async (req, res, next) => {
next(error)
}
}
const ADS_ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null }
/** Revenue-over-time + top billboards + cumulative view/like/comment totals for "Billboards" analytics. */
const getAdvertisingAnalytics = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const range = ADS_ANALYTICS_RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '30d'
const days = ADS_ANALYTICS_RANGE_DAYS[range]
const since = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : new Date(0)
const myAds = await AdvertisingModel.find({ creator_id: userId })
.select('title images viewCount likesCount commentsCount averageRating ratingsCount')
const adIds = myAds.map((ad) => ad._id)
if (!adIds.length) {
return res.json({
range,
series: [],
totals: { totalRevenue: 0, totalCount: 0, totalViews: 0, totalLikes: 0, totalComments: 0 },
topItems: [],
rating: { average: 0, count: 0 }
})
}
const [series, totalsAgg] = await Promise.all([
PaymentModel.aggregate([
{ $match: { advertising_id: { $in: adIds }, status: 'successful', createdAt: { $gte: since } } },
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } },
revenue: { $sum: '$amount' },
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
]),
PaymentModel.aggregate([
{ $match: { advertising_id: { $in: adIds }, status: 'successful', createdAt: { $gte: since } } },
{ $group: { _id: null, totalRevenue: { $sum: '$amount' }, totalCount: { $sum: 1 } } }
])
])
const totalViews = myAds.reduce((sum, ad) => sum + (ad.viewCount || 0), 0)
const totalLikes = myAds.reduce((sum, ad) => sum + (ad.likesCount || 0), 0)
const totalComments = myAds.reduce((sum, ad) => sum + (ad.commentsCount || 0), 0)
const ratedAds = myAds.filter((ad) => ad.ratingsCount > 0)
const averageRating = ratedAds.length
? ratedAds.reduce((sum, ad) => sum + (ad.averageRating || 0), 0) / ratedAds.length
: 0
const totalRatingsCount = myAds.reduce((sum, ad) => sum + (ad.ratingsCount || 0), 0)
const topItems = [...myAds]
.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0))
.slice(0, 5)
.map((ad) => ({
id: String(ad._id),
title: ad.title,
image: ad.images?.[0] || null,
metric: ad.viewCount || 0
}))
return res.json({
range,
series,
totals: {
totalRevenue: totalsAgg[0]?.totalRevenue || 0,
totalCount: totalsAgg[0]?.totalCount || 0,
totalViews,
totalLikes,
totalComments
},
topItems,
rating: { average: averageRating, count: totalRatingsCount }
})
} catch (error) {
next(error)
}
}
const getUserSingleAdvertising = async (req, res, next) => {
try {
const token = req.header('Authorization').split(' ')[1]
@@ -966,7 +1164,7 @@ const getUserSingleAdvertising = async (req, res, next) => {
const likedByUser = await AdvertisingLikeModel.findOne({ advertisingId, userId })
// دریافت تعداد کامنت‌ها
const commentsCount = await AdvertisingComment.countDocuments({ advertisingId, status: 'accepted' })
const commentsCount = await AdvertisingComment.countDocuments({ advertisingId, status: commentStatusFilter() })
const creator = await AdvertisingProfileModel.findOne({ user: advertising.creator_id._id })
// اضافه کردن تعداد لایک‌ها، تعداد کامنت‌ها و وضعیت لایک به تبلیغ
@@ -1020,6 +1218,7 @@ const editAdvertising = async (req, res, next) => {
category,
title,
description,
country,
province,
city,
neighbourhood,
@@ -1054,6 +1253,7 @@ const editAdvertising = async (req, res, next) => {
})
}
const countryFind = country ? await CountryModel.findOne({ id: country }, { _id: 0 }) : null
const provinceFind = await ProvinceModel.findOne({ id: province }, { _id: 0 })
const cityFind = await CityModel.findOne({ id: city })
@@ -1121,6 +1321,9 @@ const editAdvertising = async (req, res, next) => {
advertising.title = title
advertising.category = category
advertising.description = description
if (countryFind) {
advertising.country = { id: countryFind.id, name: countryFind.name_fa, iso2: countryFind.iso2, slug: countryFind.slug }
}
advertising.province = provinceFind
advertising.city = cityFind
advertising.neighbourhood = neighbourhood
@@ -1419,6 +1622,77 @@ const updateUserAdvertisingProfile = async (req, res, next) => {
}
}
const getUserLikedAdvertisings = async (req, res, next) => {
try {
const token = req.header('Authorization')?.split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
const skip = (page - 1) * limit
const likes = await AdvertisingLikeModel.find({ userId })
.populate('advertisingId')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
const total = await AdvertisingLikeModel.countDocuments({ userId })
res.status(200).json({
success: true,
data: likes,
pagination: {
currentPage: page,
totalPages: Math.ceil(total / limit),
totalItems: total,
itemsPerPage: limit
}
})
} catch (error) {
console.error('Error in getUserLikedAdvertisings:', error)
res.status(500).json({ success: false, message: 'خطا در دریافت بیلبوردهای لایک شده', error: error.message })
}
}
const getUserAdvertisingComments = async (req, res, next) => {
try {
const token = req.header('Authorization')?.split(' ')[1]
if (!token) return res.status(401).send('Access Denied')
const decodedToken = jwt.verify(token, process.env.APP_SECRET)
const userId = decodedToken.id
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
const skip = (page - 1) * limit
const [comments, total] = await Promise.all([
AdvertisingComment.find({ userId })
.populate('advertisingId')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit),
AdvertisingComment.countDocuments({ userId })
])
res.status(200).json({
success: true,
data: comments,
pagination: {
currentPage: page,
totalPages: Math.ceil(total / limit),
totalItems: total,
itemsPerPage: limit
}
})
} catch (error) {
console.error('Error in getUserAdvertisingComments:', error)
res.status(500).json({ success: false, message: 'خطا در دریافت نظرات بیلبورد', error: error.message })
}
}
module.exports = {
createAdvertisingValidationRules,
createAdvertising,
@@ -1432,11 +1706,15 @@ module.exports = {
getComments,
rateAdvertising,
getUserAdvertisings,
getAdvertisingAnalytics,
editAdvertising,
getUserSingleAdvertising,
getUserAdvertisingProfile,
updateUserAdvertisingProfile,
getAdvertisingsWeb,
getSingleAdvertisingWeb,
getUserAdvertisingProfileWeb
getUserAdvertisingProfileWeb,
recordAdvertisingView,
getUserLikedAdvertisings,
getUserAdvertisingComments
}