full app
This commit is contained in:
@@ -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
255
utils/adDelivery.js
Normal 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
39
utils/adPricing.js
Normal 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 }
|
||||
55
utils/autoDirectTrigger.js
Normal file
55
utils/autoDirectTrigger.js
Normal 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 }
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 };
|
||||
49
utils/shareableLinkDisplay.js
Normal file
49
utils/shareableLinkDisplay.js
Normal 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 }
|
||||
48
utils/storyMentionNotification.js
Normal file
48
utils/storyMentionNotification.js
Normal 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
38
utils/storyRingFlags.js
Normal 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 }
|
||||
@@ -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
37
utils/vpnCheck.js
Normal 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 }
|
||||
Reference in New Issue
Block a user