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

@@ -1,7 +1,15 @@
const NotificationModel = require("../models/NotificationModel");
const UserModel = require("../models/UserModel");
const { notifyUser } = require("./notifyUser");
const { buildStorageUrl } = require("./storageUrl");
const PURCHASE_TITLE = "پکیج شما خریداری شد";
const PURCHASE_DESCRIPTION = "یک کاربر پکیج آموزشی شما را خریداری کرد";
const PURCHASE_DESCRIPTION = "دوره‌ی آموزشی شما را خریداری کرد";
// همون الگوی tagNotification.js/notifyFollowers.js — چون این تابع مستقیم از کنترلر
// صدا زده می‌شه (نه یه جاب زمان‌بندی‌شده)، io از index.js با همین setIo تزریق می‌شه
let ioInstance = null;
function setIo(io) {
ioInstance = io;
}
const createCoursePurchaseNotification = async ({
ownerId,
@@ -12,12 +20,19 @@ const createCoursePurchaseNotification = async ({
if (!ownerId || !buyerId || !courseId) return;
if (String(ownerId) === String(buyerId)) return;
await NotificationModel.create({
user_id: ownerId,
project_post_id: courseId,
const buyer = await UserModel.findById(buyerId).select(
"user_name profile_image"
);
const buyerUsername = buyer?.user_name || "کاربر";
const buyerImage = buildStorageUrl(buyer?.profile_image) || undefined;
await notifyUser(ioInstance, ownerId, {
type: "academy_purchase",
title: PURCHASE_TITLE,
title: buyerUsername,
description: PURCHASE_DESCRIPTION,
project_post_id: courseId,
actor_id: buyerId,
image: buyerImage,
});
} catch (error) {
console.error("Error creating course purchase notification:", error);
@@ -26,4 +41,5 @@ const createCoursePurchaseNotification = async ({
module.exports = {
createCoursePurchaseNotification,
setIo,
};

255
utils/adDelivery.js Normal file
View File

@@ -0,0 +1,255 @@
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
}

39
utils/adPricing.js Normal file
View File

@@ -0,0 +1,39 @@
const AdPricingRuleModel = require('../models/AdPricingRuleModel')
/**
* دقیق‌ترین قانونِ منطبق رو پیدا می‌کنه — یه قانون منطبقه اگه هر فیلدِ غیرِnullش دقیقاً با
* سناریوی داده‌شده یکی باشه (فیلدِ null همیشه صدق می‌کنه، یعنی وایلدکارت). بینِ قانون‌های
* منطبق، اونی که بیشترین فیلدِ غیرِnull (دقیق‌ترین) رو داره برنده‌ست.
* gender/expertise/provinceId که خودِ کاربر انتخاب نکرده (یعنی «هرکسی»/«فرقی نمی‌کنه») باید
* null پاس داده بشه، نه رشته‌ی خالی — وگرنه هیچ قانونِ وایلدکاردی هم منطبق نمی‌شه.
* subExpertise آرایه‌ست (تبلیغ می‌تونه چند زیرتخصص هدف‌گذاری کنه) — قانونِ subExpertise
* وقتی منطبقه که همون یکی مقدارش توی این آرایه باشه
*/
async function resolveAdPrice (adType, { provinceId, gender, expertise, subExpertise } = {}) {
const rules = await AdPricingRuleModel.find({ ad_type: adType }).lean()
const subExpertiseList = Array.isArray(subExpertise) ? subExpertise : []
let best = null
let bestScore = -1
for (const rule of rules) {
if (rule.province_id != null && rule.province_id !== provinceId) continue
if (rule.gender != null && rule.gender !== gender) continue
if (rule.expertise != null && rule.expertise !== expertise) continue
if (rule.sub_expertise != null && !subExpertiseList.includes(rule.sub_expertise)) continue
const score =
(rule.province_id != null ? 1 : 0) +
(rule.gender != null ? 1 : 0) +
(rule.expertise != null ? 1 : 0) +
(rule.sub_expertise != null ? 1 : 0)
if (score > bestScore) {
bestScore = score
best = rule
}
}
return best ? best.price : null
}
module.exports = { resolveAdPrice }

View File

@@ -0,0 +1,55 @@
const AutoDirectRuleModel = require('../models/AutoDirectRuleModel')
const CommentModel = require('../models/CommentModel')
const MessageModel = require('../models/MessageModel')
const { emitChatEvents, enrichMessage } = require('../helpers/chatHelpers')
// دایرکت اتوماتیک: وقتی کسی زیر پست یه کاربر، یکی از متن‌های تریگرِ خودش رو کامنت می‌کنه،
// بدون نیاز به هیچ تاییدی هم یه دایرکت براش می‌فرسته هم زیرِ همون کامنت جواب می‌ده.
// content پیامِ دایرکت رو عینِ sharedPost (engagementController.js) plain-text ذخیره می‌کنیم —
// چون خودِ سرور ساخته، نه متنِ رمزنگاری‌شدهٔ کاربر، محدودیتِ E2EE (chatHelpers.js) اینجا صدق نمی‌کنه
async function runAutoDirectRules ({ io, postOwnerId, commenterId, commentId, postId, commentText }) {
try {
if (!postOwnerId || !commenterId || !postId || !commentText) return
if (String(postOwnerId) === String(commenterId)) return
const rules = await AutoDirectRuleModel.find({ user_id: postOwnerId, is_active: true }).lean()
if (!rules.length) return
const normalized = String(commentText).toLowerCase()
const matched = rules.find(
(r) => r.trigger_text && normalized.includes(String(r.trigger_text).toLowerCase().trim())
)
if (!matched) return
const newMessage = new MessageModel({
senderId: postOwnerId,
receiverId: commenterId,
content: matched.direct_message
})
await newMessage.save()
if (io) {
const formatted = await enrichMessage(newMessage)
emitChatEvents(io, formatted, postOwnerId, commenterId, {
type: 'chat_message',
description: String(matched.direct_message).slice(0, 40)
})
}
await CommentModel.create({
user: postOwnerId,
creator: postOwnerId,
comment_for: 'post',
post: postId,
parent: commentId,
comment: matched.reply_caption,
status: 'accepted'
})
await AutoDirectRuleModel.updateOne({ _id: matched._id }, { $inc: { sent_count: 1 } })
} catch (error) {
console.error('Error running auto-direct rules:', error)
}
}
module.exports = { runAutoDirectRules }

View File

@@ -8,20 +8,30 @@ const { sendPushToUser } = require('./pushNotification')
* همچنین یه پوش FCM هم برای گوشی کاربر می‌فرسته — این تنها نقطه‌ایه که باعث میشه
* همه‌ی نوتیفیکیشن‌هایی که از این تابع رد میشن به‌صورت پوش هم نمایش داده بشن
*/
async function notifyUser (io, userId, { type, title, description, project_post_id, actor_id } = {}) {
await NotificationModel.create({ user_id: userId, type, title, description, project_post_id, actor_id })
async function notifyUser (io, userId, { type, title, description, project_post_id, actor_id, image, link, actorUsername } = {}) {
await NotificationModel.create({ user_id: userId, type, title, description, project_post_id, actor_id, image })
if (io) {
try {
io.to(`user:${String(userId)}`).emit('notification', { type, title, description })
io.to(`user:${String(userId)}`).emit('notification', { type, title, description, image })
} catch (_) {}
}
const data = {}
if (project_post_id) data.project_post_id = String(project_post_id)
// مسیرِ نسبیِ آماده (وقتی خودِ فراخوان از قبل username/id لازم رو داره) — سرویس‌ورکرِ
// وب دیگه لازم نیست خودش لوکاپِ اضافه بزنه تا بفهمه دقیقاً کجا باز کنه
if (link) data.link = link
// یوزرنیمِ عاملِ نوتیف (مثلاً صاحبِ استوری برای story_mention) — اندروید بدونِ این
// مجبور بود یه درخواستِ اضافه بزنه تا از رویِ آیدی به یوزرنیم برسه
if (actorUsername) data.actorUsername = actorUsername
sendPushToUser(userId, {
title,
description,
type,
data: project_post_id ? { project_post_id: String(project_post_id) } : undefined
data: Object.keys(data).length ? data : undefined,
image
}).catch(() => {})
}

View File

@@ -34,18 +34,33 @@ function getFirebaseApp () {
}
}
/** ارسال پوش به یه لیست توکن مشخص — پایه‌ی مسیرهای دسته‌جمعی (فالوورها، پیام‌های همگانی پنل) */
async function sendPushToTokens (tokens, { title, description, type, data, image } = {}) {
/**
* ارسال پوش به یه لیست توکن مشخص — پایه‌ی مسیرهای دسته‌جمعی (فالوورها، پیام‌های همگانی پنل)
* dataOnly: اگه true باشه، هیچ بلاک notification فرستاده نمی‌شه — فقط data (رشته‌ای‌شده).
* دلیلش: react-native-firebase وقتی پیام یه notification داشته باشه، خودِ سیستم‌عامل
* (نه کدِ اپ) پیش‌فرض نمایشش می‌ده و setBackgroundMessageHandler برای اون پیام اصلاً صدا
* زده نمی‌شه — یعنی هیچ‌وقت نمی‌شه آیکونِ بزرگ (آواتار) رو ازش کنترل کرد. فقط برای
* توکن‌های موبایل (sendPushToUser) استفاده می‌شه؛ وب دست‌نخورده می‌مونه (already working)
*/
async function sendPushToTokens (tokens, { title, description, type, data, image, dataOnly } = {}) {
const app = getFirebaseApp()
const cleanTokens = (tokens || []).filter(Boolean)
if (!app || cleanTokens.length === 0) return { successCount: 0, invalidTokens: [] }
const dataPayload = { type, ...data }
if (dataOnly) {
// notifee (index.js پس‌زمینه/foreground) از همین فیلدها نوتیف غنی می‌سازه
dataPayload.title = title || ''
dataPayload.description = description || ''
if (image) dataPayload.image = image
}
const message = {
// notification.image: عکس/آواتار نمایش داده‌شده توی خودِ نوتیف (اندروید: بزرگ‌آیکون/
// big-picture، iOS: پیوست) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام
notification: { title, body: description, ...(image ? { image } : {}) },
// notification.image: عکس/آواتار نمایش داده‌شده توی خودِ نوتیف (وب/آی‌اواس، و اندرویدِ
// dataOnly:false) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام
...(dataOnly ? {} : { notification: { title, body: description, ...(image ? { image } : {}) } }),
data: Object.fromEntries(
Object.entries({ type, ...data }).map(([k, v]) => [k, String(v ?? '')])
Object.entries(dataPayload).map(([k, v]) => [k, String(v ?? '')])
),
tokens: cleanTokens
}
@@ -73,21 +88,34 @@ async function sendPushToTokens (tokens, { title, description, type, data, image
return { successCount, invalidTokens }
}
/** ارسال پوش به یک کاربر مشخص — توکن‌هاش رو از دیتابیس می‌خونه و توکن‌های نامعتبر رو پاک می‌کنه */
/**
* ارسال پوش به یک کاربر مشخص — توکن‌هاش رو از دیتابیس می‌خونه و توکن‌های نامعتبر رو پاک می‌کنه.
* موبایل و وب جدا ارسال می‌شن: موبایل dataOnly (نوتیفِ غنی رو notifee توی index.js/App
* می‌سازه)، وب عین قبل با بلاک notification (رفتارِ فعلاً درستِ وب دست‌نخورده می‌مونه)
*/
async function sendPushToUser (userId, { title, description, type, data, image } = {}) {
try {
const user = await UserModel.findById(userId).select('fcm_tokens fcm_web_tokens').lean()
const tokens = [...(user?.fcm_tokens || []), ...(user?.fcm_web_tokens || [])]
if (tokens.length === 0) return
const mobileTokens = user?.fcm_tokens || []
const webTokens = user?.fcm_web_tokens || []
if (mobileTokens.length === 0 && webTokens.length === 0) return
const { invalidTokens } = await sendPushToTokens(tokens, { title, description, type, data, image })
if (invalidTokens.length > 0) {
const [mobileResult, webResult] = await Promise.all([
mobileTokens.length
? sendPushToTokens(mobileTokens, { title, description, type, data, image, dataOnly: true })
: Promise.resolve({ invalidTokens: [] }),
webTokens.length
? sendPushToTokens(webTokens, { title, description, type, data, image })
: Promise.resolve({ invalidTokens: [] })
])
if (mobileResult.invalidTokens.length > 0 || webResult.invalidTokens.length > 0) {
await UserModel.updateOne(
{ _id: userId },
{
$pull: {
fcm_tokens: { $in: invalidTokens },
fcm_web_tokens: { $in: invalidTokens }
fcm_tokens: { $in: mobileResult.invalidTokens },
fcm_web_tokens: { $in: webResult.invalidTokens }
}
}
)

View File

@@ -1,88 +1,91 @@
const mongoose = require('mongoose');
const CourseModel = require('../models/CourseModel');
const AcademyModel = require('../models/AcademyModel');
const ProjectModel = require('../models/ProjectModel');
const AdvertisingModel = require('../models/AdvertisingModel');
const ShopModel = require('../models/ShopModel');
const ShopProductListingModel = require('../models/ShopProductListingModel');
const BookingConfigModel = require('../models/BookingConfigModel');
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus');
const {
PUBLIC_COURSE_STATUSES,
PUBLIC_BILLBOARD_STATUSES,
PUBLIC_PROJECT_STATUSES,
} = require('./moderationStatus');
// اعتبارسنجی مشترک «آیتم قابل‌لینک‌شدن» — هم پست (linked_*_id) هم استوری (لینک‌اورلی) از
// همین یه تابع استفاده می‌کنن تا شرط مالکیت+وضعیت هرکدوم دوبار نوشته نشه. خروجی: شناسه‌ی
// معتبری که باید ذخیره بشه، یا null اگه کاربر مالک نیست/وضعیتش پذیرفته‌شده نیست.
async function resolveLinkedEntity(kind, id, userId) {
function sameId(a, b) {
if (a == null || b == null) return false;
const sa = typeof a === 'object' && a._id != null ? String(a._id) : String(a);
const sb = typeof b === 'object' && b._id != null ? String(b._id) : String(b);
return sa === sb;
}
// اعتبارسنجی مشترک «آیتم قابل‌لینک‌شدن»
// options.requirePublic (پیش‌فرض true): برای لینک داخل پست/استوری وضعیت عمومی لازم است.
// برای صفحه «اشتراک‌گذاری لینک» فقط مالکیت کافی است (requirePublic: false).
async function resolveLinkedEntity(kind, id, userId, options = {}) {
const requirePublic = options.requirePublic !== false;
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
switch (kind) {
case 'pkg': {
const course = await CourseModel.findById(id).select('_id user_id status').lean();
// با همون آستانه‌ی «عمومی قابل‌نمایش» که همه‌جای اپ برای پکیج آموزشی استفاده می‌شه
// (temp_accept هم شامل می‌شه) — نه فقط accept نهایی؛ وگرنه هر پکیجی که کاربر تازه
// ثبت کرده (وضعیت پیش‌فرضش temp_accept) توی پیکر قابل‌انتخاب بود ولی همیشه رد می‌شد
if (
course &&
String(course.user_id) === String(userId) &&
PUBLIC_COURSE_STATUSES.includes(course.status)
) {
return String(course._id);
const course = await CourseModel.findById(id)
.select('_id user_id academyId status')
.lean();
if (!course) return null;
if (requirePublic && !PUBLIC_COURSE_STATUSES.includes(course.status)) return null;
// مالکیت: user_id مستقیم روی دوره، یا مالک آکادمیِ دوره
if (sameId(course.user_id, userId)) return String(course._id);
if (course.academyId) {
const academy = await AcademyModel.findById(course.academyId).select('userId').lean();
if (academy && sameId(academy.userId, userId)) return String(course._id);
}
return null;
}
case 'project': {
const project = await ProjectModel.findById(id).select('_id creator_id status').lean();
// عین pkg/billboard — هر وضعیتِ واقعی/متعهدشده و غیرِ رد/لغوشده رو می‌پذیریم، نه فقط
// done/accepted؛ وگرنه تقریباً هیچ پروژه‌ی واقعی‌ای (که بیشترِ عمرش pending/ongoing/
// temp_accept هست، نه دقیقاً done/accepted) قابل‌لینک‌شدن نبود
if (
project &&
String(project.creator_id) === String(userId) &&
PUBLIC_PROJECT_STATUSES.includes(project.status)
) {
return String(project._id);
}
const project = await ProjectModel.findById(id)
.select('_id creator_id status')
.lean();
if (!project) return null;
if (requirePublic && !PUBLIC_PROJECT_STATUSES.includes(project.status)) return null;
if (sameId(project.creator_id, userId)) return String(project._id);
return null;
}
case 'billboard': {
const ad = await AdvertisingModel.findById(id).select('_id creator_id status').lean();
// عین pkg — همون آستانه‌ی «عمومی قابل‌نمایش» بیلبورد (شامل temp_accept/paid هم می‌شه)
if (
ad &&
String(ad.creator_id) === String(userId) &&
PUBLIC_BILLBOARD_STATUSES.includes(ad.status)
) {
return String(ad._id);
}
const ad = await AdvertisingModel.findById(id)
.select('_id creator_id status')
.lean();
if (!ad) return null;
if (requirePublic && !PUBLIC_BILLBOARD_STATUSES.includes(ad.status)) return null;
if (sameId(ad.creator_id, userId)) return String(ad._id);
return null;
}
case 'shop': {
const shop = await ShopModel.findById(id).select('_id owner status').lean();
// draft/rejected هنوز واقعاً وجود عمومی ندارن، ولی pending_review (ثبت‌شده، منتظرِ
// تایید) هم مثل بقیه‌ی موارد بالا باید قابل‌لینک باشه — فقط active بیش‌ازحد سخت‌گیرانه بود
if (
shop &&
String(shop.owner) === String(userId) &&
['active', 'pending_review'].includes(shop.status)
) {
return String(shop._id);
}
if (!shop) return null;
if (requirePublic && !['active', 'pending_review'].includes(shop.status)) return null;
if (sameId(shop.owner, userId)) return String(shop._id);
return null;
}
case 'shopproduct': {
const listing = await ShopProductListingModel.findById(id).select('_id shop status').lean();
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null;
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
if (shop && String(shop.owner) === String(userId)) {
return String(listing._id);
const listing = await ShopProductListingModel.findById(id)
.select('_id shop status')
.lean();
if (!listing) return null;
if (requirePublic && !['active', 'pending_shop_approval'].includes(listing.status)) {
return null;
}
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
if (shop && sameId(shop.owner, userId)) return String(listing._id);
return null;
}
case 'booking': {
// برای رزرو، id همون userId مالک کانفیگه (نه _id خودِ BookingConfig) — چون صفحه‌ی
// عمومی رزرو (/booking/[userId]) با شناسه‌ی کاربر مسیردهی می‌شه، نه شناسه‌ی کانفیگ
if (String(id) !== String(userId)) return null;
const config = await BookingConfigModel.findOne({ owner: userId, status: 'active' })
.select('_id')
.lean();
// ref_id برای رزرو = userId مالک کانفیگ (نه _id خودِ BookingConfig)
if (!sameId(id, userId)) return null;
const query = requirePublic
? { owner: userId, status: 'active' }
: { owner: userId };
const config = await BookingConfigModel.findOne(query).select('_id').lean();
return config ? String(userId) : null;
}
default:
@@ -90,4 +93,4 @@ async function resolveLinkedEntity(kind, id, userId) {
}
}
module.exports = { resolveLinkedEntity };
module.exports = { resolveLinkedEntity };

View File

@@ -0,0 +1,49 @@
const CourseModel = require('../models/CourseModel')
const ProjectModel = require('../models/ProjectModel')
const AdvertisingModel = require('../models/AdvertisingModel')
const ShopModel = require('../models/ShopModel')
const ShopProductListingModel = require('../models/ShopProductListingModel')
const BookingConfigModel = require('../models/BookingConfigModel')
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus')
// عینِ resolveLinkedEntity.js ولی به‌جای اعتبارسنجیِ مالکیت، فقط عنوان/عکسِ نمایشی رو
// برمی‌گردونه — برای وقتی که بلاگر داره لینک‌های اشتراکی‌ِ یه کاربرِ دیگه (نه خودش) رو می‌بینه
async function resolveShareableLinkDisplay (kind, id) {
switch (kind) {
case 'pkg': {
const course = await CourseModel.findById(id).select('cuorse_name course_image status').lean()
if (!course || !PUBLIC_COURSE_STATUSES.includes(course.status)) return null
return { title: course.cuorse_name || 'پکیج آموزشی', image: course.course_image || null }
}
case 'project': {
const project = await ProjectModel.findById(id).select('title status').lean()
if (!project || !PUBLIC_PROJECT_STATUSES.includes(project.status)) return null
return { title: project.title || 'پروژه', image: null }
}
case 'billboard': {
const ad = await AdvertisingModel.findById(id).select('title images status').lean()
if (!ad || !PUBLIC_BILLBOARD_STATUSES.includes(ad.status)) return null
return { title: ad.title || 'بیلبورد', image: ad.images?.[0] || null }
}
case 'shop': {
const shop = await ShopModel.findById(id).select('name logo status').lean()
if (!shop || !['active', 'pending_review'].includes(shop.status)) return null
return { title: shop.name, image: shop.logo || null }
}
case 'shopproduct': {
const listing = await ShopProductListingModel.findById(id).select('title images primaryImageIndex status').lean()
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null
const image = listing.images?.[listing.primaryImageIndex ?? 0] ?? listing.images?.[0] ?? null
return { title: listing.title, image }
}
case 'booking': {
const config = await BookingConfigModel.findOne({ owner: id, status: 'active' }).select('name').lean()
if (!config) return null
return { title: config.name || 'رزرو نوبت', image: null }
}
default:
return null
}
}
module.exports = { resolveShareableLinkDisplay }

View File

@@ -0,0 +1,48 @@
const UserModel = require('./../models/UserModel')
const { notifyUser } = require('./notifyUser')
const { buildStorageUrl } = require('./storageUrl')
// همون الگوی tagNotification.js — قبلاً منشن‌کردنِ کاربر توی استوری هیچ نوتیفی
// (نه دیتابیس، نه پوش) تولید نمی‌کرد؛ این تابع بعد از ساختِ استوری، برای هر اورلیِ
// mention یه notifyUser صدا می‌زنه
let ioInstance = null
function setIo (io) {
ioInstance = io
}
async function notifyStoryMentions (story, ownerId) {
try {
const overlays = Array.isArray(story?.overlays) ? story.overlays : []
const mentionedIds = [
...new Set(
overlays
.filter((o) => o?.type === 'mention' && o.mention_user_id)
.map((o) => String(o.mention_user_id))
)
].filter((id) => id !== String(ownerId))
if (!mentionedIds.length) return
const owner = await UserModel.findById(ownerId).select('user_name profile_image')
const ownerUsername = owner?.user_name || 'کاربر'
const ownerImage = buildStorageUrl(owner?.profile_image) || undefined
await Promise.all(
mentionedIds.map((userId) =>
notifyUser(ioInstance, userId, {
type: 'story_mention',
title: ownerUsername,
description: 'شما را در استوری‌اش نام برد',
project_post_id: story._id,
actor_id: ownerId,
image: ownerImage,
actorUsername: owner?.user_name || undefined
})
)
)
} catch (error) {
console.error('Error creating story mention notifications:', error)
}
}
module.exports = { notifyStoryMentions, setIo }

38
utils/storyRingFlags.js Normal file
View File

@@ -0,0 +1,38 @@
const StoryModel = require('../models/StoryModel')
/**
* برای یه لیست از userId (مثلاً نویسنده‌های کامنت‌های یه صفحه)، نقشه‌ی has_story/
* has_unviewed_story برمی‌گردونه — همون منطقِ حلقه‌ی استوری که postEngagement.js
* برای نویسنده‌ی پست‌ها محاسبه می‌کنه، جدا شده تا هر جای دیگه (مثلاً کامنت‌ها) هم
* قابل استفاده باشه بدون وابستگی به فیلدهای مخصوصِ پست
*/
async function resolveStoryFlags (userIds, viewerId) {
const ids = [...new Set((userIds || []).filter(Boolean).map(String))]
if (!ids.length) return {}
const activeStories = await StoryModel.find({ user_id: { $in: ids }, expires_at: { $gt: new Date() } })
.select('user_id viewers')
.lean()
const storiesByAuthor = {}
activeStories.forEach((s) => {
const key = String(s.user_id)
if (!storiesByAuthor[key]) storiesByAuthor[key] = []
storiesByAuthor[key].push(s)
})
const flags = {}
for (const id of Object.keys(storiesByAuthor)) {
const stories = storiesByAuthor[id]
const hasUnviewed = stories.some(
(s) => !s.viewers?.some((v) => String(v.user_id) === String(viewerId))
)
flags[id] = {
has_story: true,
has_unviewed_story: viewerId ? hasUnviewed : true
}
}
return flags
}
module.exports = { resolveStoryFlags }

View File

@@ -1,5 +1,14 @@
const NotificationModel = require('../models/NotificationModel')
const UserModel = require('../models/UserModel')
const { notifyUser } = require('./notifyUser')
const { buildStorageUrl } = require('./storageUrl')
// همون الگوی services/*Cron.js — چون این تابع مستقیم از کنترلر صدا زده می‌شه (نه یه
// جاب زمان‌بندی‌شده)، io از index.js با همین setIo تزریق می‌شه تا notifyUser بتونه
// رویداد سوکت لحظه‌ای هم بفرسته، نه فقط ردیف دیتابیس/پوش
let ioInstance = null
function setIo (io) {
ioInstance = io
}
const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
try {
@@ -10,19 +19,20 @@ const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
)
if (!uniqueIds.length) return
const tagger = await UserModel.findById(taggerId).select('user_name')
const tagger = await UserModel.findById(taggerId).select('user_name profile_image')
const taggerUsername = tagger?.user_name || 'کاربر'
const message = `${taggerUsername} شما را در پستی نشانه‌گذاری کرد.`
const taggerImage = buildStorageUrl(tagger?.profile_image) || undefined
await Promise.all(
uniqueIds.map((userId) =>
NotificationModel.create({
user_id: userId,
notifyUser(ioInstance, userId, {
type: 'post_tag',
title: taggerUsername,
description: 'در پستی از شما نام برد',
project_post_id: postId,
actor_id: taggerId,
type: 'post_tag',
title: message,
description: message
image: taggerImage,
link: `/posts/${postId}`
})
)
)
@@ -32,5 +42,6 @@ const createTagNotifications = async ({ taggerId, taggedUserIds, postId }) => {
}
module.exports = {
createTagNotifications
createTagNotifications,
setIo
}

37
utils/vpnCheck.js Normal file
View File

@@ -0,0 +1,37 @@
const axios = require('axios')
// ip-api.com (رایگان، بدون نیاز به API key) — fields=proxy هم پراکسی/VPNِ شناخته‌شده و
// هم هاستینگ/دیتاسنتر (که معمولاً پشتِ VPNهای تجاریه) رو با یه پرچم برمی‌گردونه.
// روی IPِ لوکال/خصوصی (توسعه‌ی محلی) status:"fail" برمی‌گرده که باید نادیده گرفته بشه
// (نباید هشدار غلط نشون بده)
const IP_API_BASE = 'http://ip-api.com/json'
const ALLOWED_COUNTRY_CODE = 'IR'
/**
* @param {string} ip
* @returns {Promise<{ shouldWarn: boolean, countryCode: string|null, isProxy: boolean }>}
*/
async function checkClientRegion (ip) {
const fallback = { shouldWarn: false, countryCode: null, isProxy: false }
if (!ip) return fallback
try {
const { data } = await axios.get(
`${IP_API_BASE}/${encodeURIComponent(ip)}`,
{ params: { fields: 'status,message,countryCode,proxy,hosting,query' }, timeout: 4000 }
)
if (!data || data.status !== 'success') return fallback
const isProxy = Boolean(data.proxy) || Boolean(data.hosting)
const countryCode = data.countryCode || null
const shouldWarn = isProxy || (countryCode != null && countryCode !== ALLOWED_COUNTRY_CODE)
return { shouldWarn, countryCode, isProxy }
} catch (error) {
console.error('checkClientRegion error:', error.message)
return fallback
}
}
module.exports = { checkClientRegion }