full
This commit is contained in:
698
controllers/application/chatRooms/chatRoomController.js
Normal file
698
controllers/application/chatRooms/chatRoomController.js
Normal file
@@ -0,0 +1,698 @@
|
||||
const mongoose = require('mongoose')
|
||||
const path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const crypto = require('crypto')
|
||||
const moment = require('moment-jalaali')
|
||||
const ChatRoomModel = require('../../../models/ChatRoomModel')
|
||||
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
|
||||
const {
|
||||
slugifyTitle,
|
||||
userCanSeeRoom,
|
||||
resolveRoomOnlineCount,
|
||||
resolveIdleTimeoutMs
|
||||
} = require('./chatRoomHelpers')
|
||||
const { opaquePreview } = require('../../../utils/e2ee')
|
||||
|
||||
const DEFAULT_ROOM = {
|
||||
title: 'چت روم مدستاگرام',
|
||||
slug: 'modstagram-community',
|
||||
description: 'گفتگوی گروهی کاربران مدستاگرام'
|
||||
}
|
||||
|
||||
function formatTimeOnly(date) {
|
||||
return moment(date).locale('fa').format('HH:mm:ss')
|
||||
}
|
||||
|
||||
async function ensureDefaultRoom() {
|
||||
let room = await ChatRoomModel.findOne({ slug: DEFAULT_ROOM.slug })
|
||||
if (!room) {
|
||||
room = await ChatRoomModel.create({
|
||||
...DEFAULT_ROOM,
|
||||
isSystem: true,
|
||||
lastActivityAt: new Date()
|
||||
})
|
||||
} else if (!room.isSystem) {
|
||||
room.isSystem = true
|
||||
if (!room.lastActivityAt) room.lastActivityAt = new Date()
|
||||
await room.save()
|
||||
}
|
||||
return room
|
||||
}
|
||||
|
||||
async function enrichRoomMessage(message) {
|
||||
const doc = message._doc ? { ...message._doc } : { ...message }
|
||||
const sender = await UserModel.findById(doc.senderId)
|
||||
.select('first_name last_name user_name profile_image is_verified verify_badge')
|
||||
.lean()
|
||||
|
||||
let replyTo = null
|
||||
if (doc.replyToId) {
|
||||
const parent = await ChatRoomMessageModel.findById(doc.replyToId).lean()
|
||||
if (parent) {
|
||||
const parentUser = 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 {
|
||||
...doc,
|
||||
_id: String(doc._id),
|
||||
roomId: String(doc.roomId),
|
||||
senderId: String(doc.senderId),
|
||||
createdAt: formatTimeOnly(doc.createdAt),
|
||||
createdAtIso: doc.createdAt ? new Date(doc.createdAt).toISOString() : undefined,
|
||||
reactions: (doc.reactions || []).map((r) => ({
|
||||
userId: String(r.userId),
|
||||
emoji: r.emoji,
|
||||
})),
|
||||
sender: sender
|
||||
? {
|
||||
_id: String(sender._id),
|
||||
first_name: sender.first_name,
|
||||
last_name: sender.last_name,
|
||||
user_name: sender.user_name,
|
||||
profile_image: sender.profile_image,
|
||||
is_verified: sender.is_verified,
|
||||
verify_badge: sender.verify_badge
|
||||
}
|
||||
: undefined,
|
||||
replyTo
|
||||
}
|
||||
}
|
||||
|
||||
function emitGroupMessage(io, roomId, message, memberIds = []) {
|
||||
if (!io) return
|
||||
const payload = { ...message, roomId: String(message.roomId ?? roomId) }
|
||||
io.to(`group:${roomId}`).emit('newGroupMessage', payload)
|
||||
|
||||
const members = [...new Set((memberIds || []).map((id) => String(id)).filter(Boolean))]
|
||||
for (const memberId of members) {
|
||||
io.to(`user:${memberId}`).emit('newGroupMessage', payload)
|
||||
}
|
||||
}
|
||||
|
||||
async function serializeRoom(room, io, userId) {
|
||||
const memberCount = room.members?.length || 0
|
||||
const isMember = room.members?.some((m) => String(m) === String(userId))
|
||||
const onlineCount = await resolveRoomOnlineCount(io, room)
|
||||
return {
|
||||
_id: String(room._id),
|
||||
title: room.title,
|
||||
description: room.description,
|
||||
image: room.image,
|
||||
province: room.province || null,
|
||||
city: room.city || null,
|
||||
expertise: room.expertise || null,
|
||||
visibility: room.visibility || 'public',
|
||||
memberCount,
|
||||
isMember,
|
||||
onlineCount,
|
||||
isSystem: Boolean(room.isSystem),
|
||||
createdBy: room.createdBy ? String(room.createdBy) : null,
|
||||
lastActivityAt: room.lastActivityAt,
|
||||
idleTimeoutMs: room.idleTimeoutMs || null
|
||||
}
|
||||
}
|
||||
|
||||
const listRooms = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const io = req.app.get('io')
|
||||
await ensureDefaultRoom()
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('province city expertise')
|
||||
.lean()
|
||||
|
||||
const rooms = await ChatRoomModel.find({}).sort({ lastActivityAt: -1 }).lean()
|
||||
|
||||
const enriched = await Promise.all(
|
||||
rooms
|
||||
.filter((room) => userCanSeeRoom(user, room))
|
||||
.map(async (room) => {
|
||||
const lastMessage = await ChatRoomMessageModel.findOne({ roomId: room._id })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
return {
|
||||
...(await serializeRoom(room, io, userId)),
|
||||
lastMessage: lastMessage
|
||||
? {
|
||||
content: opaquePreview(lastMessage.content, 80),
|
||||
createdAt: formatTimeOnly(lastMessage.createdAt)
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ rooms: enriched })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const createRoom = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const io = req.app.get('io')
|
||||
const { title, province_id, city_id, expertise, visibility, member_ids, idle_timeout_days } = req.body
|
||||
|
||||
if (!title || !String(title).trim()) {
|
||||
return res.status(422).json({ error: 'نام اتاق الزامی است' })
|
||||
}
|
||||
|
||||
const roomVisibility =
|
||||
String(visibility || 'public').trim() === 'private' ? 'private' : 'public'
|
||||
|
||||
let invitedMemberIds = []
|
||||
if (member_ids) {
|
||||
try {
|
||||
const parsed =
|
||||
typeof member_ids === 'string' ? JSON.parse(member_ids) : member_ids
|
||||
if (Array.isArray(parsed)) {
|
||||
invitedMemberIds = parsed
|
||||
.map((id) => String(id).trim())
|
||||
.filter((id) => mongoose.Types.ObjectId.isValid(id))
|
||||
}
|
||||
} catch {
|
||||
invitedMemberIds = []
|
||||
}
|
||||
}
|
||||
|
||||
if (roomVisibility === 'private' && invitedMemberIds.length === 0) {
|
||||
return res.status(422).json({
|
||||
error: 'برای اتاق خصوصی، افزودن حداقل یک کاربر الزامی است',
|
||||
})
|
||||
}
|
||||
|
||||
let province = null
|
||||
let city = null
|
||||
|
||||
if (province_id) {
|
||||
province = await ProvinceModel.findOne({ id: Number(province_id) }).lean()
|
||||
if (!province) {
|
||||
return res.status(404).json({ error: 'استان یافت نشد' })
|
||||
}
|
||||
}
|
||||
|
||||
if (city_id) {
|
||||
city = await CityModel.findOne({ id: Number(city_id) }).lean()
|
||||
if (!city) {
|
||||
return res.status(404).json({ error: 'شهر یافت نشد' })
|
||||
}
|
||||
if (!province && city.province_id) {
|
||||
province = await ProvinceModel.findOne({ id: city.province_id }).lean()
|
||||
}
|
||||
}
|
||||
|
||||
let imagePath = null
|
||||
const imageFile = req.files?.room_image
|
||||
if (imageFile) {
|
||||
const uploadDir = path.join(__dirname, '../../../storage/chat-rooms')
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true })
|
||||
}
|
||||
// اسمِ فایل هیچوقت مستقیم از ورودیِ کاربر (imageFile.name) گرفته نمیشه — فقط پسوندش
|
||||
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(imageFile.name || '')}`
|
||||
const filePath = path.join(uploadDir, uniqueFileName)
|
||||
await fs.move(imageFile.path, filePath)
|
||||
imagePath = `/chat-rooms/${uniqueFileName}`
|
||||
}
|
||||
|
||||
const expertiseValue =
|
||||
expertise && String(expertise).trim() ? String(expertise).trim() : undefined
|
||||
|
||||
const members = [userId, ...invitedMemberIds.filter((id) => id !== userId)]
|
||||
|
||||
const room = await ChatRoomModel.create({
|
||||
title: String(title).trim(),
|
||||
slug: `${slugifyTitle(title)}-${Date.now()}`,
|
||||
image: imagePath,
|
||||
province:
|
||||
roomVisibility === 'public' && province
|
||||
? { id: province.id, name: province.name }
|
||||
: undefined,
|
||||
city:
|
||||
roomVisibility === 'public' && city
|
||||
? { id: city.id, name: city.name }
|
||||
: undefined,
|
||||
expertise: roomVisibility === 'public' ? expertiseValue : undefined,
|
||||
visibility: roomVisibility,
|
||||
members,
|
||||
createdBy: userId,
|
||||
lastActivityAt: new Date(),
|
||||
idleTimeoutMs: resolveIdleTimeoutMs(
|
||||
idle_timeout_days ? Number(idle_timeout_days) * 24 * 60 * 60 * 1000 : undefined
|
||||
),
|
||||
isSystem: false
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
room: await serializeRoom(room.toObject(), io, userId)
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { roomId } = req.params
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId)) {
|
||||
return res.status(400).json({ error: 'Invalid room id' })
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('province city expertise')
|
||||
.lean()
|
||||
const room = await ChatRoomModel.findById(roomId)
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (!userCanSeeRoom(user, room)) {
|
||||
return res.status(403).json({ error: 'Access denied' })
|
||||
}
|
||||
|
||||
if (!room.members?.some((m) => String(m) === userId)) {
|
||||
room.members = room.members || []
|
||||
room.members.push(userId)
|
||||
await room.save()
|
||||
}
|
||||
|
||||
res.json({
|
||||
room: {
|
||||
_id: String(room._id),
|
||||
title: room.title,
|
||||
description: room.description,
|
||||
image: room.image,
|
||||
isMember: true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoomMessages = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { roomId } = req.params
|
||||
const page = parseInt(req.query.page, 10) || 1
|
||||
const limit = parseInt(req.query.limit, 10) || 50
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId)) {
|
||||
return res.status(400).json({ error: 'Invalid room id' })
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('province city expertise')
|
||||
.lean()
|
||||
const room = await ChatRoomModel.findById(roomId)
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (!userCanSeeRoom(user, room)) {
|
||||
return res.status(403).json({ error: 'Access denied' })
|
||||
}
|
||||
|
||||
const isMember = room.members?.some((m) => String(m) === userId)
|
||||
if (!isMember) {
|
||||
return res.status(403).json({
|
||||
error: 'ابتدا به اتاق بپیوندید',
|
||||
code: 'NOT_A_MEMBER'
|
||||
})
|
||||
}
|
||||
|
||||
const filter = { roomId: room._id }
|
||||
const total = await ChatRoomMessageModel.countDocuments(filter)
|
||||
const totalPages = Math.ceil(total / limit) || 1
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const messages = await ChatRoomMessageModel.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
|
||||
const enriched = await Promise.all(messages.map((m) => enrichRoomMessage(m)))
|
||||
res.json({ messages: enriched.reverse(), totalPages })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const sendRoomMessage = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const io = req.app.get('io')
|
||||
const { roomId } = req.params
|
||||
const { content, replyToId, fileType } = req.body
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId)) {
|
||||
return res.status(400).json({ error: 'Invalid room id' })
|
||||
}
|
||||
|
||||
const textContent = content != null ? String(content).trim() : ''
|
||||
const uploaded = req.files?.file
|
||||
const resolvedFileType = fileType || undefined
|
||||
|
||||
if (!textContent && !uploaded) {
|
||||
return res.status(422).json({ error: 'content or file is required' })
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('province city expertise')
|
||||
.lean()
|
||||
const room = await ChatRoomModel.findById(roomId)
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (!userCanSeeRoom(user, room)) {
|
||||
return res.status(403).json({ error: 'Access denied' })
|
||||
}
|
||||
|
||||
const isMember = room.members?.some((m) => String(m) === userId)
|
||||
if (!isMember) {
|
||||
return res.status(403).json({
|
||||
error: 'ابتدا به اتاق بپیوندید',
|
||||
code: 'NOT_A_MEMBER'
|
||||
})
|
||||
}
|
||||
|
||||
let fileUrl = null
|
||||
if (uploaded) {
|
||||
const uploadDir = path.join(__dirname, '../../../storage/chat-rooms/messages')
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true })
|
||||
}
|
||||
// اسمِ فایل هیچوقت مستقیم از ورودیِ کاربر (uploaded.name) گرفته نمیشه — فقط پسوندش
|
||||
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(uploaded.name || '')}`
|
||||
const filePath = path.join(uploadDir, uniqueFileName)
|
||||
await fs.move(uploaded.path, filePath)
|
||||
fileUrl = `/chat-rooms/messages/${uniqueFileName}`
|
||||
}
|
||||
|
||||
room.lastActivityAt = new Date()
|
||||
await room.save()
|
||||
|
||||
const message = await ChatRoomMessageModel.create({
|
||||
roomId: room._id,
|
||||
senderId: userId,
|
||||
content: textContent,
|
||||
file: fileUrl || undefined,
|
||||
fileType: fileUrl ? resolvedFileType : undefined,
|
||||
replyToId: replyToId || undefined
|
||||
})
|
||||
|
||||
const formatted = await enrichRoomMessage(message)
|
||||
emitGroupMessage(io, String(room._id), formatted, room.members)
|
||||
res.status(201).json({ message: formatted })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoomDetail = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const io = req.app.get('io')
|
||||
const { roomId } = req.params
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId)) {
|
||||
return res.status(400).json({ error: 'Invalid room id' })
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('province city expertise')
|
||||
.lean()
|
||||
const room = await ChatRoomModel.findById(roomId).lean()
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (!userCanSeeRoom(user, room)) {
|
||||
return res.status(403).json({ error: 'Access denied' })
|
||||
}
|
||||
|
||||
const members = await UserModel.find({ _id: { $in: room.members || [] } })
|
||||
.select('first_name last_name user_name profile_image is_verified verify_badge')
|
||||
.lean()
|
||||
|
||||
res.json({
|
||||
room: {
|
||||
...(await serializeRoom(room, io, userId)),
|
||||
members: members.map((m) => ({
|
||||
_id: String(m._id),
|
||||
first_name: m.first_name,
|
||||
last_name: m.last_name,
|
||||
user_name: m.user_name,
|
||||
profile_image: m.profile_image,
|
||||
is_verified: m.is_verified,
|
||||
verify_badge: m.verify_badge
|
||||
}))
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const addRoomMembers = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { roomId } = req.params
|
||||
const { member_ids: memberIdsRaw } = req.body
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId)) {
|
||||
return res.status(400).json({ error: 'Invalid room id' })
|
||||
}
|
||||
|
||||
const room = await ChatRoomModel.findById(roomId)
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (room.visibility !== 'private') {
|
||||
return res.status(422).json({ error: 'فقط اتاقهای خصوصی قابل دعوت هستند' })
|
||||
}
|
||||
|
||||
if (!room.createdBy || String(room.createdBy) !== userId) {
|
||||
return res.status(403).json({ error: 'فقط سازنده اتاق میتواند کاربر اضافه کند' })
|
||||
}
|
||||
|
||||
let memberIds = []
|
||||
if (Array.isArray(memberIdsRaw)) {
|
||||
memberIds = memberIdsRaw
|
||||
} else if (typeof memberIdsRaw === 'string') {
|
||||
try {
|
||||
memberIds = JSON.parse(memberIdsRaw)
|
||||
} catch {
|
||||
memberIds = []
|
||||
}
|
||||
}
|
||||
|
||||
const validIds = [...new Set(
|
||||
(memberIds || [])
|
||||
.map((id) => String(id).trim())
|
||||
.filter((id) => mongoose.Types.ObjectId.isValid(id) && id !== userId)
|
||||
)]
|
||||
|
||||
if (!validIds.length) {
|
||||
return res.status(422).json({ error: 'کاربر معتبری برای افزودن انتخاب نشده است' })
|
||||
}
|
||||
|
||||
const existing = new Set((room.members || []).map((id) => String(id)))
|
||||
for (const id of validIds) {
|
||||
if (!existing.has(id)) {
|
||||
room.members.push(id)
|
||||
existing.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
await room.save()
|
||||
|
||||
const io = req.app.get('io')
|
||||
res.json({ room: await serializeRoom(room.toObject(), io, userId) })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const reactToRoomMessage = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const io = req.app.get('io')
|
||||
const { roomId } = req.params
|
||||
const { messageId, emoji } = req.body
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId) || !mongoose.Types.ObjectId.isValid(messageId)) {
|
||||
return res.status(400).json({ error: 'Invalid id' })
|
||||
}
|
||||
if (!emoji) {
|
||||
return res.status(400).json({ error: 'emoji is required' })
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('province city expertise')
|
||||
.lean()
|
||||
const room = await ChatRoomModel.findById(roomId)
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (!userCanSeeRoom(user, room)) {
|
||||
return res.status(403).json({ error: 'Access denied' })
|
||||
}
|
||||
|
||||
if (!room.members?.some((m) => String(m) === userId)) {
|
||||
return res.status(403).json({
|
||||
error: 'ابتدا به اتاق بپیوندید',
|
||||
code: 'NOT_A_MEMBER'
|
||||
})
|
||||
}
|
||||
|
||||
const msg = await ChatRoomMessageModel.findOne({ _id: messageId, roomId: room._id })
|
||||
if (!msg) return res.status(404).json({ error: 'Message not found' })
|
||||
|
||||
let reactions = Array.isArray(msg.reactions) ? [...msg.reactions] : []
|
||||
const idx = reactions.findIndex((r) => String(r.userId) === userId)
|
||||
if (idx >= 0 && reactions[idx].emoji === emoji) {
|
||||
reactions.splice(idx, 1)
|
||||
} else if (idx >= 0) {
|
||||
reactions[idx] = { userId, emoji }
|
||||
} else {
|
||||
reactions.push({ userId, emoji })
|
||||
}
|
||||
|
||||
msg.reactions = reactions
|
||||
await msg.save()
|
||||
|
||||
const formatted = await enrichRoomMessage(msg)
|
||||
io.to(`group:${roomId}`).emit('groupMessageReaction', {
|
||||
roomId: String(roomId),
|
||||
messageId: String(msg._id),
|
||||
reactions: formatted.reactions,
|
||||
})
|
||||
|
||||
res.json({ message: formatted })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoomE2eeKeys = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { roomId } = req.params
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId)) {
|
||||
return res.status(400).json({ error: 'Invalid room id' })
|
||||
}
|
||||
|
||||
const room = await ChatRoomModel.findById(roomId)
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (!room.members?.some((m) => String(m) === userId)) {
|
||||
return res.status(403).json({
|
||||
error: 'ابتدا به اتاق بپیوندید',
|
||||
code: 'NOT_A_MEMBER'
|
||||
})
|
||||
}
|
||||
|
||||
const memberIds = (room.members || []).map((m) => String(m))
|
||||
const users = await UserModel.find({ _id: { $in: memberIds } })
|
||||
.select('e2ee_public_key')
|
||||
.lean()
|
||||
|
||||
const wraps = Array.isArray(room.e2ee_wrapped_keys) ? room.e2ee_wrapped_keys : []
|
||||
const myWrap = wraps.find((w) => String(w.userId) === userId)
|
||||
|
||||
const wrapUserIds = new Set(wraps.map((w) => String(w.userId)))
|
||||
const missingWrapUserIds = memberIds.filter((id) => !wrapUserIds.has(id))
|
||||
|
||||
res.json({
|
||||
e2ee_enabled: Boolean(room.e2ee_enabled),
|
||||
myWrappedKey: myWrap?.wrappedKey || null,
|
||||
memberPublicKeys: users.map((u) => ({
|
||||
userId: String(u._id),
|
||||
publicKey: u.e2ee_public_key || null
|
||||
})),
|
||||
missingWrapUserIds
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
const putRoomE2eeKeys = async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user._id)
|
||||
const { roomId } = req.params
|
||||
const { e2ee_enabled, wrapped_keys } = req.body
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(roomId)) {
|
||||
return res.status(400).json({ error: 'Invalid room id' })
|
||||
}
|
||||
|
||||
const room = await ChatRoomModel.findById(roomId)
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' })
|
||||
|
||||
if (!room.members?.some((m) => String(m) === userId)) {
|
||||
return res.status(403).json({
|
||||
error: 'ابتدا به اتاق بپیوندید',
|
||||
code: 'NOT_A_MEMBER'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof e2ee_enabled === 'boolean') {
|
||||
room.e2ee_enabled = e2ee_enabled
|
||||
}
|
||||
|
||||
if (Array.isArray(wrapped_keys)) {
|
||||
const memberSet = new Set((room.members || []).map((m) => String(m)))
|
||||
const existing = Array.isArray(room.e2ee_wrapped_keys)
|
||||
? [...room.e2ee_wrapped_keys]
|
||||
: []
|
||||
const byUser = new Map(existing.map((w) => [String(w.userId), w.wrappedKey]))
|
||||
|
||||
for (const entry of wrapped_keys) {
|
||||
const uid = String(entry?.userId || '').trim()
|
||||
const wrappedKey = String(entry?.wrappedKey || '').trim()
|
||||
if (!uid || !wrappedKey || !memberSet.has(uid)) continue
|
||||
if (wrappedKey.length > 8192) continue
|
||||
byUser.set(uid, wrappedKey)
|
||||
}
|
||||
|
||||
room.e2ee_wrapped_keys = [...byUser.entries()].map(([uid, key]) => ({
|
||||
userId: uid,
|
||||
wrappedKey: key
|
||||
}))
|
||||
}
|
||||
|
||||
await room.save()
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
e2ee_enabled: Boolean(room.e2ee_enabled),
|
||||
wrappedCount: room.e2ee_wrapped_keys?.length || 0
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRooms,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
getRoomMessages,
|
||||
sendRoomMessage,
|
||||
getRoomDetail,
|
||||
addRoomMembers,
|
||||
reactToRoomMessage,
|
||||
getRoomE2eeKeys,
|
||||
putRoomE2eeKeys,
|
||||
enrichRoomMessage,
|
||||
emitGroupMessage
|
||||
}
|
||||
156
controllers/application/chatRooms/chatRoomHelpers.js
Normal file
156
controllers/application/chatRooms/chatRoomHelpers.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const mongoose = require('mongoose')
|
||||
const path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const ChatRoomModel = require('../../../models/ChatRoomModel')
|
||||
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
|
||||
const DEFAULT_IDLE_MS = 30 * 24 * 60 * 60 * 1000 // 1 month
|
||||
const MIN_IDLE_MS = 60 * 60 * 1000 // 1 hour
|
||||
const MAX_IDLE_MS = 365 * 24 * 60 * 60 * 1000 // 1 year
|
||||
const STORAGE_ROOT = path.join(__dirname, '../../../storage')
|
||||
const ONLINE_WINDOW_MS = 60 * 1000
|
||||
|
||||
function resolveIdleTimeoutMs (value) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IDLE_MS
|
||||
return Math.min(MAX_IDLE_MS, Math.max(MIN_IDLE_MS, parsed))
|
||||
}
|
||||
|
||||
function slugifyTitle(title) {
|
||||
return String(title)
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\u0600-\u06FFa-zA-Z0-9-]/g, '')
|
||||
.slice(0, 48) || 'room'
|
||||
}
|
||||
|
||||
function userCanSeeRoom(user, room) {
|
||||
if (!room) return false
|
||||
if (room.isSystem) return true
|
||||
|
||||
const userId = user?._id != null ? String(user._id) : ''
|
||||
|
||||
if (room.visibility === 'private') {
|
||||
if (!userId) return false
|
||||
if (room.createdBy && String(room.createdBy) === userId) return true
|
||||
return Boolean(room.members?.some((memberId) => String(memberId) === userId))
|
||||
}
|
||||
|
||||
if (userId && room.createdBy && String(room.createdBy) === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hasProvince = room.province?.id != null
|
||||
const hasCity = room.city?.id != null
|
||||
const hasExpertise = Boolean(room.expertise && String(room.expertise).trim())
|
||||
|
||||
if (!hasProvince && !hasCity && !hasExpertise) return true
|
||||
|
||||
if (hasExpertise) {
|
||||
const userExp = String(user?.expertise || '').trim()
|
||||
if (userExp !== String(room.expertise).trim()) return false
|
||||
}
|
||||
|
||||
if (hasCity) {
|
||||
const userCityId = user?.city?.id
|
||||
return userCityId != null && String(userCityId) === String(room.city.id)
|
||||
}
|
||||
|
||||
if (hasProvince) {
|
||||
const userProvId = user?.province?.id
|
||||
return userProvId != null && String(userProvId) === String(room.province.id)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function countRoomOnlineMembers(memberIds = []) {
|
||||
const ids = [...new Set((memberIds || []).map((id) => String(id)).filter(Boolean))]
|
||||
if (!ids.length) return 0
|
||||
|
||||
const objectIds = ids
|
||||
.filter((id) => mongoose.Types.ObjectId.isValid(id))
|
||||
.map((id) => new mongoose.Types.ObjectId(id))
|
||||
if (!objectIds.length) return 0
|
||||
|
||||
const cutoff = new Date(Date.now() - ONLINE_WINDOW_MS)
|
||||
return UserModel.countDocuments({
|
||||
_id: { $in: objectIds },
|
||||
ghost_mode: { $ne: true },
|
||||
last_online: { $gte: cutoff },
|
||||
})
|
||||
}
|
||||
|
||||
function getRoomOnlineCount(io, roomId) {
|
||||
if (!io || !roomId) return 0
|
||||
const key = `group:${String(roomId)}`
|
||||
const adapterRoom = io.sockets?.adapter?.rooms?.get(key)
|
||||
return adapterRoom ? adapterRoom.size : 0
|
||||
}
|
||||
|
||||
async function resolveRoomOnlineCount(io, room) {
|
||||
const memberOnline = await countRoomOnlineMembers(room?.members)
|
||||
const socketOnline = getRoomOnlineCount(io, room?._id)
|
||||
return Math.max(memberOnline, socketOnline)
|
||||
}
|
||||
|
||||
async function removeRoomImage(imagePath) {
|
||||
if (!imagePath) return
|
||||
const relative = String(imagePath).replace(/^\//, '')
|
||||
const filePath = path.join(STORAGE_ROOT, relative)
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.remove(filePath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRoomFully(io, room) {
|
||||
if (!room?._id) return
|
||||
|
||||
const messages = await ChatRoomMessageModel.find({ roomId: room._id }).lean()
|
||||
for (const msg of messages) {
|
||||
if (msg.file) await removeRoomImage(msg.file)
|
||||
}
|
||||
|
||||
await ChatRoomMessageModel.deleteMany({ roomId: room._id })
|
||||
await removeRoomImage(room.image)
|
||||
await ChatRoomModel.deleteOne({ _id: room._id })
|
||||
|
||||
if (io) {
|
||||
io.to(`group:${room._id}`).emit('chatRoomDeleted', { roomId: String(room._id) })
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeInactiveChatRooms(io) {
|
||||
const now = new Date()
|
||||
const stale = await ChatRoomModel.find({
|
||||
isSystem: { $ne: true },
|
||||
$expr: {
|
||||
$lt: [
|
||||
{ $add: ['$lastActivityAt', { $ifNull: ['$idleTimeoutMs', DEFAULT_IDLE_MS] }] },
|
||||
now
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
for (const room of stale) {
|
||||
await deleteRoomFully(io, room)
|
||||
}
|
||||
|
||||
return stale.length
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_IDLE_MS,
|
||||
MIN_IDLE_MS,
|
||||
MAX_IDLE_MS,
|
||||
resolveIdleTimeoutMs,
|
||||
slugifyTitle,
|
||||
userCanSeeRoom,
|
||||
countRoomOnlineMembers,
|
||||
getRoomOnlineCount,
|
||||
resolveRoomOnlineCount,
|
||||
deleteRoomFully,
|
||||
purgeInactiveChatRooms,
|
||||
removeRoomImage
|
||||
}
|
||||
Reference in New Issue
Block a user