Files
modstagram-back/helpers/chatHelpers.js
payacom 1954b2661d Add shop-chat push notifications and a "send shop to users" endpoint
- shop-chat messages (buyer <-> shop owner) never sent any push notification
  at all — sendShopChatPushNotification (chatHelpers.js) now fires on every
  send, mirroring the existing regular-chat push path; data payload carries
  shop_id/buyer_id/sender_role so clients can route directly to the thread
- New POST /shops/:id/send, mirroring the existing shop-product send
  endpoint — lets a user share a shop itself (not just one product) into a
  regular chat message as a sharedShop payload

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:05:19 +03:30

170 lines
6.6 KiB
JavaScript

const moment = require('moment-jalaali')
const MessageModel = require('../models/MessageModel')
const UserModel = require('../models/UserModel')
const { opaquePreview, isE2eeEnvelope } = require('../utils/e2ee')
const { sendPushToUser } = require('../utils/pushNotification')
const { buildStorageUrl } = require('../utils/storageUrl')
function formatTimeOnly(date) {
return moment(date).locale('fa').format('HH:mm:ss')
}
function normalizeId(value) {
if (value == null) return "";
return String(value).trim();
}
function normalizeMessagePayload(message) {
return {
...message,
_id: message._id != null ? String(message._id) : message._id,
senderId: normalizeId(message.senderId),
receiverId: normalizeId(message.receiverId),
};
}
async function enrichMessage(message) {
const doc = message._doc ? { ...message._doc } : { ...message }
let replyTo = null
if (doc.replyToId) {
const parent = await MessageModel.findById(doc.replyToId).lean()
if (parent) {
const parentUser =
String(parent.senderId) === String(doc.senderId)
? await UserModel.findById(doc.senderId).select('first_name last_name user_name').lean()
: await UserModel.findById(parent.senderId).select('first_name last_name user_name').lean()
replyTo = {
_id: parent._id,
content: opaquePreview(parent.content, 200),
senderName: parentUser
? `${parentUser.first_name || ''} ${parentUser.last_name || ''}`.trim() || parentUser.user_name
: 'کاربر'
}
}
}
return normalizeMessagePayload({
...doc,
createdAt: formatTimeOnly(doc.createdAt),
createdAtIso: doc.createdAt ? new Date(doc.createdAt).toISOString() : undefined,
expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined,
selfDestructSeconds: doc.selfDestructSeconds || undefined,
selfDestructStartedAt: doc.selfDestructStartedAt
? new Date(doc.selfDestructStartedAt).toISOString()
: undefined,
viewOnce: Boolean(doc.viewOnce),
viewOnceLocked: Boolean(doc.viewOnceLocked),
viewOnceExpired: Boolean(doc.viewOnceExpired),
viewOnceOpenedAt: doc.viewOnceOpenedAt
? new Date(doc.viewOnceOpenedAt).toISOString()
: undefined,
replyTo,
forwardedFrom: doc.forwardedFrom || undefined,
reactions: (doc.reactions || []).map((r) => ({
userId: normalizeId(r.userId),
emoji: r.emoji
}))
})
}
function emitChatEvents(io, message, senderId, receiverId) {
const sid = normalizeId(senderId)
const rid = normalizeId(receiverId)
const payload = normalizeMessagePayload(
typeof message === "object" && message !== null ? { ...message } : { content: message }
)
const roomA = `chat:${sid}:${rid}`
const roomB = `chat:${rid}:${sid}`
const userSender = `user:${sid}`
const userReceiver = `user:${rid}`
io.to(roomA).to(roomB).to(userSender).to(userReceiver).emit('newMessage', payload)
io.to(userSender).to(userReceiver).emit('chatListUpdate', {
senderId: sid,
receiverId: rid,
message: payload
})
sendChatPushNotification(sid, rid).catch(() => {})
}
// پیام‌ها E2EE هستن (رمزنگاری سرتاسر) — سرور محتوا رو نمی‌بینه و نباید توی پوش هم نشونش بده،
// فقط اسم فرستنده + یه متن ثابت. عین همین تابع برای همه‌ی مسیرهای emitChatEvents (چت مستقیم،
// پاسخ استوری، اشتراک‌گذاری پست/محصول توی چت) صدا زده می‌شه
async function sendChatPushNotification(senderId, receiverId) {
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 },
image: buildStorageUrl(sender.profile_image) || undefined
})
}
// چتِ فروشگاه (خریدار <-> مالکِ فروشگاه) قبلاً هیچ پوشی نمی‌فرستاد — کاملاً جدا از
// sendChatPushNotification بالا چون فرستنده می‌تونه «فروشگاه» باشه، نه یه کاربرِ ساده،
// پس تایتل/عکسِ نوتیف باید از روی نقشِ فرستنده (shop یا buyer) انتخاب بشه
async function sendShopChatPushNotification({ shop, senderId, senderRole, receiverId, buyerId }) {
let title
let image
if (senderRole === 'shop') {
title = shop?.name || 'فروشگاه'
image = buildStorageUrl(shop?.logo) || undefined
} else {
const sender = await UserModel.findById(senderId).select('first_name last_name user_name profile_image').lean()
if (!sender) return
title = `${sender.first_name || ''} ${sender.last_name || ''}`.trim() || sender.user_name || 'کاربر'
image = buildStorageUrl(sender.profile_image) || undefined
}
await sendPushToUser(receiverId, {
title,
description: 'یک پیام جدید برای شما ارسال کرد',
type: 'shop_chat_message',
// buyer_id همیشه لازمه تا مالکِ فروشگاه (که می‌تونه چند نخِ گفتگو با خریدارهای مختلف
// داشته باشه) بدونه دقیقاً کدوم نخ رو باز کنه؛ برای خودِ خریدار هم همون id خودشه
data: { shop_id: String(shop?._id || ''), sender_role: senderRole, buyer_id: String(buyerId || '') },
image
})
}
function parseForwardedContent(content) {
if (!content) return { body: '', forwardedFrom: null }
// پاکت E2EE را دست نزن — JSON.parse + regex قبلی ciphertext را خراب می‌کرد
if (typeof content === 'string' && isE2eeEnvelope(content)) {
return { body: content, forwardedFrom: null }
}
if (content && typeof content === 'object' && content.e2ee === true) {
return { body: JSON.stringify(content), forwardedFrom: null }
}
try {
const line = String(content).split('\n')[0]
const j = JSON.parse(line)
if (j.forwardedFrom && !j.e2ee) {
const rest = String(content).includes('\n')
? String(content).slice(String(content).indexOf('\n') + 1).trim()
: ''
return {
forwardedFrom: j.forwardedFrom,
body: rest
}
}
} catch (_) {
/* not json */
}
return { body: typeof content === 'string' ? content : String(content), forwardedFrom: null }
}
module.exports = {
formatTimeOnly,
enrichMessage,
emitChatEvents,
parseForwardedContent,
sendShopChatPushNotification
}