import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { ConversationAccessDto, ConversationPeerDto, ConversationRoomDto, ConversationSessionDto, ConversationSettingsDto, PlatformConversationStatsDto, UpdateConversationSettingsRequest, UserRole, } from '@mento/shared'; import { canManageSettings } 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 updateSettings( organizationId: string, role: UserRole | string, dto: UpdateConversationSettingsRequest, ): Promise { if (!canManageSettings(role)) { throw new ForbiddenException('فقط مدیر می‌تواند تنظیمات گفتگو را تغییر دهد'); } 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, downloadUrl: creds.downloadUrl, }; } 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 external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId); const tracked = await this.prisma.conversationRoom.findMany({ where: { organizationId }, }); const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t])); return external.map((row) => this.toRoomDto(row, trackedByExternal, organizationId, userId)); } 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, }; } 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( 'تنظیمات گفتگو برای این محیط انجام نشده است. از بخش تنظیمات پیکربندی کنید.', ); } 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, }; } private toRoomDto( row: ExternalRoomRow, trackedByExternal: Map< string, { kind: string; name: string | null; dmUserMinId: string | null; dmUserMaxId: string | null; } >, organizationId: string, currentUserId: string, ): ConversationRoomDto { const externalRoomId = String(row.id ?? row.room_id ?? '').trim(); const tracked = trackedByExternal.get(externalRoomId); const kind = tracked?.kind === 'group' ? 'group' : tracked?.kind === 'dm' ? 'dm' : 'group'; const lastMsg = (row.last_message ?? row.lastMessage) as Record | undefined; const preview = lastMsg ? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim() : ''; 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 === '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: kind as 'dm' | 'group', name, unreadCount: Number.isFinite(unread) ? Math.max(0, unread) : 0, lastMessagePreview: preview || null, lastMessageAt: lastAtRaw ? String(lastAtRaw) : null, peerUserId, }; } }