129 lines
6.3 KiB
JavaScript
129 lines
6.3 KiB
JavaScript
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 }
|