diff --git a/controllers/application/academy/academyControllers.js b/controllers/application/academy/academyControllers.js index 849ee13..b8e2edd 100644 --- a/controllers/application/academy/academyControllers.js +++ b/controllers/application/academy/academyControllers.js @@ -18,6 +18,7 @@ const AcademyCategoryModel = require('../../../models/AcademyCategoryModel') const { buildUnifiedInterestScore, blendFreshIntoRanked: blendInterestIntoRanked } = require('../../../utils/exploreAlgorithm') const { resolveCommentModerationStatus } = require('../../../utils/contentModeration') const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave') +const { resolveStoryFlags } = require('../../../utils/storyRingFlags') const { isVerifiedBadge } = require('../../../utils/verifyBadge') const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') const { @@ -39,6 +40,7 @@ const { } = require('../../../utils/academyNotification') const AdvertisingProfileModel = require('../../../models/AdvertisingProfile') const WalletTransactionModel = require('../../../models/WalletTransactionModel') +const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') const ACADEMY_COMMISSION_RATE = 0.15 @@ -1811,10 +1813,42 @@ const getCourseComments = async (req, res) => { .sort(sortOptions) .skip(skip) .limit(parseInt(limit)) - .populate('user_id', 'name username profileImage'), + // فیلدهای قبلی (name/username/profileImage) روی این اسکیما اصلاً وجود نداشتن — + // یعنی اطلاعاتِ نویسنده‌ی کامنت همیشه خالی برمی‌گشت + .populate('user_id', 'user_name first_name last_name profile_image verify_badge is_verified') + .lean(), CourseComment.countDocuments(query) ]) + // بررسی وجود توکن (اختیاری — لیست کامنت‌ها بدون لاگین هم قابل مشاهده‌ست) + let viewerId = null + const authHeader = req.header('Authorization') + if (authHeader) { + const tokenParts = authHeader.split(' ') + if (tokenParts.length === 2) { + try { + viewerId = jwt.verify(tokenParts[1], process.env.APP_SECRET).id + } catch (err) { + console.error('Invalid Token:', err.message) + } + } + } + + const commenterIds = comments.map((c) => c.user_id?._id).filter(Boolean) + const storyFlags = await resolveStoryFlags(commenterIds, viewerId) + const commentsWithStoryFlags = comments.map((c) => { + if (!c.user_id) return c + const flags = storyFlags[String(c.user_id._id)] || {} + return { + ...c, + user_id: { + ...c.user_id, + has_story: flags.has_story || false, + has_unviewed_story: flags.has_unviewed_story || false + } + } + }) + // محاسبه آمار کامنت‌ها const stats = await CourseComment.aggregate([ { $match: { course_id: courseId, status: 'accepted' } }, @@ -1836,7 +1870,7 @@ const getCourseComments = async (req, res) => { success: true, message: 'لیست کامنت‌ها با موفقیت دریافت شد', data: { - comments, + comments: commentsWithStoryFlags, pagination: { currentPage: parseInt(page), totalPages, @@ -2058,7 +2092,7 @@ const coursePayment = async (req, res, next) => { const decodedToken = jwt.verify(token, process.env.APP_SECRET) const userId = decodedToken.id - const { courseId } = req.body + const { courseId, useWallet } = req.body // ========== 1. بررسی وجود دوره ========== const course = await CourseModel.findById(courseId) @@ -2133,8 +2167,68 @@ const coursePayment = async (req, res, next) => { }) } + // ========== 5.۵ بررسی موجودی کیف پول (عین shopOrderPaymentController.js) ========== + const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit( + userId, + finalPrice, + useWallet + ) + + // کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست + if (fullyCovered) { + await debitWalletForPurchase(userId, walletAmount, { + referenceType: 'payment', + referenceId: course._id, + description: `خرید پکیج آموزشی «${course.cuorse_name}» از کیف پول` + }) + + const paymentRecord = await AcademyPaymentModel.create({ + price: finalPrice, + discountAmount: priceNum - discountedPrice, + taxAmount: finalTaxPrice, + taxRate, + course_name: course.cuorse_name, + course_id: course._id, + academy_id: course.academyId, + user_id: userId, + walletAmountUsed: walletAmount, + status: 'success' + }) + + await IsPaymentCourse.create({ + user_id: userId, + course_id: course._id, + price_paid: finalPrice, + purchased_at: new Date() + }) + + const courseOwnerId = await resolveCourseOwnerId(course) + await createCoursePurchaseNotification({ + ownerId: courseOwnerId, + buyerId: userId, + courseId: course._id + }) + + if (courseOwnerId) { + const sellerNetAmount = Math.round(finalPrice * (1 - ACADEMY_COMMISSION_RATE)) + if (sellerNetAmount > 0) { + await WalletTransactionModel.create({ + user: courseOwnerId, + bucket: 'academy', + type: 'credit', + amount: sellerNetAmount, + referenceType: 'payment', + referenceId: paymentRecord._id, + description: `درآمد فروش پکیج آموزشی «${course.cuorse_name}» (پس از کسر ۱۵٪ کارمزد)` + }) + } + } + + return res.status(200).json({ success: true, paidFromWallet: true }) + } + // تبدیل به ریال (ضرب در 10 چون هر تومان = 10 ریال) - const amountInRials = Math.round(finalPrice * 10) + const amountInRials = Math.round(gatewayAmount * 10) // مبلغ باید حداقل 1000 تومان باشد (10000 ریال) if (amountInRials < 10000) { @@ -2172,7 +2266,8 @@ const coursePayment = async (req, res, next) => { // ========== 8. بررسی پاسخ زرین‌پال ========== if (result.data && result.data.code === 100) { - // ذخیره اطلاعات پرداخت در حالت pending + // ذخیره اطلاعات پرداخت در حالت pending — walletAmountUsed فقط یادداشت می‌شه، + // کسرِ واقعی توی verify (بعد از تاییدِ قطعیِ پرداخت) انجام می‌شه await AcademyPaymentModel.create({ price: finalPrice, discountAmount: priceNum - discountedPrice, @@ -2183,6 +2278,7 @@ const coursePayment = async (req, res, next) => { academy_id: course.academyId, user_id: userId, payment_authority: result.data.authority, + walletAmountUsed: walletAmount, status: 'pending' }) @@ -2245,8 +2341,11 @@ const CoursePaymentCallback = async (req, res) => { ) } - // ========== 4. محاسبه مبلغ ========== - const amountInRials = Math.round(paymentRecord.price * 10) + // ========== 4. محاسبه مبلغ — فقط سهمِ درگاه (walletAmountUsed تا همینجا فقط + // یادداشت شده، کیف پول دست‌نخورده مونده — عین shopOrderPaymentController.js) ========== + const walletAmount = paymentRecord.walletAmountUsed || 0 + const gatewayAmount = paymentRecord.price - walletAmount + const amountInRials = Math.round(gatewayAmount * 10) // ========== 5. تایید پرداخت با زرین‌پال ========== const verify = await axios.post( @@ -2266,6 +2365,14 @@ const CoursePaymentCallback = async (req, res) => { // ========== 6. بررسی نتیجه تایید ========== if (data.code === 100) { + // درگاه فقط بخشِ باقی‌مونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ + // کیف پول هم واقعاً کسر می‌شه + await debitWalletForPurchase(userId, walletAmount, { + referenceType: 'payment', + referenceId: paymentRecord._id, + description: `خرید پکیج آموزشی «${paymentRecord.course_name}» از کیف پول` + }) + // به‌روزرسانی رکورد پرداخت await AcademyPaymentModel.findByIdAndUpdate(paymentRecord._id, { status: 'success', diff --git a/controllers/application/ads/adsController.js b/controllers/application/ads/adsController.js new file mode 100644 index 0000000..4570eeb --- /dev/null +++ b/controllers/application/ads/adsController.js @@ -0,0 +1,388 @@ +const axios = require('axios') +const mongoose = require('mongoose') +const AdModel = require('../../../models/AdModel') +const PostModel = require('../../../models/PostModel') +const StoryModel = require('../../../models/StoryModel') +const UserModel = require('../../../models/UserModel') +const { resolveAdPrice } = require('../../../utils/adPricing') +const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') +const { deliverNotificationAd, trackAdEvent, findActiveAd, buildAdLinkedContentItem } = require('../../../utils/adDelivery') +const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity') + +const SPONSORED_TYPES = ['project', 'product', 'shop', 'billboard'] + +const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE +const AD_CAMPAIGN_DURATION_DAYS = 7 +const AD_TYPES = AdModel.AD_TYPES + +function toRial (toman) { + return Math.round(Number(toman) * 10) +} + +function normalizeTargeting (body) { + // برای POST (createAd) یه آرایه‌ی واقعیه (JSON body)؛ برای GET (getAdPricing) کوئری‌استرینگ + // که فرانت با کاما جدا می‌فرسته — هر دو شکل رو قبول می‌کنیم + let subExpertise = [] + if (Array.isArray(body.target_sub_expertise)) { + subExpertise = body.target_sub_expertise.filter(Boolean) + } else if (typeof body.target_sub_expertise === 'string' && body.target_sub_expertise) { + subExpertise = body.target_sub_expertise.split(',').map((s) => s.trim()).filter(Boolean) + } + + return { + target_country_id: body.target_country_id != null && body.target_country_id !== '' ? Number(body.target_country_id) : null, + target_province_id: body.target_province_id != null && body.target_province_id !== '' ? Number(body.target_province_id) : null, + target_city_id: body.target_city_id != null && body.target_city_id !== '' ? Number(body.target_city_id) : null, + target_expertise: body.target_expertise ? String(body.target_expertise).trim() : null, + target_sub_expertise: subExpertise, + target_gender: ['female', 'male'].includes(body.target_gender) ? body.target_gender : 'any' + } +} + +/** GET /ads/pricing — کاربر با هر انتخابی توی ویزارد، قیمتِ همون سناریو رو زنده می‌بینه */ +const getAdPricing = async (req, res, next) => { + try { + const { ad_type: adType } = req.query + if (!AD_TYPES.includes(adType)) { + return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' }) + } + const targeting = normalizeTargeting(req.query) + const price = await resolveAdPrice(adType, { + provinceId: targeting.target_province_id, + gender: targeting.target_gender === 'any' ? null : targeting.target_gender, + expertise: targeting.target_expertise, + subExpertise: targeting.target_sub_expertise + }) + return res.json({ price }) + } catch (error) { + next(error) + } +} + +async function activateAd (ad) { + ad.status = 'active' + ad.started_at = new Date() + ad.expires_at = new Date(Date.now() + AD_CAMPAIGN_DURATION_DAYS * 24 * 60 * 60 * 1000) + await ad.save() + if (ad.ad_type === 'notification') { + deliverNotificationAd(ad).catch((error) => console.error('deliverNotificationAd error:', error.message)) + } +} + +/** POST /ads — ساخت تبلیغ؛ عین سفارش فروشگاه/دوره، اول کیف پول رو چک می‌کنه */ +const createAd = async (req, res, next) => { + try { + const { ad_type: adType, useWallet } = req.body + if (!AD_TYPES.includes(adType)) { + return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' }) + } + + let contentPostId = null + let contentStoryId = null + let contentMediaPath = null + let contentMediaType = null + let contentOverlays = null + let contentProjectId = null + let contentShopProductId = null + let contentShopId = null + let contentBillboardId = null + let notificationTitle = null + let notificationBody = null + + // نگاشتِ ad_type → {کلیدِ ورودی، تابعِ resolveLinkedEntity، پیامِ خطا} برای چهار + // نوعِ «تبلیغِ محتوای موجود» (پروژه/کالا/فروشگاه/بیلبورد) — همون منطقِ مالکیت+وضعیتی + // که ثبتِ پست (resolveLinkedEntity.js) از قبل داره، دوباره‌نویسی نشده + const LINKED_CONTENT_TYPES = { + project: { bodyKey: 'content_project_id', kind: 'project', message: 'پروژه‌ای برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این پروژه نیستید' }, + product: { bodyKey: 'content_shop_product_id', kind: 'shopproduct', message: 'کالایی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این کالا نیستید' }, + shop: { bodyKey: 'content_shop_id', kind: 'shop', message: 'فروشگاهی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این فروشگاه نیستید' }, + billboard: { bodyKey: 'content_billboard_id', kind: 'billboard', message: 'بیلبوردی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این بیلبورد نیستید' } + } + + if (adType === 'first_post') { + const postId = req.body.content_post_id + if (!postId || !mongoose.Types.ObjectId.isValid(postId)) { + return res.status(422).json({ error: true, message: 'پستی برای تبلیغ انتخاب نشده است' }) + } + const post = await PostModel.findById(postId).select('user_id').lean() + if (!post || String(post.user_id) !== String(req.user._id)) { + return res.status(403).json({ error: true, message: 'شما مالک این پست نیستید' }) + } + contentPostId = postId + } else if (adType === 'first_story') { + const storyId = req.body.content_story_id + if (!storyId || !mongoose.Types.ObjectId.isValid(storyId)) { + return res.status(422).json({ error: true, message: 'استوری‌ای برای تبلیغ انتخاب نشده است' }) + } + // مدیا/اورلی‌ها همینجا اسنپ‌شات می‌شن (نه فقط رفرنس) — استوریِ اصلی معمولاً ۲۴ساعته + // منقضی می‌شه، ولی کمپینِ تبلیغاتی باید کاملِ مدتش (activateAd) زنده بمونه + const story = await StoryModel.findById(storyId).select('user_id media_path media_type overlays').lean() + if (!story || String(story.user_id) !== String(req.user._id)) { + return res.status(403).json({ error: true, message: 'شما مالک این استوری نیستید' }) + } + contentStoryId = storyId + contentMediaPath = story.media_path + contentMediaType = story.media_type + contentOverlays = story.overlays || [] + } else if (adType === 'notification') { + notificationTitle = String(req.body.notification_title || '').trim() + notificationBody = String(req.body.notification_body || '').trim() + if (!notificationTitle || !notificationBody) { + return res.status(422).json({ error: true, message: 'عنوان و متن نوتیفیکیشن الزامی است' }) + } + } else if (LINKED_CONTENT_TYPES[adType]) { + const { bodyKey, kind, message, ownerMessage } = LINKED_CONTENT_TYPES[adType] + const rawId = req.body[bodyKey] + if (!rawId || !mongoose.Types.ObjectId.isValid(rawId)) { + return res.status(422).json({ error: true, message }) + } + const resolvedId = await resolveLinkedEntity(kind, rawId, req.user._id) + if (!resolvedId) { + return res.status(403).json({ error: true, message: ownerMessage }) + } + if (adType === 'project') contentProjectId = resolvedId + else if (adType === 'product') contentShopProductId = resolvedId + else if (adType === 'shop') contentShopId = resolvedId + else if (adType === 'billboard') contentBillboardId = resolvedId + } + + const targeting = normalizeTargeting(req.body) + const price = await resolveAdPrice(adType, { + provinceId: targeting.target_province_id, + gender: targeting.target_gender === 'any' ? null : targeting.target_gender, + expertise: targeting.target_expertise, + subExpertise: targeting.target_sub_expertise + }) + + if (!price || price <= 0) { + return res.status(422).json({ error: true, message: 'قیمتی برای این سناریوی تبلیغ تعریف نشده است' }) + } + + const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit( + req.user._id, + price, + useWallet + ) + + const adPayload = { + user_id: req.user._id, + ad_type: adType, + content_post_id: contentPostId, + content_story_id: contentStoryId, + content_project_id: contentProjectId, + content_shop_product_id: contentShopProductId, + content_shop_id: contentShopId, + content_billboard_id: contentBillboardId, + content_media_path: contentMediaPath, + content_media_type: contentMediaType, + content_overlays: contentOverlays, + notification_title: notificationTitle, + notification_body: notificationBody, + ...targeting, + price, + walletAmountUsed: walletAmount + } + + if (fullyCovered) { + const ad = await AdModel.create(adPayload) + await debitWalletForPurchase(req.user._id, walletAmount, { + referenceType: 'payment', + referenceId: ad._id, + description: 'ثبت تبلیغ از کیف پول' + }) + await activateAd(ad) + return res.status(201).json({ success: true, paidFromWallet: true, adId: ad._id }) + } + + const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL + if (!callbackBase) { + return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' }) + } + + const ad = await AdModel.create({ ...adPayload, status: 'pending_payment' }) + + const response = await axios.post( + 'https://api.zarinpal.com/pg/v4/payment/request.json', + { + merchant_id: MERCHANT_ID, + amount: toRial(gatewayAmount), + description: `ثبت تبلیغ (${adType})`, + callback_url: `${callbackBase}/payment/ads/callback?adId=${ad._id}` + }, + { headers: { 'Content-Type': 'application/json' } } + ) + + const result = response.data?.data + if (result?.code === 100 && result?.authority) { + ad.payment_authority = result.authority + await ad.save() + return res.status(201).json({ + success: true, + adId: ad._id, + authority: result.authority, + paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + }) + } + + await AdModel.deleteOne({ _id: ad._id }) + console.error('ZarinPal initiate error:', response.data) + return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' }) + } catch (error) { + console.error('createAd error:', error?.response?.data || error.message) + next(error) + } +} + +/** GET /ads/payment/verify — کال‌بک زرین‌پال */ +const verifyAdPayment = async (req, res, next) => { + const { adId, Authority, Status } = req.query + const failRedirect = `${process.env.APP_SITE}/settings/ads?payment=failed` + const successRedirect = `${process.env.APP_SITE}/settings/ads?payment=success` + + try { + const ad = await AdModel.findById(adId) + if (!ad || ad.status !== 'pending_payment') { + return res.redirect(failRedirect) + } + + if (Status !== 'OK') { + ad.status = 'failed' + await ad.save() + return res.redirect(failRedirect) + } + + const walletAmount = ad.walletAmountUsed || 0 + const gatewayAmount = ad.price - walletAmount + + const verify = await axios.post( + 'https://api.zarinpal.com/pg/v4/payment/verify.json', + { + merchant_id: MERCHANT_ID, + authority: Authority, + amount: toRial(gatewayAmount) + }, + { headers: { 'Content-Type': 'application/json' } } + ) + + const data = verify.data?.data + if (data?.code === 100 || data?.code === 101) { + await debitWalletForPurchase(ad.user_id, walletAmount, { + referenceType: 'payment', + referenceId: ad._id, + description: 'ثبت تبلیغ از کیف پول' + }) + await activateAd(ad) + return res.redirect(successRedirect) + } + + ad.status = 'failed' + await ad.save() + return res.redirect(failRedirect) + } catch (error) { + console.error('verifyAdPayment error:', error?.response?.data || error.message) + return res.redirect(failRedirect) + } +} + +/** GET /ads/mine */ +const getMyAds = async (req, res, next) => { + try { + const ads = await AdModel.find({ user_id: req.user._id }) + .sort({ createdAt: -1 }) + .lean() + const now = Date.now() + const withEffectiveStatus = ads.map((ad) => ({ + ...ad, + effective_status: + ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now + ? 'expired' + : ad.status + })) + return res.json({ ads: withEffectiveStatus }) + } catch (error) { + next(error) + } +} + +/** GET /ads/:id — جزئیات + آنالیتیکس */ +const getAdDetail = async (req, res, next) => { + try { + const ad = await AdModel.findById(req.params.id).lean() + if (!ad || String(ad.user_id) !== String(req.user._id)) { + return res.status(404).json({ error: true, message: 'تبلیغ یافت نشد' }) + } + const now = Date.now() + const effectiveStatus = + ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now + ? 'expired' + : ad.status + + let content = null + if (ad.ad_type === 'first_post' && ad.content_post_id) { + content = await PostModel.findById(ad.content_post_id).select('files caption type').lean() + } else if (ad.ad_type === 'first_story') { + // اسنپ‌شاتِ خودِ AdModel — نه لوکاپِ زنده روی StoryModel، چون استوریِ اصلی معمولاً + // ۲۴ساعته منقضی می‌شه ولی این جزئیات باید کاملِ عمرِ تبلیغ در دسترس بمونه + content = { media_path: ad.content_media_path, media_type: ad.content_media_type } + } else if (SPONSORED_TYPES.includes(ad.ad_type)) { + content = await buildAdLinkedContentItem(ad) + } + + return res.json({ ad: { ...ad, effective_status: effectiveStatus }, content }) + } catch (error) { + next(error) + } +} + +/** GET /ads/sponsored?ad_type=project|product|shop|billboard — لیستِ مربوطه (اسپشیالیست‌ها/ + * کالاهای فروشگاه/فروشگاه‌ها/بیلبوردها) این رو صدا می‌زنه و اگه چیزی برگشت، اولِ لیستِ خودش + * نشونش می‌ده. برخلافِ first_post/first_story که مستقیم توی fید/استوری تزریق می‌شن، این چهار + * نوع endpoint جدا دارن — چون fetch لیست‌شون توی کنترلرهای پرترافیک/حساسِ جداگانه‌ست و + * دست‌نخورده موندنشون امن‌تره */ +const getSponsoredAd = async (req, res, next) => { + try { + const { ad_type: adType } = req.query + if (!SPONSORED_TYPES.includes(adType)) { + return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' }) + } + + const viewer = await UserModel.findById(req.user._id) + .select('country province city gender expertise sub_expertise') + .lean() + + const ad = await findActiveAd(adType, { ...viewer, _id: req.user._id }) + if (!ad) return res.json({ ad: null }) + + const item = await buildAdLinkedContentItem(ad) + if (!item) return res.json({ ad: null }) + + trackAdEvent(ad._id, 'view').catch(() => {}) + return res.json({ ad: item }) + } catch (error) { + next(error) + } +} + +/** POST /ads/:id/track — {type:'view'|'click'} — بیننده صدا می‌زنه، نیازی به مالکیت نیست */ +const trackAdEventEndpoint = async (req, res, next) => { + try { + const { type } = req.body + if (!['view', 'click'].includes(type)) { + return res.status(422).json({ error: true, message: 'نوع رویداد نامعتبر است' }) + } + await trackAdEvent(req.params.id, type) + return res.json({ success: true }) + } catch (error) { + next(error) + } +} + +module.exports = { + getAdPricing, + createAd, + verifyAdPayment, + getMyAds, + getAdDetail, + getSponsoredAd, + trackAdEventEndpoint +} diff --git a/controllers/application/advertising/advertisingController.js b/controllers/application/advertising/advertisingController.js index 92058a5..10f4e42 100644 --- a/controllers/application/advertising/advertisingController.js +++ b/controllers/application/advertising/advertisingController.js @@ -1,5 +1,6 @@ /* eslint-disable eqeqeq */ /* eslint-disable camelcase */ +const mongoose = require('mongoose') const jwt = require('jsonwebtoken') const { check, validationResult } = require('express-validator') const { billboardPublicStatusFilter } = require('../../../utils/moderationStatus') @@ -25,6 +26,8 @@ const { createRatingNotification } = require('../../../utils/commentNotification') const { buildUnifiedInterestScore, blendFreshIntoRanked } = require('../../../utils/exploreAlgorithm') +const { resolveStoryFlags } = require('../../../utils/storyRingFlags') +const { findActiveAd, buildAdBillboardItem, trackAdEvent } = require('../../../utils/adDelivery') /** 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) { @@ -540,6 +543,24 @@ const getAdvertisingsWeb = async (req, res, next) => { } catch { /* non-blocking: personalization is a bonus, never break the listing */ } + + // تبلیغِ نوع billboard — عینِ الگوی first_post (adDelivery.js): فقط صفحه‌ی اول، + // فقط برای کاربرِ واردشده، خطاش هیچ‌وقت نباید لیستِ بیلبوردها رو بشکنه + try { + const viewer = await UserModel.findById(userId) + .select('country province city gender expertise sub_expertise') + .lean() + const sponsoredAd = await findActiveAd('billboard', { ...viewer, _id: userId }) + if (sponsoredAd) { + const adBillboard = await buildAdBillboardItem(sponsoredAd) + if (adBillboard) { + newAds = [adBillboard, ...newAds] + trackAdEvent(sponsoredAd._id, 'view').catch(() => {}) + } + } + } catch (adError) { + console.error('billboard ad injection error:', adError.message) + } } res.status(200).json({ @@ -831,12 +852,26 @@ const addComment = async (req, res, next) => { const decodedToken = jwt.verify(token, process.env.APP_SECRET) const userId = decodedToken.id const user = await UserModel.findById(decodedToken.id) + + // مثل شاپ‌پروداکت/آموزشگاه: امتیاز فقط روی اولین کامنتِ هر کاربر روی این آگهی مجازه + let rateValue = null + const requestedRate = Number(req.body.rate) + if (requestedRate >= 1 && requestedRate <= 5) { + const existingRating = await AdvertisingComment.findOne({ + advertisingId, + userId, + rate: { $ne: null, $gt: 0 } + }).select('_id') + if (!existingRating) rateValue = requestedRate + } + const newComment = await saveCommentWithModerationStatus( AdvertisingComment, { advertisingId, userId, - text + text, + rate: rateValue }, 'text', user?.verify_badge @@ -872,11 +907,62 @@ const getComments = async (req, res, next) => { 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: commentStatusFilter() }, options) + const [comments, ratingAgg] = await Promise.all([ + AdvertisingComment.paginate({ advertisingId, status: commentStatusFilter() }, options), + AdvertisingComment.aggregate([ + { $match: { advertisingId: new mongoose.Types.ObjectId(advertisingId), rate: { $ne: null, $gt: 0 } } }, + { $group: { _id: null, average: { $avg: '$rate' }, count: { $sum: 1 } } } + ]) + ]) + + // بررسی وجود توکن (اختیاری — لیست کامنت‌ها بدون لاگین هم قابل مشاهده‌ست) + let currentUserId = null + const authHeader = req.header('Authorization') + if (authHeader) { + const tokenParts = authHeader.split(' ') + if (tokenParts.length === 2) { + try { + currentUserId = jwt.verify(tokenParts[1], process.env.APP_SECRET).id + } catch (err) { + console.error('Invalid Token:', err.message) + } + } + } + + let hasRated = false + if (currentUserId) { + const existingRating = await AdvertisingComment.findOne({ + advertisingId, + userId: currentUserId, + rate: { $ne: null, $gt: 0 } + }).select('_id') + hasRated = Boolean(existingRating) + } + + const commenterIds = comments.docs.map((c) => c.userId?._id).filter(Boolean) + const storyFlags = await resolveStoryFlags(commenterIds, currentUserId) + const commentsWithStoryFlags = comments.docs.map((c) => { + const obj = c.toObject ? c.toObject() : c + if (obj.userId) { + const flags = storyFlags[String(obj.userId._id)] || {} + obj.userId = { + ...obj.userId, + has_story: flags.has_story || false, + has_unviewed_story: flags.has_unviewed_story || false + } + } + return obj + }) + res.status(200).json({ - comments: comments.docs, + comments: commentsWithStoryFlags, totalPages: comments.totalPages, - totalItems: comments.totalDocs + totalItems: comments.totalDocs, + has_rated: hasRated, + rating: { + average: ratingAgg[0]?.average || 0, + count: ratingAgg[0]?.count || 0 + } }) } catch (error) { next(error) diff --git a/controllers/application/autoDirect/autoDirectController.js b/controllers/application/autoDirect/autoDirectController.js new file mode 100644 index 0000000..9213579 --- /dev/null +++ b/controllers/application/autoDirect/autoDirectController.js @@ -0,0 +1,91 @@ +const AutoDirectRuleModel = require('../../../models/AutoDirectRuleModel') + +function ruleError (res, status, message) { + return res.status(status).json({ error: true, message }) +} + +async function findOwnedRule (ruleId, userId) { + const rule = await AutoDirectRuleModel.findById(ruleId) + if (!rule) return { error: 'NOT_FOUND' } + if (String(rule.user_id) !== String(userId)) return { error: 'FORBIDDEN' } + return { rule } +} + +const getMyAutoDirectRules = async (req, res, next) => { + try { + const rules = await AutoDirectRuleModel.find({ user_id: req.user._id }).sort({ createdAt: -1 }) + return res.json({ rules }) + } catch (error) { + next(error) + } +} + +const createAutoDirectRule = async (req, res, next) => { + try { + const { trigger_text, direct_message, reply_caption } = req.body + if (!trigger_text?.trim() || !direct_message?.trim() || !reply_caption?.trim()) { + return ruleError(res, 422, 'همه‌ی فیلدها الزامی است') + } + + const rule = await AutoDirectRuleModel.create({ + user_id: req.user._id, + trigger_text: trigger_text.trim(), + direct_message: direct_message.trim(), + reply_caption: reply_caption.trim() + }) + + return res.status(201).json({ message: 'دایرکت اتوماتیک ثبت شد', rule }) + } catch (error) { + next(error) + } +} + +const updateAutoDirectRule = async (req, res, next) => { + try { + const { rule, error } = await findOwnedRule(req.params.id, req.user._id) + if (error === 'NOT_FOUND') return ruleError(res, 404, 'دایرکت اتوماتیک یافت نشد') + if (error === 'FORBIDDEN') return ruleError(res, 403, 'شما مالک این قانون نیستید') + + const { trigger_text, direct_message, reply_caption, is_active } = req.body + if (trigger_text !== undefined) { + if (!trigger_text?.trim()) return ruleError(res, 422, 'متن کامنت الزامی است') + rule.trigger_text = trigger_text.trim() + } + if (direct_message !== undefined) { + if (!direct_message?.trim()) return ruleError(res, 422, 'متن دایرکت الزامی است') + rule.direct_message = direct_message.trim() + } + if (reply_caption !== undefined) { + if (!reply_caption?.trim()) return ruleError(res, 422, 'متن پاسخ کامنت الزامی است') + rule.reply_caption = reply_caption.trim() + } + if (is_active !== undefined) { + rule.is_active = Boolean(is_active) + } + + await rule.save() + return res.json({ message: 'دایرکت اتوماتیک به‌روزرسانی شد', rule }) + } catch (error) { + next(error) + } +} + +const deleteAutoDirectRule = async (req, res, next) => { + try { + const { rule, error } = await findOwnedRule(req.params.id, req.user._id) + if (error === 'NOT_FOUND') return ruleError(res, 404, 'دایرکت اتوماتیک یافت نشد') + if (error === 'FORBIDDEN') return ruleError(res, 403, 'شما مالک این قانون نیستید') + + await rule.deleteOne() + return res.json({ message: 'دایرکت اتوماتیک حذف شد' }) + } catch (error) { + next(error) + } +} + +module.exports = { + getMyAutoDirectRules, + createAutoDirectRule, + updateAutoDirectRule, + deleteAutoDirectRule +} diff --git a/controllers/application/geo/geoController.js b/controllers/application/geo/geoController.js new file mode 100644 index 0000000..3f473ca --- /dev/null +++ b/controllers/application/geo/geoController.js @@ -0,0 +1,14 @@ +const { checkClientRegion } = require('../../../utils/vpnCheck') + +/** GET /geo/check — طبق خواسته‌ی کاربر: هر بار اپ (وب/اندروید) باز می‌شه صدا زده می‌شه + * تا اگه کاربر با آی‌پیِ غیرایران یا VPN/پراکسی وارد شده، بهش هشدار نشون داده بشه */ +const checkGeo = async (req, res, next) => { + try { + const result = await checkClientRegion(req.ip) + return res.status(200).json(result) + } catch (error) { + next(error) + } +} + +module.exports = { checkGeo } diff --git a/controllers/application/posts/engagementController.js b/controllers/application/posts/engagementController.js index 5ebb485..28aa3c8 100644 --- a/controllers/application/posts/engagementController.js +++ b/controllers/application/posts/engagementController.js @@ -389,7 +389,13 @@ const sendPostToUsers = async (req, res, next) => { if (io) { const formatted = await enrichMessage(newMessage) - emitChatEvents(io, formatted, senderId, receiverId) + // برخلافِ پیامِ متنیِ معمولی، این پیام رو خودِ سرور از رویِ دیتای واقعیِ پست ساخته + // (نه متنِ رمزنگاری‌شدهٔ کاربر)، پس اینجا محدودیتِ E2EE برای پوش صدق نمی‌کنه — + // می‌تونیم دقیقاً بگیم ریلز بوده یا پست + emitChatEvents(io, formatted, senderId, receiverId, { + type: post.type === 'video' ? 'reel_shared' : 'post_shared', + description: post.type === 'video' ? 'یک ریلز برای شما ارسال کرد' : 'یک پست برای شما ارسال کرد' + }) } sentCount += 1 } diff --git a/controllers/application/profile/accountController.js b/controllers/application/profile/accountController.js index e01aab5..bbe269b 100644 --- a/controllers/application/profile/accountController.js +++ b/controllers/application/profile/accountController.js @@ -153,35 +153,55 @@ const getAccountStatus = async (req, res) => { const updatePrivacySettings = async (req, res) => { try { - const user = await UserModel.findById(req.user._id) + // auth فقط توکن را چک می‌کند؛ blockCheck باید req.user را ست کرده باشد + const userId = req.user?._id || getUserIdFromToken(req) + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }) + } + + const $set = {} + if (typeof req.body.allow_save_posts === 'boolean') { + $set.allow_save_posts = req.body.allow_save_posts + } + if (typeof req.body.ghost_mode === 'boolean') { + $set.ghost_mode = req.body.ghost_mode + } + if (typeof req.body.show_location === 'boolean') { + $set.show_location = req.body.show_location + } + if (typeof req.body.post_location_enabled === 'boolean') { + $set.post_location_enabled = req.body.post_location_enabled + } + if (typeof req.body.is_private === 'boolean') { + $set.is_private = req.body.is_private + } + if (typeof req.body.gift_enabled === 'boolean') { + $set.gift_enabled = req.body.gift_enabled + } + if (typeof req.body.offer_payment_enabled === 'boolean') { + $set.offer_payment_enabled = req.body.offer_payment_enabled + } + if (APP_LANGUAGE_CODES.includes(req.body.preferred_language)) { + $set.preferred_language = req.body.preferred_language + } + + // فقط فیلدهای privacy را با $set آپدیت می‌کنیم تا validation کل سند + // (فیلدهای قدیمی/ناسازگار) باعث 500 نشود + const user = await UserModel.findByIdAndUpdate( + userId, + Object.keys($set).length ? { $set } : {}, + { + new: true, + runValidators: false, + select: + 'allow_save_posts ghost_mode show_location post_location_enabled is_private gift_enabled offer_payment_enabled preferred_language', + } + ).lean() + if (!user) { return res.status(404).json({ message: 'کاربر یافت نشد' }) } - if (typeof req.body.allow_save_posts === 'boolean') { - user.allow_save_posts = req.body.allow_save_posts - } - if (typeof req.body.ghost_mode === 'boolean') { - user.ghost_mode = req.body.ghost_mode - } - if (typeof req.body.show_location === 'boolean') { - user.show_location = req.body.show_location - } - if (typeof req.body.post_location_enabled === 'boolean') { - user.post_location_enabled = req.body.post_location_enabled - } - if (typeof req.body.is_private === 'boolean') { - user.is_private = req.body.is_private - } - if (typeof req.body.gift_enabled === 'boolean') { - user.gift_enabled = req.body.gift_enabled - } - if (typeof req.body.offer_payment_enabled === 'boolean') { - user.offer_payment_enabled = req.body.offer_payment_enabled - } - if (APP_LANGUAGE_CODES.includes(req.body.preferred_language)) { - user.preferred_language = req.body.preferred_language - } - await user.save() + return res.status(200).json({ allow_save_posts: user.allow_save_posts !== false, ghost_mode: Boolean(user.ghost_mode), @@ -193,7 +213,11 @@ const updatePrivacySettings = async (req, res) => { preferred_language: user.preferred_language || 'fa', }) } catch (err) { - return res.status(500).json({ message: 'خطا در ذخیره تنظیمات' }) + console.error('updatePrivacySettings:', err) + return res.status(500).json({ + message: 'خطا در ذخیره تنظیمات', + detail: err?.message || String(err), + }) } } diff --git a/controllers/application/projects/getProjectController.js b/controllers/application/projects/getProjectController.js index 2fa2044..911e5c9 100644 --- a/controllers/application/projects/getProjectController.js +++ b/controllers/application/projects/getProjectController.js @@ -6,6 +6,7 @@ const RequestModel = require('../../../models/RequestModel') const UserModel = require('../../../models/UserModel') const jwt = require('jsonwebtoken') const { buildUnifiedInterestScore, blendFreshIntoRanked } = require('../../../utils/exploreAlgorithm') +const { findActiveAd, buildAdProjectItem, trackAdEvent } = require('../../../utils/adDelivery') function getOptionalUserId (req) { try { @@ -168,6 +169,27 @@ const getProjects = async (req, res, next) => { } catch { /* non-blocking: personalization is a bonus, never break the listing */ } + + // تبلیغِ نوع project — عینِ الگوی first_post (adDelivery.js): فقط صفحه‌ی اول، + // فقط برای کاربرِ واردشده، خطاش هیچ‌وقت نباید لیستِ پروژه‌ها رو بشکنه + try { + const userId = getOptionalUserId(req) + if (userId) { + const viewer = await UserModel.findById(userId) + .select('country province city gender expertise sub_expertise') + .lean() + const ad = await findActiveAd('project', { ...viewer, _id: userId }) + if (ad) { + const adProject = await buildAdProjectItem(ad) + if (adProject) { + newProj = [adProject, ...newProj] + trackAdEvent(ad._id, 'view').catch(() => {}) + } + } + } + } catch (adError) { + console.error('project ad injection error:', adError.message) + } } res.status(200).json({ diff --git a/controllers/application/shareableLinks/shareableLinksController.js b/controllers/application/shareableLinks/shareableLinksController.js new file mode 100644 index 0000000..94750a8 --- /dev/null +++ b/controllers/application/shareableLinks/shareableLinksController.js @@ -0,0 +1,102 @@ +const mongoose = require('mongoose') +const ShareableLinkModel = require('../../../models/ShareableLinkModel') +const UserModel = require('../../../models/UserModel') +const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity') +const { resolveShareableLinkDisplay } = require('../../../utils/shareableLinkDisplay') + +const KINDS = ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking'] + +const getMyShareableLinks = async (req, res, next) => { + try { + const rows = await ShareableLinkModel.find({ user_id: req.user._id }) + .select('kind ref_id') + .lean() + return res.json({ links: rows.map((r) => ({ kind: r.kind, ref_id: r.ref_id })) }) + } catch (error) { + next(error) + } +} + +const toggleShareableLink = async (req, res, next) => { + try { + const { kind, ref_id: refId, shared } = req.body + if (!KINDS.includes(kind) || !refId) { + return res.status(422).json({ error: true, message: 'ورودی نامعتبر است' }) + } + + if (shared) { + // صفحه اشتراک‌گذاری لینک: فقط مالکیت کافی است (نه الزام وضعیت عمومی) + const resolvedId = await resolveLinkedEntity(kind, refId, req.user._id, { + requirePublic: false, + }) + if (!resolvedId) { + return res.status(403).json({ + error: true, + message: 'شما مالک این محتوا نیستید یا آیتم یافت نشد', + kind, + ref_id: String(refId), + }) + } + await ShareableLinkModel.updateOne( + { user_id: req.user._id, kind, ref_id: String(resolvedId) }, + { $setOnInsert: { user_id: req.user._id, kind, ref_id: String(resolvedId) } }, + { upsert: true } + ) + } else { + await ShareableLinkModel.deleteOne({ user_id: req.user._id, kind, ref_id: String(refId) }) + } + + return res.json({ message: 'به‌روزرسانی شد' }) + } catch (error) { + next(error) + } +} + +const getShareableLinksForUsers = async (req, res, next) => { + try { + const raw = req.query.userIds + const userIds = (typeof raw === 'string' ? raw.split(',') : Array.isArray(raw) ? raw : []) + .map((id) => String(id).trim()) + .filter((id) => mongoose.Types.ObjectId.isValid(id)) + .slice(0, 50) + + if (!userIds.length) return res.json({ users: [] }) + + const [rows, users] = await Promise.all([ + ShareableLinkModel.find({ user_id: { $in: userIds } }).select('user_id kind ref_id').lean(), + UserModel.find({ _id: { $in: userIds } }).select('user_name').lean() + ]) + + const usernameById = Object.fromEntries(users.map((u) => [String(u._id), u.user_name])) + + const byUser = {} + for (const row of rows) { + const uid = String(row.user_id) + if (!byUser[uid]) byUser[uid] = [] + byUser[uid].push(row) + } + + const result = [] + for (const uid of Object.keys(byUser)) { + const links = [] + for (const row of byUser[uid]) { + const display = await resolveShareableLinkDisplay(row.kind, row.ref_id) + if (!display) continue + links.push({ kind: row.kind, ref_id: row.ref_id, title: display.title, image: display.image }) + } + if (links.length) { + result.push({ userId: uid, username: usernameById[uid] || '', links }) + } + } + + return res.json({ users: result }) + } catch (error) { + next(error) + } +} + +module.exports = { + getMyShareableLinks, + toggleShareableLink, + getShareableLinksForUsers +} \ No newline at end of file diff --git a/controllers/application/shop-products/shopProductController.js b/controllers/application/shop-products/shopProductController.js index bc433dd..476b624 100644 --- a/controllers/application/shop-products/shopProductController.js +++ b/controllers/application/shop-products/shopProductController.js @@ -20,6 +20,8 @@ const { viewerIsBlockedBy } = require('../../../utils/blockVisibility') const ShopCategoryModel = require('../../../models/ShopCategoryModel') const { buildUnifiedInterestScore } = require('../../../utils/exploreAlgorithm') const { optimizeImageFile } = require('../../../utils/imageOptimize') +const { findActiveAd, buildAdShopProductItem, trackAdEvent } = require('../../../utils/adDelivery') +const AdModel = require('../../../models/AdModel') /** Best-effort JWT decode for public browsing endpoints — never throws, never blocks guests. */ function getOptionalUserId (req) { @@ -361,6 +363,29 @@ const getCatalogListings = async (req, res, next) => { const visible = listings.filter((listing) => listing.shop?.status === 'active') visible.sort((a, b) => cheapestVariantPrice(a) - cheapestVariantPrice(b)) + // طبق خواسته‌ی کاربر: اگه یکی از همین لیستینگ‌ها (یه فروشگاهِ خاص که این کالا رو + // می‌فروشه) تبلیغِ فعالِ نوع product داره، اول از همه (قبلِ مرتب‌سازیِ ارزان‌ترین) + // نشون داده بشه — نه تزریقِ یه لیستینگِ جدید، فقط اولویت‌دهیِ دوباره به همین‌ها + try { + const activeAds = await AdModel.find({ + ad_type: 'product', + status: 'active', + expires_at: { $gt: new Date() }, + content_shop_product_id: { $in: visible.map((l) => l._id) } + }).select('content_shop_product_id').lean() + + if (activeAds.length) { + const adListingIds = new Set(activeAds.map((a) => String(a.content_shop_product_id))) + visible.sort((a, b) => { + const aIsAd = adListingIds.has(String(a._id)) ? 1 : 0 + const bIsAd = adListingIds.has(String(b._id)) ? 1 : 0 + return bIsAd - aIsAd + }) + } + } catch (adError) { + console.error('catalog listing ad sort error:', adError.message) + } + return res.json({ listings: visible }) } catch (error) { next(error) @@ -508,10 +533,30 @@ const getDiscoverFeed = async (req, res, next) => { } }) - const merged = [...withShop.map((d) => ({ ...d, tileId: String(d._id) })), ...postTiles].sort( + let merged = [...withShop.map((d) => ({ ...d, tileId: String(d._id) })), ...postTiles].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) + // تبلیغِ نوع product — عینِ الگوی first_post (adDelivery.js): فقط صفحه‌ی اول، + // فقط برای کاربرِ واردشده، خطاش هیچ‌وقت نباید گریدِ اکسپلورِ فروشگاه‌ها رو بشکنه + if (currentUserId && page === 1 && !q && !category && !pinterestOnly) { + try { + const viewer = await UserModel.findById(currentUserId) + .select('country province city gender expertise sub_expertise') + .lean() + const sponsoredAd = await findActiveAd('product', { ...viewer, _id: currentUserId }) + if (sponsoredAd) { + const adProduct = await buildAdShopProductItem(sponsoredAd) + if (adProduct) { + merged = [adProduct, ...merged] + trackAdEvent(sponsoredAd._id, 'view').catch(() => {}) + } + } + } catch (adError) { + console.error('product ad injection error:', adError.message) + } + } + return res.json({ docs: merged, totalDocs, @@ -690,7 +735,10 @@ const sendProductToUsers = async (req, res, next) => { if (io) { const formatted = await enrichMessage(newMessage) - emitChatEvents(io, formatted, senderId, receiverId) + emitChatEvents(io, formatted, senderId, receiverId, { + type: 'product_shared', + description: 'یک کالا برای شما ارسال کرد' + }) } sentCount += 1 } diff --git a/controllers/application/stories/storyController.js b/controllers/application/stories/storyController.js index cb2e5ea..9ca035e 100644 --- a/controllers/application/stories/storyController.js +++ b/controllers/application/stories/storyController.js @@ -13,6 +13,8 @@ const { filterStoryUserIdsByPrivateProfile } = require('../../../utils/privatePr const { sendStoryReactionMessage } = require('../../../utils/storyChatMessage') const { enrichMessage, emitChatEvents } = require('../../../helpers/chatHelpers') const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity') +const { findActiveAd, buildAdStoryGroup, trackAdEvent } = require('../../../utils/adDelivery') +const { notifyStoryMentions } = require('../../../utils/storyMentionNotification') const { optimizeImageFile } = require('../../../utils/imageOptimize') let optimizeVideoFaststart = async () => {} try { @@ -240,6 +242,10 @@ const createStoryBase64 = async (req, res) => { viewers: [] }) + notifyStoryMentions(story, userId).catch((err) => { + console.error('[notifyStoryMentions] background error:', err.message) + }) + return res.status(201).json({ message: 'استوری با موفقیت منتشر شد', storyId: story._id @@ -286,6 +292,10 @@ const createStoryMultipart = async (req, res) => { viewers: [] }) + notifyStoryMentions(story, userId).catch((err) => { + console.error('[notifyStoryMentions] background error:', err.message) + }) + return res.status(201).json({ message: 'استوری با موفقیت منتشر شد', storyId: story._id @@ -400,6 +410,25 @@ const getStoriesFeed = async (req, res, next) => { if (mine) myActiveStory = mine } + // تبلیغِ نوع first_story — همیشه اولِ نوار، فقط برای کاربرِ واردشده (utils/adDelivery.js) + if (viewerId) { + try { + const viewer = await UserModel.findById(viewerId) + .select('country province city gender expertise sub_expertise') + .lean() + const ad = await findActiveAd('first_story', { ...viewer, _id: viewerId }) + if (ad) { + const adGroup = await buildAdStoryGroup(ad) + if (adGroup) { + feed = [adGroup, ...feed] + trackAdEvent(ad._id, 'view').catch(() => {}) + } + } + } catch (adError) { + console.error('first_story ad injection error:', adError.message) + } + } + return res.status(200).json({ feed, my_story: myActiveStory, diff --git a/controllers/application/users/getUserController.js b/controllers/application/users/getUserController.js index 45c60e0..18acde7 100644 --- a/controllers/application/users/getUserController.js +++ b/controllers/application/users/getUserController.js @@ -15,6 +15,8 @@ const { const { resolveCommentModerationStatus } = require('../../../utils/contentModeration') const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave') const { createCommentNotification, createRatingNotification } = require('../../../utils/commentNotification') +const { runAutoDirectRules } = require('../../../utils/autoDirectTrigger') +const { findActiveAd, buildAdPostItem, trackAdEvent } = require('../../../utils/adDelivery') const { ProvinceModel, CityModel } = require('../../../models/StateCity') const OfferModel = require('../../../models/OfferModel') const PostModel = require('../../../models/PostModel') @@ -49,6 +51,7 @@ const { buildAppearanceUserFilter } = require('../../../utils/appearanceFilter') const { paginatePersonalizedExplore } = require('../../../utils/exploreAlgorithm') const ExploreInteractionModel = require('../../../models/ExploreInteractionModel') const { attachPostEngagementFields } = require('../../../utils/postEngagement') +const { resolveStoryFlags } = require('../../../utils/storyRingFlags') const { getFollowingReelsFeed, getSavedReelsFeed, @@ -1353,7 +1356,7 @@ const getPostsWeb = async (req, res, next) => { explorePosts = await mapPostsWithUsers(explorePosts, usersMap, decodedToken) - const { posts: paginatedPosts, totalItems, isColdStart } = + let { posts: paginatedPosts, totalItems, isColdStart } = await paginatePersonalizedExplore({ posts: explorePosts, usersById, @@ -1366,6 +1369,26 @@ const getPostsWeb = async (req, res, next) => { cacheScope: reelsMode ? 'reels' : 'explore' }) + // تبلیغِ نوع first_post — فقط صفحه‌ی اول، فقط برای کاربرِ واردشده، خطاش هیچ‌وقت + // نباید فیدِ اصلی رو بشکنه (utils/adDelivery.js) + if (decodedToken?.id && parseInt(page, 10) === 1) { + try { + const viewer = await UserModel.findById(decodedToken.id) + .select('country province city gender expertise sub_expertise') + .lean() + const ad = await findActiveAd('first_post', { ...viewer, _id: decodedToken.id }) + if (ad) { + const adPost = await buildAdPostItem(ad) + if (adPost) { + paginatedPosts = [adPost, ...paginatedPosts] + trackAdEvent(ad._id, 'view').catch(() => {}) + } + } + } catch (adError) { + console.error('first_post ad injection error:', adError.message) + } + } + return res.status(200).json({ posts: paginatedPosts, totalPages: Math.ceil(totalItems / limit) || 1, @@ -2130,6 +2153,12 @@ const getComments = async (req, res, next) => { return acc }, {}) + const commenterIds = [ + ...comments.docs.map((c) => c.creator._id), + ...repliesRaw.map((r) => r.creator._id) + ] + const storyFlags = await resolveStoryFlags(commenterIds, decodedToken.id) + const earliestRatedComments = await CommentModel.find({ user: user_id, rating: { $ne: null, $gt: 0 } @@ -2164,7 +2193,9 @@ const getComments = async (req, res, next) => { user_name: comment.creator.user_name, is_verified: comment.creator.is_verified, verify_badge: comment.creator.verify_badge, - _id: comment.creator._id + _id: comment.creator._id, + has_story: storyFlags[creatorId]?.has_story || false, + has_unviewed_story: storyFlags[creatorId]?.has_unviewed_story || false }, project: comment.project ? comment.project.project_name : null, post_id: comment.post ? comment.post.toString() : null, @@ -2307,6 +2338,17 @@ const createUserComment = async (req, res, next) => { type: commentType }) + if (targetPostId) { + void runAutoDirectRules({ + io: req.app.get('io'), + postOwnerId: ownerId, + commenterId: creatorId, + commentId: newComment._id, + postId: targetPostId, + commentText: comment + }) + } + if (isNewRating && ratingValue > 0) { await createRatingNotification({ io: req.app.get('io'), diff --git a/controllers/panel/ads/adsPanelController.js b/controllers/panel/ads/adsPanelController.js new file mode 100644 index 0000000..fe160f2 --- /dev/null +++ b/controllers/panel/ads/adsPanelController.js @@ -0,0 +1,175 @@ +const AdModel = require('../../../models/AdModel') +const AdPricingRuleModel = require('../../../models/AdPricingRuleModel') +const UserModel = require('../../../models/UserModel') +const { ProvinceModel, CityModel, CountryModel } = require('../../../models/StateCity') + +const AD_TYPES = AdModel.AD_TYPES + +/** GET /panel/ads — تبِ اول: لیستِ تبلیغ‌های ثبت‌شده */ +const listAds = async (req, res, next) => { + try { + const { page = 1, limit = 20, status, ad_type: adType } = req.query + const filter = {} + if (status) filter.status = status + if (adType) filter.ad_type = adType + + const [ads, totalItems] = await Promise.all([ + AdModel.find(filter) + .sort({ createdAt: -1 }) + .skip((Number(page) - 1) * Number(limit)) + .limit(Number(limit)) + .lean(), + AdModel.countDocuments(filter) + ]) + + const userIds = [...new Set(ads.map((a) => String(a.user_id)))] + const users = await UserModel.find({ _id: { $in: userIds } }) + .select('user_name first_name last_name profile_image') + .lean() + const usersById = Object.fromEntries(users.map((u) => [String(u._id), u])) + + const now = Date.now() + const rows = ads.map((ad) => ({ + ...ad, + advertiser: usersById[String(ad.user_id)] || null, + effective_status: + ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now + ? 'expired' + : ad.status + })) + + return res.json({ + ads: rows, + totalItems, + totalPages: Math.ceil(totalItems / Number(limit)) || 1 + }) + } catch (error) { + next(error) + } +} + +/** GET /panel/ads/:id — جزئیات کامل + آنالیتیکس یه تبلیغ */ +const getAdDetailPanel = async (req, res, next) => { + try { + const ad = await AdModel.findById(req.params.id).lean() + if (!ad) { + return res.status(404).json({ error: true, message: 'تبلیغ یافت نشد' }) + } + + const [advertiser, country, province, city] = await Promise.all([ + UserModel.findById(ad.user_id).select('user_name first_name last_name profile_image').lean(), + ad.target_country_id != null ? CountryModel.findOne({ id: ad.target_country_id }).lean() : null, + ad.target_province_id != null ? ProvinceModel.findOne({ id: ad.target_province_id }).lean() : null, + ad.target_city_id != null ? CityModel.findOne({ id: ad.target_city_id }).lean() : null + ]) + + const now = Date.now() + const effectiveStatus = + ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now + ? 'expired' + : ad.status + + return res.json({ + ad: { + ...ad, + effective_status: effectiveStatus, + advertiser, + target_country_name: country?.name || null, + target_province_name: province?.name || null, + target_city_name: city?.name || null, + click_through_rate: ad.views_count > 0 ? Number(((ad.clicks_count / ad.views_count) * 100).toFixed(2)) : 0 + } + }) + } catch (error) { + next(error) + } +} + +/** GET /panel/ads/pricing — تبِ دوم: لیستِ قانون‌های قیمت */ +const listPricingRules = async (req, res, next) => { + try { + const rules = await AdPricingRuleModel.find({}).sort({ createdAt: -1 }).lean() + const provinceIds = [...new Set(rules.map((r) => r.province_id).filter((v) => v != null))] + const provinces = provinceIds.length + ? await ProvinceModel.find({ id: { $in: provinceIds } }).lean() + : [] + const provincesById = Object.fromEntries(provinces.map((p) => [p.id, p.name])) + + const rows = rules.map((r) => ({ + ...r, + province_name: r.province_id != null ? provincesById[r.province_id] || null : null + })) + + return res.json({ rules: rows }) + } catch (error) { + next(error) + } +} + +/** POST /panel/ads/pricing — افزودنِ قانونِ قیمتِ یه سناریوی خاص */ +const createPricingRule = async (req, res, next) => { + try { + const { ad_type: adType, province_id: provinceId, gender, expertise, sub_expertise: subExpertise, price } = req.body + if (!AD_TYPES.includes(adType)) { + return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' }) + } + if (!price || Number(price) <= 0) { + return res.status(422).json({ error: true, message: 'قیمت نامعتبر است' }) + } + if (gender && !['female', 'male'].includes(gender)) { + return res.status(422).json({ error: true, message: 'جنسیت نامعتبر است' }) + } + + const rule = await AdPricingRuleModel.create({ + ad_type: adType, + province_id: provinceId != null && provinceId !== '' ? Number(provinceId) : null, + gender: gender || null, + expertise: expertise ? String(expertise).trim() : null, + sub_expertise: subExpertise ? String(subExpertise).trim() : null, + price: Number(price) + }) + + return res.status(201).json({ rule }) + } catch (error) { + next(error) + } +} + +/** PATCH /panel/ads/pricing/:id */ +const updatePricingRule = async (req, res, next) => { + try { + const rule = await AdPricingRuleModel.findById(req.params.id) + if (!rule) { + return res.status(404).json({ error: true, message: 'قانون یافت نشد' }) + } + const { price, gender, expertise, sub_expertise: subExpertise, province_id: provinceId } = req.body + if (price !== undefined) rule.price = Number(price) + if (gender !== undefined) rule.gender = gender || null + if (expertise !== undefined) rule.expertise = expertise ? String(expertise).trim() : null + if (subExpertise !== undefined) rule.sub_expertise = subExpertise ? String(subExpertise).trim() : null + if (provinceId !== undefined) rule.province_id = provinceId != null && provinceId !== '' ? Number(provinceId) : null + await rule.save() + return res.json({ rule }) + } catch (error) { + next(error) + } +} + +/** DELETE /panel/ads/pricing/:id */ +const deletePricingRule = async (req, res, next) => { + try { + await AdPricingRuleModel.deleteOne({ _id: req.params.id }) + return res.json({ success: true }) + } catch (error) { + next(error) + } +} + +module.exports = { + listAds, + getAdDetailPanel, + listPricingRules, + createPricingRule, + updatePricingRule, + deletePricingRule +} diff --git a/controllers/panel/notifications/notificationsController.js b/controllers/panel/notifications/notificationsController.js index 5b7bac0..766ace6 100644 --- a/controllers/panel/notifications/notificationsController.js +++ b/controllers/panel/notifications/notificationsController.js @@ -3,8 +3,12 @@ const NotificationModel = require('../../../models/NotificationModel') const UserModel = require('../../../models/UserModel') const jMoment = require('moment-jalaali') const { sendPushToTokens } = require('../../../utils/pushNotification') +const { buildStorageUrl } = require('../../../utils/storageUrl') -/** ارسال اعلان همگانی — مثل ارسال پیامک، به همه‌ی کاربران */ +/** ارسال اعلان همگانی — مثل ارسال پیامک، به همه‌ی کاربران. بنر اختیاریه (multer: + * routes/panel/notifications/index.js) — هم توی خودِ پوشِ FCM (notification.image، + * از قبل توی utils/pushNotification.js پشتیبانی می‌شد) و هم توی ردیفِ دیتابیسِ هر + * کاربر (برای لیستِ داخلِ اپ) ذخیره می‌شه */ const createBroadcast = async (req, res, next) => { try { const { title, body } = req.body @@ -12,11 +16,14 @@ const createBroadcast = async (req, res, next) => { return res.status(400).json({ message: 'عنوان و متن اعلان الزامی است' }) } + const image = req.file ? buildStorageUrl(`/broadcasts/${req.file.filename}`) : null + const users = await UserModel.find({}).select('_id fcm_tokens fcm_web_tokens').lean() const broadcast = await AdminBroadcastModel.create({ title, body, + image, recipientCount: users.length, sentBy: req.admin?._id }) @@ -28,6 +35,7 @@ const createBroadcast = async (req, res, next) => { type: 'admin_broadcast', title, description: body, + image, broadcast_id: broadcast._id })) ) @@ -39,7 +47,8 @@ const createBroadcast = async (req, res, next) => { title, description: body, type: 'admin_broadcast', - data: { broadcastId: String(broadcast._id) } + data: { broadcastId: String(broadcast._id) }, + image }).catch((err) => console.error('[notificationsController] ارسال پوش همگانی خطا:', err.message)) } diff --git a/helpers/chatHelpers.js b/helpers/chatHelpers.js index 860a30a..2b7c808 100644 --- a/helpers/chatHelpers.js +++ b/helpers/chatHelpers.js @@ -68,7 +68,7 @@ async function enrichMessage(message) { }) } -function emitChatEvents(io, message, senderId, receiverId) { +function emitChatEvents(io, message, senderId, receiverId, pushOverride) { const sid = normalizeId(senderId) const rid = normalizeId(receiverId) const payload = normalizeMessagePayload( @@ -86,21 +86,22 @@ function emitChatEvents(io, message, senderId, receiverId) { message: payload }) - sendChatPushNotification(sid, rid).catch(() => {}) + sendChatPushNotification(sid, rid, pushOverride).catch(() => {}) } -// پیام‌ها E2EE هستن (رمزنگاری سرتاسر) — سرور محتوا رو نمی‌بینه و نباید توی پوش هم نشونش بده، -// فقط اسم فرستنده + یه متن ثابت. عین همین تابع برای همه‌ی مسیرهای emitChatEvents (چت مستقیم، -// پاسخ استوری، اشتراک‌گذاری پست/محصول توی چت) صدا زده می‌شه -async function sendChatPushNotification(senderId, receiverId) { +// پیام‌های متنیِ معمولی E2EE هستن (رمزنگاری سرتاسر) — سرور محتوا رو نمی‌بینه و نباید توی +// پوش هم نشونش بده، فقط اسم فرستنده + یه متن ثابت. اما اشتراک‌گذاریِ پست/محصول (که خودِ +// سرور با دادهٔ واقعیِ دیتابیس می‌سازه، نه متنِ رمزنگاری‌شدهٔ کاربر) می‌تونه با pushOverride +// نوع/متنِ دقیق‌تری بفرسته (نگاه کن به فراخوانی‌ها توی engagementController.js/shopProductController.js) +async function sendChatPushNotification(senderId, receiverId, pushOverride = {}) { const sender = await UserModel.findById(senderId).select('first_name last_name user_name profile_image').lean() if (!sender) return const name = `${sender.first_name || ''} ${sender.last_name || ''}`.trim() || sender.user_name || 'کاربر' await sendPushToUser(receiverId, { title: name, - description: 'یک پیام جدید برای شما ارسال کرد', - type: 'chat_message', - data: { project_post_id: senderId }, + description: pushOverride.description || 'یک پیام جدید برای شما ارسال کرد', + type: pushOverride.type || 'chat_message', + data: { project_post_id: senderId, link: `/settings/chats/${sender.user_name || ''}/${senderId}` }, image: buildStorageUrl(sender.profile_image) || undefined }) } diff --git a/index.js b/index.js index c934870..abe6f53 100644 --- a/index.js +++ b/index.js @@ -161,6 +161,8 @@ app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/ app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'), STATIC_MEDIA_OPTIONS)); app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'), STATIC_MEDIA_OPTIONS)); app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'), STATIC_MEDIA_OPTIONS)); +app.use('/storage/broadcasts', express.static(path.join(__dirname, '../storage/broadcasts'), STATIC_MEDIA_OPTIONS)); +app.use('/storage/broadcasts', express.static(path.join(__dirname, 'storage/broadcasts'), STATIC_MEDIA_OPTIONS)); app.use('/storage', express.static(path.join(__dirname, 'storage'), STATIC_MEDIA_OPTIONS)); require('./boot'); @@ -684,6 +686,9 @@ require('./services/SmartNotificationsCron').setIo(io) require('./services/InstagramMediaSyncCron') require('./utils/notifyFollowers').setIo(io) require('./utils/partnerMatch').setIo(io) +require('./utils/tagNotification').setIo(io) +require('./utils/academyNotification').setIo(io) +require('./utils/storyMentionNotification').setIo(io) cron.schedule('*/30 * * * * *', () => { purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err)) diff --git a/models/AcademyPaymentModel.js b/models/AcademyPaymentModel.js index 28e1923..79330a1 100644 --- a/models/AcademyPaymentModel.js +++ b/models/AcademyPaymentModel.js @@ -14,6 +14,9 @@ const modelSchema = new mongoose.Schema({ payment_authority: { type: String }, payment_ref_id: { type: String }, status: { type: String, enum: ["pending", "success", "failed", "settled"], default: "success" }, + // فقط «قصدِ» استفاده از کیف پول رو نگه می‌داره تا لحظه‌ی تاییدِ verify — کسرِ واقعی + // اونجا انجام می‌شه (utils/walletSplit.js) + walletAmountUsed: { type: Number, default: 0 }, }); diff --git a/models/AdModel.js b/models/AdModel.js new file mode 100644 index 0000000..c2883c4 --- /dev/null +++ b/models/AdModel.js @@ -0,0 +1,72 @@ +const mongoose = require('mongoose') +const timestamp = require('mongoose-timestamp') +const mongoosePaginate = require('mongoose-paginate-v2') + +// تبلیغِ کاربری — نوعش تعیین می‌کنه محتواش کجا تزریق می‌شه: first_post (اول فید)، +// first_story (اول نوار استوری)، notification (پوش مستقیم به کاربرهای هدف)، project/ +// product/shop/billboard (اول لیستِ مربوطه، از utils/adDelivery.js:findActiveAd با همون +// ad_type resolve می‌شه). هدف‌گذاری (لوکیشن/تخصص/جنسیت) روی همون فیلدهایی که +// User/Post/exploreAlgorithm.js از قبل استفاده می‌کنن سوار شده (province.id عددیه، نه +// ObjectId — utils/adPricing.js همین رو resolve می‌کنه) +const AD_TYPES = ['first_post', 'first_story', 'notification', 'project', 'product', 'shop', 'billboard'] + +const adSchema = new mongoose.Schema({ + user_id: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + ad_type: { type: String, enum: AD_TYPES, required: true }, + + // محتوا — بسته به ad_type فقط یکی از این پر می‌شه + content_post_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', default: null }, + content_project_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Project', default: null }, + content_shop_product_id: { type: mongoose.Schema.Types.ObjectId, ref: 'ShopProductListing', default: null }, + content_shop_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Shop', default: null }, + content_billboard_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Advertising', default: null }, + // استوری‌های واقعی معمولاً ۲۴ساعته منقضی می‌شن (StoryModel: expires_at) و از فیدِ + // استوریِ معمولی حذف می‌شن، ولی یه کمپینِ تبلیغاتی می‌تونه چند روز فعال بمونه — پس + // مدیا/اورلی‌ها رو موقعِ ساختِ تبلیغ اینجا کپی/اسنپ‌شات می‌کنیم تا مستقل از عمرِ + // طبیعیِ خودِ استوری باشه (content_story_id فقط برای رفرنسِ منبع نگه داشته می‌شه) + content_story_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Story', default: null }, + content_media_path: { type: String, default: null }, + content_media_type: { type: String, default: null }, + content_overlays: { type: mongoose.Schema.Types.Mixed, default: null }, + notification_title: { type: String, trim: true, default: null }, + notification_body: { type: String, trim: true, default: null }, + + // هدف‌گذاری + target_country_id: { type: Number, default: null }, + target_province_id: { type: Number, default: null }, + target_city_id: { type: Number, default: null }, + target_expertise: { type: String, trim: true, default: null }, + target_sub_expertise: { type: [String], default: [] }, + target_gender: { type: String, enum: ['female', 'male', 'any'], default: 'any' }, + + // مالی + price: { type: Number, required: true }, + walletAmountUsed: { type: Number, default: 0 }, + payment_authority: { type: String, default: null }, + + status: { + type: String, + enum: ['pending_payment', 'active', 'expired', 'failed'], + default: 'pending_payment' + }, + started_at: { type: Date, default: null }, + // عین بقیه‌ی کمپین‌های اپ، یه مدت‌زمانِ ثابت (AD_CAMPAIGN_DURATION_DAYS توی + // adsController.js) بعد از فعال‌شدن — بعدش دیگه توی تزریقِ فید/استوری/نوتیف لحاظ نمی‌شه + expires_at: { type: Date, default: null }, + + views_count: { type: Number, default: 0 }, + clicks_count: { type: Number, default: 0 } +}) + +// تزریق (سرچِ یه تبلیغِ فعالِ منطبق روی فید/استوری) و لیستِ «تبلیغ‌های من» دو مصرفِ +// اصلیِ ایندکسن +adSchema.index({ status: 1, ad_type: 1, expires_at: 1 }) +adSchema.index({ user_id: 1, createdAt: -1 }) + +adSchema.plugin(timestamp) +adSchema.plugin(mongoosePaginate) +const AdModel = mongoose.model('Ad', adSchema) +// روی خودِ مدل به‌عنوان استاتیک — تا adsController.js/adsPanelController.js بدونِ +// require جدا به لیستِ معتبرِ ad_type دسترسی داشته باشن (module.exports همون مدله، نه یه آبجکت) +AdModel.AD_TYPES = AD_TYPES +module.exports = AdModel diff --git a/models/AdPricingRuleModel.js b/models/AdPricingRuleModel.js new file mode 100644 index 0000000..7e159f4 --- /dev/null +++ b/models/AdPricingRuleModel.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose') +const timestamp = require('mongoose-timestamp') + +// قانونِ قیمت‌گذاریِ ادمین برای یه «سناریو»ی تبلیغ — هر فیلدِ null یعنی وایلدکارت +// (روی هر مقداری از اون بعد صدق می‌کنه)؛ utils/adPricing.js دقیق‌ترین قانونِ منطبق +// (بیشترین فیلدِ غیرِnull) رو انتخاب می‌کنه +const adPricingRuleSchema = new mongoose.Schema({ + ad_type: { + type: String, + enum: ['first_post', 'first_story', 'notification', 'project', 'product', 'shop', 'billboard'], + required: true + }, + province_id: { type: Number, default: null }, + gender: { type: String, enum: ['female', 'male'], default: null }, + expertise: { type: String, trim: true, default: null }, + sub_expertise: { type: String, trim: true, default: null }, + price: { type: Number, required: true } +}) + +adPricingRuleSchema.index({ ad_type: 1 }) + +adPricingRuleSchema.plugin(timestamp) +const AdPricingRuleModel = mongoose.model('AdPricingRule', adPricingRuleSchema) +module.exports = AdPricingRuleModel diff --git a/models/AdminBroadcastModel.js b/models/AdminBroadcastModel.js index 467ae3a..3d5f59f 100644 --- a/models/AdminBroadcastModel.js +++ b/models/AdminBroadcastModel.js @@ -19,6 +19,11 @@ const adminBroadcastSchema = new mongoose.Schema({ type: Number, default: 0 }, + image: { + type: String, + required: false, + default: null + }, sentBy: { type: mongoose.Schema.Types.ObjectId, ref: 'Admin', diff --git a/models/AdvertisingCommentModel.js b/models/AdvertisingCommentModel.js index 1d4bdb7..bbc434e 100644 --- a/models/AdvertisingCommentModel.js +++ b/models/AdvertisingCommentModel.js @@ -18,6 +18,11 @@ const advertisingCommentSchema = new mongoose.Schema({ required: true, trim: true }, + rate: { + type: Number, + required: false, + default: null + }, status: { type: String, enum: ['pending', 'temp_accept', 'accepted', 'rejected'], diff --git a/models/AutoDirectRuleModel.js b/models/AutoDirectRuleModel.js new file mode 100644 index 0000000..2e4e06a --- /dev/null +++ b/models/AutoDirectRuleModel.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose') +const timestamp = require('mongoose-timestamp') + +const autoDirectRuleSchema = new mongoose.Schema({ + user_id: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + trigger_text: { type: String, required: true, trim: true }, + direct_message: { type: String, required: true, trim: true }, + reply_caption: { type: String, required: true, trim: true }, + is_active: { type: Boolean, default: true }, + // هر بار این قانون واقعاً اجرا بشه (دایرکت ارسال + کامنتِ پاسخ ثبت بشه) یکی زیاد می‌شه — + // برای نمایشِ «تعداد کامنت‌های ارسالی» توی لیستِ AutoDirectScreen.tsx + sent_count: { type: Number, default: 0 } +}) + +// autoDirectTrigger.js هر کامنتِ تازه رو در برابرِ همه‌ی قانون‌های فعالِ صاحبِ پست چک می‌کنه +autoDirectRuleSchema.index({ user_id: 1, is_active: 1 }) + +autoDirectRuleSchema.plugin(timestamp) +const AutoDirectRuleModel = mongoose.model('AutoDirectRule', autoDirectRuleSchema) +module.exports = AutoDirectRuleModel diff --git a/models/NotificationModel.js b/models/NotificationModel.js index 2abe4d2..64153b6 100644 --- a/models/NotificationModel.js +++ b/models/NotificationModel.js @@ -33,6 +33,13 @@ const notificationSchema = new mongoose.Schema({ ref: 'User', required: false }, + // آواتار فرستنده (برای نوتیف‌های مربوط به یه کاربر خاص) یا بنرِ دستیِ ادمین + // (برای admin_broadcast) — هم توی لیستِ داخلِ اپ و هم توی خودِ پوش FCM استفاده می‌شه + image: { + type: String, + required: false, + default: null + }, // فقط برای اعلان‌های همگانی پنل — جدا از read (که یعنی صفحه اعلان‌ها باز شده)، // seen یعنی پوش واقعاً به دستگاه رسیده و clicked یعنی کاربر روش ضربه زده broadcast_id: { diff --git a/models/ShareableLinkModel.js b/models/ShareableLinkModel.js new file mode 100644 index 0000000..4a0ac67 --- /dev/null +++ b/models/ShareableLinkModel.js @@ -0,0 +1,28 @@ +const mongoose = require('mongoose') +const timestamp = require('mongoose-timestamp') + +// اشتراک‌گذاری لینک: کاربر مشخص می‌کنه کدوم محتوای خودش (پکیج/پروژه/بیلبورد/فروشگاه/ +// کالا/رزرو) وقتی یکی دیگه توی ثبت پست تگش می‌کنه، توی لیست لینک‌های قابل‌انتخاب اون +// کاربر (بلاگر) نشون داده بشه — shareableLinksController.js/for-users همین رکوردها رو می‌خونه +const shareableLinkSchema = new mongoose.Schema({ + user_id: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + kind: { + type: String, + enum: ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking'], + required: true + }, + ref_id: { + type: String, + required: true + } +}) + +shareableLinkSchema.index({ user_id: 1, kind: 1, ref_id: 1 }, { unique: true }) + +shareableLinkSchema.plugin(timestamp) +const ShareableLinkModel = mongoose.model('ShareableLink', shareableLinkSchema) +module.exports = ShareableLinkModel diff --git a/routes/application/ads/index.js b/routes/application/ads/index.js new file mode 100644 index 0000000..097cee1 --- /dev/null +++ b/routes/application/ads/index.js @@ -0,0 +1,23 @@ +const express = require('express') +const { + getAdPricing, + createAd, + verifyAdPayment, + getMyAds, + getAdDetail, + getSponsoredAd, + trackAdEventEndpoint +} = require('../../../controllers/application/ads/adsController') +const blockCheck = require('../../../middlewares/blockCheck') + +const router = express.Router() + +router.get('/pricing', [blockCheck], getAdPricing) +router.post('/', [blockCheck], createAd) +router.get('/payment/verify', verifyAdPayment) +router.get('/mine', [blockCheck], getMyAds) +router.get('/sponsored', [blockCheck], getSponsoredAd) +router.get('/:id', [blockCheck], getAdDetail) +router.post('/:id/track', [blockCheck], trackAdEventEndpoint) + +module.exports = router diff --git a/routes/application/autoDirect/index.js b/routes/application/autoDirect/index.js new file mode 100644 index 0000000..4a93f06 --- /dev/null +++ b/routes/application/autoDirect/index.js @@ -0,0 +1,17 @@ +const express = require('express') +const { + getMyAutoDirectRules, + createAutoDirectRule, + updateAutoDirectRule, + deleteAutoDirectRule +} = require('../../../controllers/application/autoDirect/autoDirectController') +const blockCheck = require('../../../middlewares/blockCheck') + +const router = express.Router() + +router.get('/mine', [blockCheck], getMyAutoDirectRules) +router.post('/', [blockCheck], createAutoDirectRule) +router.patch('/:id', [blockCheck], updateAutoDirectRule) +router.delete('/:id', [blockCheck], deleteAutoDirectRule) + +module.exports = router diff --git a/routes/application/geo/index.js b/routes/application/geo/index.js new file mode 100644 index 0000000..d4b7d6d --- /dev/null +++ b/routes/application/geo/index.js @@ -0,0 +1,6 @@ +const express = require('express') +const { checkGeo } = require('../../../controllers/application/geo/geoController') +const router = express.Router() +router.get('/check', checkGeo) + +module.exports = router diff --git a/routes/application/shareableLinks/index.js b/routes/application/shareableLinks/index.js new file mode 100644 index 0000000..c106a29 --- /dev/null +++ b/routes/application/shareableLinks/index.js @@ -0,0 +1,15 @@ +const express = require('express') +const { + getMyShareableLinks, + toggleShareableLink, + getShareableLinksForUsers +} = require('../../../controllers/application/shareableLinks/shareableLinksController') +const blockCheck = require('../../../middlewares/blockCheck') + +const router = express.Router() + +router.get('/mine', [blockCheck], getMyShareableLinks) +router.post('/toggle', [blockCheck], toggleShareableLink) +router.get('/for-users', [blockCheck], getShareableLinksForUsers) + +module.exports = router diff --git a/routes/index.js b/routes/index.js index beecf92..2ae9d3d 100644 --- a/routes/index.js +++ b/routes/index.js @@ -16,6 +16,9 @@ const ordersRouter = require('./application/orders') const shopOrderPaymentRouter = require('./application/payment/shop-order') const walletRouter = require('./application/wallet') const bookingsRouter = require('./application/bookings') +const autoDirectRouter = require('./application/autoDirect') +const shareableLinksRouter = require('./application/shareableLinks') +const adsRouter = require('./application/ads') const profileRouter = require('./application/profile') const workroomRouter = require('./application/workroom') const messagesRouter = require('./application/messages') @@ -36,10 +39,12 @@ const giftsRouter = require('./application/gifts') const chatRoomsRouter = require('./application/chat-rooms') const suggestionsRouter = require('./application/suggestions') const analyticsRouter = require('./application/analytics') +const geoRouter = require('./application/geo') const projectsPanelRouter = require('./panel/projects') const postsPanelRouter = require('./panel/posts') const expertisePanelRouter = require('./panel/expertise') +const adsPanelRouter = require('./panel/ads') const shopCategoriesPanelRouter = require('./panel/shop-categories') const marketplaceShopsPanelRouter = require('./panel/marketplace-shops') const walletRequestsPanelRouter = require('./panel/wallet-requests') @@ -89,6 +94,9 @@ module.exports = (app) => { app.use('/api/v1/payment/shop-order', shopOrderPaymentRouter) app.use('/api/v1/wallet', walletRouter) app.use('/api/v1/bookings', bookingsRouter) + app.use('/api/v1/auto-direct', autoDirectRouter) + app.use('/api/v1/shareable-links', shareableLinksRouter) + app.use('/api/v1/ads', adsRouter) app.use('/api/v1/profile', profileRouter) app.use('/api/v1/account', accountRouter) app.use('/api/v1/gifts', giftsRouter) @@ -106,6 +114,7 @@ module.exports = (app) => { app.use('/api/v1/advertising', advertisingRouter) app.use('/api/v1/settings', settingsRouter) app.use('/api/v1/version', versionRouter) + app.use('/api/v1/geo', geoRouter) app.use('/api/v1/academy', academyRoute) app.use('/api/v1/stories', storiesRouter) @@ -118,6 +127,7 @@ module.exports = (app) => { app.use('/api/v1/panel/projects', [adminAuth, requirePermission('projects')], projectsPanelRouter) app.use('/api/v1/panel/posts', [adminAuth, requirePermission('posts')], postsPanelRouter) app.use('/api/v1/panel/expertise', [adminAuth, requirePermission('expertise')], expertisePanelRouter) + app.use('/api/v1/panel/ads', [adminAuth, requirePermission('ads')], adsPanelRouter) app.use('/api/v1/panel/shop-categories', [adminAuth, requirePermission('shop-categories')], shopCategoriesPanelRouter) app.use('/api/v1/panel/marketplace-shops', [adminAuth, requirePermission('marketplace-shops')], marketplaceShopsPanelRouter) app.use('/api/v1/panel/wallet-requests', [adminAuth, requirePermission('wallet-requests')], walletRequestsPanelRouter) diff --git a/routes/panel/ads/index.js b/routes/panel/ads/index.js new file mode 100644 index 0000000..e10dc33 --- /dev/null +++ b/routes/panel/ads/index.js @@ -0,0 +1,21 @@ +const express = require('express') +const { + listAds, + getAdDetailPanel, + listPricingRules, + createPricingRule, + updatePricingRule, + deletePricingRule +} = require('../../../controllers/panel/ads/adsPanelController') + +const router = express.Router() + +router.get('/pricing', listPricingRules) +router.post('/pricing', createPricingRule) +router.patch('/pricing/:id', updatePricingRule) +router.delete('/pricing/:id', deletePricingRule) + +router.get('/', listAds) +router.get('/:id', getAdDetailPanel) + +module.exports = router diff --git a/routes/panel/notifications/index.js b/routes/panel/notifications/index.js index 2ab216e..a5062a5 100644 --- a/routes/panel/notifications/index.js +++ b/routes/panel/notifications/index.js @@ -1,7 +1,31 @@ const express = require('express') +const path = require('path') +const fs = require('fs') +const multer = require('multer') const { createBroadcast, getBroadcasts, getBroadcastDetail } = require('../../../controllers/panel/notifications/notificationsController') const router = express.Router() + +const broadcastStorageDir = path.join(__dirname, '../../../storage/broadcasts') +if (!fs.existsSync(broadcastStorageDir)) { + fs.mkdirSync(broadcastStorageDir, { recursive: true }) +} + +const broadcastUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, broadcastStorageDir), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname) || '.jpg' + cb(null, `${Date.now()}-broadcast${ext}`) + } + }), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (file.mimetype.startsWith('image/')) return cb(null, true) + cb(new Error('فقط تصویر مجاز است'), false) + } +}).single('image') + router.get('/', getBroadcasts) router.get('/:id', getBroadcastDetail) -router.post('/broadcast', createBroadcast) +router.post('/broadcast', broadcastUpload, createBroadcast) module.exports = router diff --git a/utils/academyNotification.js b/utils/academyNotification.js index 4f67503..2031f55 100644 --- a/utils/academyNotification.js +++ b/utils/academyNotification.js @@ -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, }; diff --git a/utils/adDelivery.js b/utils/adDelivery.js new file mode 100644 index 0000000..56177a2 --- /dev/null +++ b/utils/adDelivery.js @@ -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 +} diff --git a/utils/adPricing.js b/utils/adPricing.js new file mode 100644 index 0000000..37d5233 --- /dev/null +++ b/utils/adPricing.js @@ -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 } diff --git a/utils/autoDirectTrigger.js b/utils/autoDirectTrigger.js new file mode 100644 index 0000000..70650e9 --- /dev/null +++ b/utils/autoDirectTrigger.js @@ -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 } diff --git a/utils/notifyUser.js b/utils/notifyUser.js index f8c6f42..8215b9b 100644 --- a/utils/notifyUser.js +++ b/utils/notifyUser.js @@ -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(() => {}) } diff --git a/utils/pushNotification.js b/utils/pushNotification.js index f92e9aa..64cdde5 100644 --- a/utils/pushNotification.js +++ b/utils/pushNotification.js @@ -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 } } } ) diff --git a/utils/resolveLinkedEntity.js b/utils/resolveLinkedEntity.js index 9af87f5..0ebf752 100644 --- a/utils/resolveLinkedEntity.js +++ b/utils/resolveLinkedEntity.js @@ -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 }; \ No newline at end of file diff --git a/utils/shareableLinkDisplay.js b/utils/shareableLinkDisplay.js new file mode 100644 index 0000000..2fd7528 --- /dev/null +++ b/utils/shareableLinkDisplay.js @@ -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 } diff --git a/utils/storyMentionNotification.js b/utils/storyMentionNotification.js new file mode 100644 index 0000000..5c89818 --- /dev/null +++ b/utils/storyMentionNotification.js @@ -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 } diff --git a/utils/storyRingFlags.js b/utils/storyRingFlags.js new file mode 100644 index 0000000..1fb9586 --- /dev/null +++ b/utils/storyRingFlags.js @@ -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 } diff --git a/utils/tagNotification.js b/utils/tagNotification.js index f4906dd..86295b1 100644 --- a/utils/tagNotification.js +++ b/utils/tagNotification.js @@ -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 } diff --git a/utils/vpnCheck.js b/utils/vpnCheck.js new file mode 100644 index 0000000..d6fe04b --- /dev/null +++ b/utils/vpnCheck.js @@ -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 }