import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { ConversationAccessDto, ConversationPeerDto, ConversationPinnedMessageDto, ConversationRoomDto, 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 { constructor( private readonly prisma: PrismaService, private readonly crypto: TahesabCryptoService, private readonly chat: ChatExternalClient, ) {} 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 membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, select: { role: true }, }); const effectiveRole = String(membership?.role ?? role); 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); return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) }; }); } catch (e) { // اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده if (tracked.length) { return tracked.map((t) => { const kind = this.normalizeKind(t.kind); 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), canPost: this.canPostInRoom(kind, t.createdByUserId, userId, effectiveRole), }; }); } 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.chat.createRoom(creds.host, creds.apiKey, { name: trimmedName, type: 'GROUP', members, }); const row = await this.prisma.conversationRoom.create({ data: { organizationId, externalRoomId: created.room_id, kind: 'group', name: trimmedName, createdByUserId: userId, }, }); return { id: row.id, externalRoomId: row.externalRoomId, kind: 'group', name: trimmedName, unreadCount: 0, lastMessagePreview: null, lastMessageAt: null, peerUserId: null, canPost: 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.chat.createRoom(creds.host, creds.apiKey, { name: trimmedName, type: 'GROUP', members, }); const row = await this.prisma.conversationRoom.create({ data: { organizationId, externalRoomId: created.room_id, kind: 'channel', name: trimmedName, createdByUserId: userId, }, }); return { id: row.id, externalRoomId: row.externalRoomId, kind: 'channel', name: trimmedName, unreadCount: 0, lastMessagePreview: null, lastMessageAt: null, peerUserId: null, canPost: 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)) { 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)); } 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 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))] }; } }