import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { ConversationAccessDto, ConversationPeerDto, ConversationPinnedMessageDto, ConversationRoomDto, ConversationRoomMemberDto, ConversationSessionDto, ConversationSettingsDto, PlatformConversationStatsDto, UpdateConversationSettingsRequest, } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { TahesabCryptoService } from '../integrations/tahesab/tahesab-crypto.service'; import { ChatExternalClient, type ExternalRoomRow } from './chat-external.client'; import { buildChatUserId, dmPairKey } from './chat-user-id.util'; import { resolveConversationAccess, type ConversationAccess, } from './conversations-permissions'; @Injectable() export class ConversationsService { /** presence: org|room|userId → epoch ms (بدون DB؛ کافی برای typing کوتاه‌عمر) */ private readonly typingPresence = new Map(); private typingSweepAt = 0; constructor( private readonly prisma: PrismaService, private readonly crypto: TahesabCryptoService, private readonly chat: ChatExternalClient, ) {} private typingKey(organizationId: string, roomId: string, userId: string) { return `${organizationId}|${roomId}|${userId}`; } /** * سرویس خارجی چت اغلب در create بیش از ~۶ عضو را رد می‌کند. * ابتدا با سقف کوچک می‌سازیم و بقیه را با add_members اضافه می‌کنیم. */ private async createExternalRoomChunked( host: string, apiKey: string, name: string, type: string, members: string[], ): Promise<{ room_id: string }> { const CREATE_CAP = 6; const ADD_CHUNK = 6; const unique = [...new Set(members.filter(Boolean))]; const head = unique.slice(0, CREATE_CAP); const rest = unique.slice(CREATE_CAP); const created = await this.chat.createRoom(host, apiKey, { name, type, members: head.length ? head : unique.slice(0, 1), }); for (let i = 0; i < rest.length; i += ADD_CHUNK) { const chunk = rest.slice(i, i + ADD_CHUNK); await this.chat.addRoomMembers(host, apiKey, created.room_id, chunk); } return created; } private sweepTypingPresence(now = Date.now()) { if (now - this.typingSweepAt < 10_000) return; this.typingSweepAt = now; for (const [k, at] of this.typingPresence) { if (now - at > 8000) this.typingPresence.delete(k); } } async signalTyping( userId: string, organizationId: string, role: string, externalRoomId: string, ): Promise<{ ok: true }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); const now = Date.now(); this.typingPresence.set(this.typingKey(organizationId, roomId, userId), now); this.sweepTypingPresence(now); return { ok: true }; } async listTyping( userId: string, organizationId: string, role: string, externalRoomId: string, ): Promise> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) return []; const now = Date.now(); this.sweepTypingPresence(now); const prefix = `${organizationId}|${roomId}|`; const ids: string[] = []; for (const [k, at] of this.typingPresence) { if (!k.startsWith(prefix)) continue; if (now - at > 4000) { this.typingPresence.delete(k); continue; } const uid = k.slice(prefix.length); if (!uid || uid === userId) continue; ids.push(uid); } if (!ids.length) return []; const users = await this.prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, fullName: true }, }); return users.map((u) => ({ userId: u.id, fullName: u.fullName || 'کاربر', chatUserId: buildChatUserId(organizationId, u.id), })); } /** typing همه اتاق‌های سازمان (برای پیش‌نمایش لیست گفتگو) */ async listTypingAll( userId: string, organizationId: string, role: string, ): Promise< Array<{ externalRoomId: string; userId: string; fullName: string; chatUserId: string }> > { await this.loadAccess(userId, organizationId, role); const now = Date.now(); this.sweepTypingPresence(now); const orgPrefix = `${organizationId}|`; const byRoom = new Map(); for (const [k, at] of this.typingPresence) { if (!k.startsWith(orgPrefix)) continue; if (now - at > 4000) { this.typingPresence.delete(k); continue; } const rest = k.slice(orgPrefix.length); const sep = rest.lastIndexOf('|'); if (sep <= 0) continue; const roomId = rest.slice(0, sep); const uid = rest.slice(sep + 1); if (!roomId || !uid || uid === userId) continue; const list = byRoom.get(roomId) || []; list.push(uid); byRoom.set(roomId, list); } if (!byRoom.size) return []; const allIds = [...new Set([...byRoom.values()].flat())]; const users = await this.prisma.user.findMany({ where: { id: { in: allIds } }, select: { id: true, fullName: true }, }); const nameById = new Map(users.map((u) => [u.id, u.fullName || 'کاربر'])); const out: Array<{ externalRoomId: string; userId: string; fullName: string; chatUserId: string; }> = []; for (const [roomId, ids] of byRoom) { for (const uid of ids) { out.push({ externalRoomId: roomId, userId: uid, fullName: nameById.get(uid) || 'کاربر', chatUserId: buildChatUserId(organizationId, uid), }); } } return out; } async getAccess(userId: string, organizationId: string, role: string): Promise { const access = await this.loadAccess(userId, organizationId, role); return access; } async getSettings(organizationId: string): Promise { const row = await this.prisma.conversationSettings.findUnique({ where: { organizationId }, }); if (!row) { return { chatHost: '', uploadUrl: '', downloadUrl: '', apiKeySet: false, updatedAt: new Date(0).toISOString(), }; } return { chatHost: row.chatHost, uploadUrl: row.uploadUrl, downloadUrl: row.downloadUrl, apiKeySet: true, updatedAt: row.updatedAt.toISOString(), }; } /** ذخیره تنظیمات اتصال چت — فقط از مسیر سوپرادمین */ async upsertSettingsForOrganization( organizationId: string, dto: UpdateConversationSettingsRequest, ): Promise { const org = await this.prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true }, }); if (!org) throw new NotFoundException('محیط پیدا نشد'); const host = this.chat.normalizeHost(dto.chatHost); if (!host) throw new BadRequestException('آدرس سرویس گفتگو نامعتبر است'); const existing = await this.prisma.conversationSettings.findUnique({ where: { organizationId }, }); let enc = existing ? { ciphertext: existing.apiKeyCipher, iv: existing.apiKeyIv, tag: existing.apiKeyTag, } : null; const nextKey = dto.apiKey?.trim(); if (nextKey) { enc = this.crypto.encrypt(nextKey); } else if (!enc) { throw new BadRequestException('API Key الزامی است'); } const uploadUrl = dto.uploadUrl?.trim() ?? existing?.uploadUrl ?? ''; const downloadUrl = dto.downloadUrl?.trim() ?? existing?.downloadUrl ?? ''; await this.prisma.conversationSettings.upsert({ where: { organizationId }, create: { organizationId, chatHost: host, apiKeyCipher: enc!.ciphertext, apiKeyIv: enc!.iv, apiKeyTag: enc!.tag, uploadUrl, downloadUrl, }, update: { chatHost: host, apiKeyCipher: enc!.ciphertext, apiKeyIv: enc!.iv, apiKeyTag: enc!.tag, uploadUrl, downloadUrl, }, }); return this.getSettings(organizationId); } async getSession( userId: string, organizationId: string, role: string, ): Promise { await this.requireUse(userId, organizationId, role); const creds = await this.getCredentials(organizationId); const chatUserId = buildChatUserId(organizationId, userId); const params = new URLSearchParams({ api_key: creds.apiKey, user_id: chatUserId, }); const host = creds.host; return { chatUserId, wsUrl: `wss://${host}/ws?${params.toString()}`, messagesBaseUrl: `https://${host}`, uploadUrl: creds.uploadUrl.trim() || `https://${host}/upload`, downloadUrl: creds.downloadUrl.trim() || `https://${host}/files`, }; } async listPeers( userId: string, organizationId: string, role: string, ): Promise { await this.requireUse(userId, organizationId, role); const rows = await this.prisma.membership.findMany({ where: { organizationId, isActive: true, user: { isActive: true }, }, select: { user: { select: { id: true, fullName: true, avatarStorageKey: true, }, }, }, orderBy: { user: { fullName: 'asc' } }, }); return rows .filter((r) => r.user.id !== userId) .map((r) => ({ id: r.user.id, fullName: r.user.fullName, hasAvatar: Boolean(r.user.avatarStorageKey), chatUserId: buildChatUserId(organizationId, r.user.id), })); } async listRooms( userId: string, organizationId: string, role: string, ): Promise { await this.requireUse(userId, organizationId, role); const creds = await this.getCredentials(organizationId); const chatUserId = buildChatUserId(organizationId, userId); const tracked = await this.prisma.conversationRoom.findMany({ where: { organizationId }, }); const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t])); const pinnedIds = await this.userPinnedRoomIds(organizationId, userId); const muteMap = await this.userMuteMap(organizationId, userId); const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, select: { role: true }, }); const effectiveRole = String(membership?.role ?? role); const orgAdmin = effectiveRole === 'owner' || effectiveRole === 'admin'; const roomAdminIds = await this.userAdminRoomIds(organizationId, userId); try { const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId); return external.map((row) => { const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId, effectiveRole); const isRoomAdmin = roomAdminIds.has(dto.externalRoomId) || trackedByExternal.get(dto.externalRoomId)?.createdByUserId === userId; const mute = muteMap.get(dto.externalRoomId); return { ...dto, pinned: pinnedIds.has(dto.externalRoomId), muted: Boolean(mute), muteAllowMentions: mute ? mute.allowMentions : true, canPost: dto.kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin), canManageMembers: dto.kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin), }; }); } catch (e) { // اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده if (tracked.length) { return tracked.map((t) => { const kind = this.normalizeKind(t.kind); const isRoomAdmin = roomAdminIds.has(t.externalRoomId) || t.createdByUserId === userId; const mute = muteMap.get(t.externalRoomId); return { id: t.id, externalRoomId: t.externalRoomId, kind, name: t.name || (kind === 'channel' ? 'کانال' : kind === 'group' ? 'گروه' : 'گفتگو'), unreadCount: 0, lastMessagePreview: null, lastMessageAt: null, peerUserId: kind === 'dm' ? t.dmUserMinId === userId ? t.dmUserMaxId : t.dmUserMaxId === userId ? t.dmUserMinId : null : null, pinned: pinnedIds.has(t.externalRoomId), muted: Boolean(mute), muteAllowMentions: mute ? mute.allowMentions : true, canPost: kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin), canManageMembers: kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin), }; }); } throw e; } } async createDm( userId: string, organizationId: string, role: string, peerUserId: string, ): Promise { await this.requireUse(userId, organizationId, role); if (peerUserId === userId) { throw new BadRequestException('نمی‌توانید با خودتان گفتگو بسازید'); } const peer = await this.prisma.membership.findFirst({ where: { organizationId, userId: peerUserId, isActive: true, user: { isActive: true }, }, include: { user: { select: { fullName: true } } }, }); if (!peer) throw new NotFoundException('همکار پیدا نشد'); const [minId, maxId] = dmPairKey(userId, peerUserId); const existing = await this.prisma.conversationRoom.findFirst({ where: { organizationId, dmUserMinId: minId, dmUserMaxId: maxId }, }); if (existing) { return { id: existing.id, externalRoomId: existing.externalRoomId, kind: 'dm', name: peer.user.fullName, unreadCount: 0, lastMessagePreview: null, lastMessageAt: null, peerUserId, }; } const creds = await this.getCredentials(organizationId); const members = [ buildChatUserId(organizationId, userId), buildChatUserId(organizationId, peerUserId), ]; const created = await this.chat.createRoom(creds.host, creds.apiKey, { name: peer.user.fullName, type: 'DIRECT', members, }); const row = await this.prisma.conversationRoom.create({ data: { organizationId, externalRoomId: created.room_id, kind: 'dm', name: peer.user.fullName, dmUserMinId: minId, dmUserMaxId: maxId, createdByUserId: userId, }, }); return { id: row.id, externalRoomId: row.externalRoomId, kind: 'dm', name: peer.user.fullName, unreadCount: 0, lastMessagePreview: null, lastMessageAt: null, peerUserId, }; } async createGroup( userId: string, organizationId: string, role: string, name: string, memberUserIds: string[], ): Promise { const access = await this.loadAccess(userId, organizationId, role); if (!access.canCreateGroup) { throw new ForbiddenException('اجازه ساخت گروه گفتگو را ندارید'); } const uniqueIds = [...new Set(memberUserIds.filter((id) => id && id !== userId))]; const memberships = await this.prisma.membership.findMany({ where: { organizationId, userId: { in: uniqueIds }, isActive: true, user: { isActive: true }, }, select: { userId: true }, }); if (memberships.length !== uniqueIds.length) { throw new BadRequestException('برخی اعضای گروه در این سازمان فعال نیستند'); } const creds = await this.getCredentials(organizationId); const members = [ buildChatUserId(organizationId, userId), ...uniqueIds.map((id) => buildChatUserId(organizationId, id)), ]; const trimmedName = name.trim(); const created = await this.createExternalRoomChunked( creds.host, creds.apiKey, trimmedName, 'GROUP', members, ); const row = await this.prisma.conversationRoom.create({ data: { organizationId, externalRoomId: created.room_id, kind: 'group', name: trimmedName, createdByUserId: userId, }, }); await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds); return { id: row.id, externalRoomId: row.externalRoomId, kind: 'group', name: trimmedName, unreadCount: 0, lastMessagePreview: null, lastMessageAt: null, peerUserId: null, canPost: true, canManageMembers: true, }; } async createChannel( userId: string, organizationId: string, role: string, name: string, memberUserIds: string[], ): Promise { const access = await this.loadAccess(userId, organizationId, role); if (!access.canCreateChannel) { throw new ForbiddenException('اجازه ساخت کانال را ندارید'); } const uniqueIds = [...new Set(memberUserIds.filter((id) => id && id !== userId))]; const memberships = await this.prisma.membership.findMany({ where: { organizationId, userId: { in: uniqueIds }, isActive: true, user: { isActive: true }, }, select: { userId: true }, }); if (memberships.length !== uniqueIds.length) { throw new BadRequestException('برخی اعضای کانال در این سازمان فعال نیستند'); } const creds = await this.getCredentials(organizationId); const members = [ buildChatUserId(organizationId, userId), ...uniqueIds.map((id) => buildChatUserId(organizationId, id)), ]; const trimmedName = name.trim(); // سرویس خارجی معمولاً CHANNEL ندارد؛ به‌صورت GROUP ساخته و در CRM به‌عنوان کانال یک‌طرفه نگه داشته می‌شود const created = await this.createExternalRoomChunked( creds.host, creds.apiKey, trimmedName, 'GROUP', members, ); const row = await this.prisma.conversationRoom.create({ data: { organizationId, externalRoomId: created.room_id, kind: 'channel', name: trimmedName, createdByUserId: userId, }, }); await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds); return { id: row.id, externalRoomId: row.externalRoomId, kind: 'channel', name: trimmedName, unreadCount: 0, lastMessagePreview: null, lastMessageAt: null, peerUserId: null, canPost: true, canManageMembers: true, }; } async platformStats(): Promise { const [organizationsWithChat, totalRoomsTracked] = await Promise.all([ this.prisma.conversationSettings.count(), this.prisma.conversationRoom.count(), ]); const totalConfiguredOrgs = await this.prisma.organization.count({ where: { isActive: true }, }); return { organizationsWithChat, totalConfiguredOrgs, totalRoomsTracked, }; } private async requireUse(userId: string, organizationId: string, role: string) { const access = await this.loadAccess(userId, organizationId, role); if (!access.canUse) { throw new ForbiddenException('به بخش گفتگو دسترسی ندارید'); } await this.getCredentials(organizationId); } private async loadAccess( userId: string, organizationId: string, role: string, ): Promise { const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, include: { orgRole: { select: { permissions: true } } }, }); return resolveConversationAccess( membership?.role ?? role, membership?.orgRole?.permissions ?? [], ); } private async getCredentials(organizationId: string) { const row = await this.prisma.conversationSettings.findUnique({ where: { organizationId }, }); if (!row) { throw new BadRequestException( 'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.', ); } try { const apiKey = this.crypto.decrypt({ ciphertext: row.apiKeyCipher, iv: row.apiKeyIv, tag: row.apiKeyTag, }); return { host: this.chat.normalizeHost(row.chatHost), apiKey, uploadUrl: row.uploadUrl, downloadUrl: row.downloadUrl, }; } catch { throw new BadRequestException( 'رمزگشایی API Key گفتگو ناموفق بود. INTEGRATIONS_ENCRYPTION_KEY سرور را بررسی کنید یا تنظیمات را دوباره ذخیره کنید.', ); } } private normalizeKind(raw: string | null | undefined): 'dm' | 'group' | 'channel' { if (raw === 'channel') return 'channel'; if (raw === 'dm') return 'dm'; return 'group'; } private canPostInRoom( kind: 'dm' | 'group' | 'channel', createdByUserId: string | null | undefined, currentUserId: string, role: string, ): boolean { if (kind !== 'channel') return true; if (createdByUserId && createdByUserId === currentUserId) return true; return role === 'owner' || role === 'admin'; } private toRoomDto( row: ExternalRoomRow, trackedByExternal: Map< string, { kind: string; name: string | null; dmUserMinId: string | null; dmUserMaxId: string | null; createdByUserId: string | null; } >, organizationId: string, currentUserId: string, role = '', ): ConversationRoomDto { const externalRoomId = String(row.id ?? row.room_id ?? '').trim(); const tracked = trackedByExternal.get(externalRoomId); const kind = this.normalizeKind(tracked?.kind); const lastMsg = (row.last_message ?? row.lastMessage) as Record | undefined; const lastType = String(lastMsg?.type_content ?? lastMsg?.type ?? 'text') .toLowerCase() .trim(); let preview = lastMsg ? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim() : ''; if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview) || /^__MENTO_TYPING__:/i.test(preview) || /^__MENTO_REACT__:/i.test(preview)) { preview = ''; } if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { if (/(?:^|[\\/._-])vid-\d+/i.test(preview) || /\.(mp4|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview)) { preview = 'ویدیو'; } else { preview = 'تصویر'; } } else if (lastType === 'voice' || lastType === 'audio') { preview = 'پیام صوتی'; } else if (lastType === 'video') { preview = 'ویدیو'; } else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') { if (/(?:^|[\\/._-])vid-\d+/i.test(preview) || /\.(mp4|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview)) { preview = 'ویدیو'; } else if ( /(?:^|[\\/._-])voice(?:[\\/._-]|$)/i.test(preview) || /\.(webm|ogg|mp3|m4a|wav|aac|opus)(?:\?|$)/i.test(preview) ) { preview = 'پیام صوتی'; } else { preview = 'فایل'; } } const lastAtRaw = lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null; const unread = Number(row.unread_count ?? row.unreadCount ?? 0); let name = String(tracked?.name ?? row.name ?? row.title ?? '').trim() || (kind === 'channel' ? 'کانال' : kind === 'group' ? 'گروه' : 'گفتگو'); let peerUserId: string | null = null; if (tracked?.kind === 'dm' && tracked.dmUserMinId && tracked.dmUserMaxId) { if (tracked.dmUserMinId === currentUserId) peerUserId = tracked.dmUserMaxId; else if (tracked.dmUserMaxId === currentUserId) peerUserId = tracked.dmUserMinId; } return { id: externalRoomId, externalRoomId, kind, name, unreadCount: Number.isFinite(unread) ? Math.max(0, unread) : 0, lastMessagePreview: preview || null, lastMessageAt: lastAtRaw ? String(lastAtRaw) : null, peerUserId, pinned: false, canPost: this.canPostInRoom(kind, tracked?.createdByUserId, currentUserId, role), }; } private async userPinnedRoomIds(organizationId: string, userId: string): Promise> { const rows = await this.prisma.conversationRoomUserPin.findMany({ where: { organizationId, userId }, select: { externalRoomId: true }, take: 500, }); return new Set(rows.map((r) => r.externalRoomId)); } private async userMuteMap( organizationId: string, userId: string, ): Promise> { const rows = await this.prisma.conversationRoomUserMute.findMany({ where: { organizationId, userId }, select: { externalRoomId: true, allowMentions: true }, take: 2000, }); return new Map(rows.map((r) => [r.externalRoomId, { allowMentions: r.allowMentions }])); } private async userAdminRoomIds(organizationId: string, userId: string): Promise> { const rows = await this.prisma.conversationRoomMember.findMany({ where: { organizationId, userId, role: 'admin' }, select: { externalRoomId: true }, take: 1000, }); return new Set(rows.map((r) => r.externalRoomId)); } private async seedRoomMembers( organizationId: string, externalRoomId: string, adminUserId: string, memberUserIds: string[], ) { const rows = [ { organizationId, externalRoomId, userId: adminUserId, role: 'admin', }, ...memberUserIds.map((id) => ({ organizationId, externalRoomId, userId: id, role: 'member', })), ]; await this.prisma.conversationRoomMember.createMany({ data: rows, skipDuplicates: true, }); } private async assertCanManageRoom( userId: string, organizationId: string, role: string, externalRoomId: string, ) { await this.loadAccess(userId, organizationId, role); const room = await this.prisma.conversationRoom.findFirst({ where: { organizationId, externalRoomId }, }); if (!room) throw new NotFoundException('گفتگو پیدا نشد'); if (room.kind === 'dm') { throw new BadRequestException('مدیریت اعضا برای گفتگوی خصوصی نیست'); } const orgAdmin = role === 'owner' || role === 'admin'; if (orgAdmin || room.createdByUserId === userId) return room; const mine = await this.prisma.conversationRoomMember.findFirst({ where: { organizationId, externalRoomId, userId, role: 'admin' }, }); if (!mine) throw new ForbiddenException('اجازه مدیریت اعضای این گفتگو را ندارید'); return room; } async listRoomMembers( userId: string, organizationId: string, role: string, externalRoomId: string, ): Promise { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); const room = await this.prisma.conversationRoom.findFirst({ where: { organizationId, externalRoomId: roomId }, }); if (!room) throw new NotFoundException('گفتگو پیدا نشد'); // همیشه سازنده + کاربر فعلی را در لیست عضو داشته باش (اتاق‌های قدیمی بدون ردیف) const seedIds: Array<{ userId: string; role: string }> = []; if (room.createdByUserId) { seedIds.push({ userId: room.createdByUserId, role: 'admin' }); } if (!seedIds.some((s) => s.userId === userId)) { const orgAdmin = role === 'owner' || role === 'admin'; seedIds.push({ userId, role: orgAdmin || room.createdByUserId === userId ? 'admin' : 'member', }); } if (seedIds.length) { try { await this.prisma.conversationRoomMember.createMany({ data: seedIds.map((s) => ({ organizationId, externalRoomId: roomId, userId: s.userId, role: s.role, })), skipDuplicates: true, }); } catch (e) { const code = (e as { code?: string })?.code; if (code === 'P2021' || code === 'P2022') { throw new BadRequestException( 'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.', ); } throw e; } } let rows: Array<{ userId: string; role: string }> = []; try { rows = await this.prisma.conversationRoomMember.findMany({ where: { organizationId, externalRoomId: roomId }, select: { userId: true, role: true }, }); } catch (e) { const code = (e as { code?: string })?.code; if (code === 'P2021' || code === 'P2022') { throw new BadRequestException( 'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.', ); } throw e; } const userIds = rows.map((r) => r.userId); const users = await this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, fullName: true, avatarStorageKey: true }, }); const byId = new Map(users.map((u) => [u.id, u])); return rows .map((r) => { const u = byId.get(r.userId); return { userId: r.userId, fullName: u?.fullName || 'کاربر', role: (r.role === 'admin' ? 'admin' : 'member') as 'admin' | 'member', hasAvatar: Boolean(u?.avatarStorageKey), isMe: r.userId === userId, }; }) .sort((a, b) => { if (a.role !== b.role) return a.role === 'admin' ? -1 : 1; return a.fullName.localeCompare(b.fullName, 'fa'); }); } async addRoomMember( userId: string, organizationId: string, role: string, externalRoomId: string, memberUserId: string, memberRole: 'admin' | 'member' = 'member', ): Promise { const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim()); const targetId = memberUserId.trim(); if (!targetId || targetId === userId) { // اجازه افزودن خود لازم نیست } if (!targetId) throw new BadRequestException('شناسه عضو خالی است'); const membership = await this.prisma.membership.findFirst({ where: { organizationId, userId: targetId, isActive: true, user: { isActive: true }, }, }); if (!membership) throw new NotFoundException('همکار در این سازمان پیدا نشد'); await this.prisma.conversationRoomMember.upsert({ where: { organizationId_externalRoomId_userId: { organizationId, externalRoomId: room.externalRoomId, userId: targetId, }, }, create: { organizationId, externalRoomId: room.externalRoomId, userId: targetId, role: memberRole === 'admin' ? 'admin' : 'member', }, update: { role: memberRole === 'admin' ? 'admin' : 'member', }, }); try { const creds = await this.getCredentials(organizationId); await this.chat.addRoomMembers(creds.host, creds.apiKey, room.externalRoomId, [ buildChatUserId(organizationId, targetId), ]); } catch { // CRM به‌روز شده؛ همگام‌سازی خارجی best-effort است } return this.listRoomMembers(userId, organizationId, role, room.externalRoomId); } async removeRoomMember( userId: string, organizationId: string, role: string, externalRoomId: string, memberUserId: string, ): Promise { const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim()); const targetId = memberUserId.trim(); if (!targetId) throw new BadRequestException('شناسه عضو خالی است'); if (targetId === room.createdByUserId) { throw new BadRequestException('نمی‌توان سازنده گفتگو را حذف کرد'); } if (targetId === userId) { throw new BadRequestException('نمی‌توانید خودتان را حذف کنید'); } await this.prisma.conversationRoomMember.deleteMany({ where: { organizationId, externalRoomId: room.externalRoomId, userId: targetId, }, }); try { const creds = await this.getCredentials(organizationId); await this.chat.removeRoomMembers(creds.host, creds.apiKey, room.externalRoomId, [ buildChatUserId(organizationId, targetId), ]); } catch { // best-effort } return this.listRoomMembers(userId, organizationId, role, room.externalRoomId); } async updateRoomMemberRole( userId: string, organizationId: string, role: string, externalRoomId: string, memberUserId: string, memberRole: 'admin' | 'member', ): Promise { const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim()); const targetId = memberUserId.trim(); if (!targetId) throw new BadRequestException('شناسه عضو خالی است'); if (targetId === room.createdByUserId && memberRole !== 'admin') { throw new BadRequestException('نقش سازنده گفتگو را نمی‌توان از ادمین برداشت'); } const existing = await this.prisma.conversationRoomMember.findFirst({ where: { organizationId, externalRoomId: room.externalRoomId, userId: targetId, }, }); if (!existing) throw new NotFoundException('عضو پیدا نشد'); await this.prisma.conversationRoomMember.update({ where: { id: existing.id }, data: { role: memberRole === 'admin' ? 'admin' : 'member' }, }); return this.listRoomMembers(userId, organizationId, role, room.externalRoomId); } async setRoomPinned( userId: string, organizationId: string, role: string, externalRoomId: string, pinned: boolean, ): Promise<{ externalRoomId: string; pinned: boolean }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); if (pinned) { await this.prisma.conversationRoomUserPin.upsert({ where: { organizationId_userId_externalRoomId: { organizationId, userId, externalRoomId: roomId, }, }, create: { organizationId, userId, externalRoomId: roomId }, update: {}, }); } else { await this.prisma.conversationRoomUserPin.deleteMany({ where: { organizationId, userId, externalRoomId: roomId }, }); } return { externalRoomId: roomId, pinned }; } async setRoomMuted( userId: string, organizationId: string, role: string, externalRoomId: string, muted: boolean, allowMentions = true, ): Promise<{ externalRoomId: string; muted: boolean; muteAllowMentions: boolean }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); if (muted) { await this.prisma.conversationRoomUserMute.upsert({ where: { organizationId_userId_externalRoomId: { organizationId, userId, externalRoomId: roomId, }, }, create: { organizationId, userId, externalRoomId: roomId, allowMentions: allowMentions !== false, }, update: { allowMentions: allowMentions !== false }, }); } else { await this.prisma.conversationRoomUserMute.deleteMany({ where: { organizationId, userId, externalRoomId: roomId }, }); } return { externalRoomId: roomId, muted, muteAllowMentions: muted ? allowMentions !== false : true, }; } async listMessageReactions( userId: string, organizationId: string, role: string, externalRoomId: string, ): Promise< Array<{ messageId: string; emoji: string; count: number; me: boolean }> > { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) return []; const rows = await this.prisma.conversationMessageReaction.findMany({ where: { organizationId, externalRoomId: roomId }, select: { messageId: true, emoji: true, userId: true }, take: 5000, }); const map = new Map(); for (const r of rows) { const key = `${r.messageId}\0${r.emoji}`; const cur = map.get(key) || { messageId: r.messageId, emoji: r.emoji, count: 0, me: false, }; cur.count += 1; if (r.userId === userId) cur.me = true; map.set(key, cur); } return [...map.values()]; } async toggleMessageReaction( userId: string, organizationId: string, role: string, externalRoomId: string, messageId: string, emoji: string, ): Promise<{ added: boolean; reactions: Array<{ messageId: string; emoji: string; count: number; me: boolean }>; }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); const mid = messageId.trim(); const em = emoji.trim().slice(0, 16); if (!roomId || !mid || !em) throw new BadRequestException('پارامتر واکنش ناقص است'); const existing = await this.prisma.conversationMessageReaction.findFirst({ where: { organizationId, externalRoomId: roomId, messageId: mid, userId, emoji: em, }, }); let added = false; if (existing) { await this.prisma.conversationMessageReaction.delete({ where: { id: existing.id } }); } else { await this.prisma.conversationMessageReaction.create({ data: { organizationId, externalRoomId: roomId, messageId: mid, userId, emoji: em, }, }); added = true; } const reactions = await this.listMessageReactions( userId, organizationId, role, roomId, ); return { added, reactions }; } async getPinnedMessage( userId: string, organizationId: string, role: string, externalRoomId: string, ): Promise { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); const row = await this.prisma.conversationPinnedMessage.findUnique({ where: { organizationId_externalRoomId: { organizationId, externalRoomId: roomId }, }, }); if (!row) return null; return { messageId: row.messageId, preview: row.preview, pinnedByUserId: row.pinnedByUserId, createdAt: row.createdAt.toISOString(), }; } async pinMessage( userId: string, organizationId: string, role: string, externalRoomId: string, messageId: string, preview?: string, ): Promise { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); const msgId = messageId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); if (!msgId) throw new BadRequestException('شناسه پیام خالی است'); const cleanPreview = String(preview || '') .replace(/\s+/g, ' ') .trim() .slice(0, 240); const row = await this.prisma.conversationPinnedMessage.upsert({ where: { organizationId_externalRoomId: { organizationId, externalRoomId: roomId }, }, create: { organizationId, externalRoomId: roomId, messageId: msgId, preview: cleanPreview, pinnedByUserId: userId, }, update: { messageId: msgId, preview: cleanPreview, pinnedByUserId: userId, }, }); return { messageId: row.messageId, preview: row.preview, pinnedByUserId: row.pinnedByUserId, createdAt: row.createdAt.toISOString(), }; } async unpinMessage( userId: string, organizationId: string, role: string, externalRoomId: string, ): Promise<{ ok: true }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); await this.prisma.conversationPinnedMessage.deleteMany({ where: { organizationId, externalRoomId: roomId }, }); return { ok: true }; } async hideMessages( userId: string, organizationId: string, role: string, externalRoomId: string, messageIds: string[], forEveryone = true, ): Promise<{ messageIds: string[]; scope: 'all' | 'self' }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); const ids = [ ...new Set( messageIds .map((id) => String(id || '').trim()) .filter((id) => id.length > 0 && id.length <= 200), ), ].slice(0, 100); if (!ids.length) throw new BadRequestException('شناسه پیام خالی است'); const scope = forEveryone ? 'all' : 'self'; await this.prisma.conversationHiddenMessage.createMany({ data: ids.map((messageId) => ({ organizationId, externalRoomId: roomId, messageId, hiddenByUserId: userId, scope, })), skipDuplicates: true, }); return { messageIds: ids, scope }; } async listHiddenMessageIds( userId: string, organizationId: string, role: string, externalRoomId: string, ): Promise<{ messageIds: string[] }> { await this.loadAccess(userId, organizationId, role); const roomId = externalRoomId.trim(); if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); const rows = await this.prisma.conversationHiddenMessage.findMany({ where: { organizationId, externalRoomId: roomId, OR: [{ scope: 'all' }, { scope: 'self', hiddenByUserId: userId }], }, select: { messageId: true }, take: 5000, }); return { messageIds: [...new Set(rows.map((r) => r.messageId))] }; } }