const admin = require('firebase-admin') // firebase-admin v12+ دیگه admin.messaging(app) رو صادر نمی‌کنه — باید از ساب‌ماژول جدا بگیریم const { getMessaging } = require('firebase-admin/messaging') const UserModel = require('../models/UserModel') let firebaseApp = null let warnedMissingCredential = false function getFirebaseApp () { if (firebaseApp) return firebaseApp const raw = process.env.FIREBASE_SERVICE_ACCOUNT_BASE64 if (!raw) { // بدون این هشدار، ارسال پوش کاملاً بی‌صدا هیچ‌کاری نمی‌کنه (نوتیف داخل‌برنامه‌ای و // سوکت درست کار می‌کنن، فقط پوش واقعاً نمی‌ره) و تشخیصش خیلی سخت می‌شه — یک‌بار در طول // عمر پروسه لاگ می‌کنیم تا توی لاگ سرور واضح دیده بشه if (!warnedMissingCredential) { warnedMissingCredential = true console.error('[pushNotification] FIREBASE_SERVICE_ACCOUNT_BASE64 تنظیم نشده — ارسال پوش نوتیفیکیشن غیرفعاله (نوتیف داخل‌برنامه‌ای همچنان کار می‌کنه)') } return null } try { const serviceAccount = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) // firebase-admin v12+ آبجکت credential رو دیگه تو‌درتو صادر نمی‌کنه (admin.cert مستقیم)؛ // نسخه‌های قدیمی‌تر هنوز admin.credential.cert دارن — هر دو رو پشتیبانی می‌کنیم const cert = admin.credential?.cert ?? admin.cert firebaseApp = admin.initializeApp({ credential: cert(serviceAccount) }) return firebaseApp } catch (error) { console.error('[pushNotification] پیکربندی FIREBASE_SERVICE_ACCOUNT_BASE64 نامعتبر است:', error.message) return null } } /** * ارسال پوش به یه لیست توکن مشخص — پایه‌ی مسیرهای دسته‌جمعی (فالوورها، پیام‌های همگانی پنل) * 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: عکس/آواتار نمایش داده‌شده توی خودِ نوتیف (وب/آی‌اواس، و اندرویدِ // dataOnly:false) — عین رفتار واتساپ/اینستاگرام برای نوتیف پیام ...(dataOnly ? {} : { notification: { title, body: description, ...(image ? { image } : {}) } }), data: Object.fromEntries( Object.entries(dataPayload).map(([k, v]) => [k, String(v ?? '')]) ), tokens: cleanTokens } const invalidTokens = [] let successCount = 0 // FCM حداکثر ۵۰۰ توکن در هر multicast قبول می‌کنه for (let i = 0; i < cleanTokens.length; i += 500) { const chunk = cleanTokens.slice(i, i + 500) try { const messaging = admin.messaging ? admin.messaging(app) : getMessaging(app) const res = await messaging.sendEachForMulticast({ ...message, tokens: chunk }) successCount += res.successCount res.responses.forEach((r, idx) => { if (!r.success && r.error?.code === 'messaging/registration-token-not-registered') { invalidTokens.push(chunk[idx]) } }) } catch (error) { console.error('[pushNotification] خطا در ارسال دسته‌ای:', error.message) } } 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 mobileTokens = user?.fcm_tokens || [] const webTokens = user?.fcm_web_tokens || [] if (mobileTokens.length === 0 && webTokens.length === 0) return 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: mobileResult.invalidTokens }, fcm_web_tokens: { $in: webResult.invalidTokens } } } ) } } catch (error) { console.error('[pushNotification] sendPushToUser خطا:', error.message) } } module.exports = { sendPushToUser, sendPushToTokens, getFirebaseApp }