diff --git a/apps/api/prisma/migrations/20260929103000_conversations/migration.sql b/apps/api/prisma/migrations/20260929103000_conversations/migration.sql new file mode 100644 index 0000000..42ab072 --- /dev/null +++ b/apps/api/prisma/migrations/20260929103000_conversations/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "ConversationSettings" ( + "organizationId" TEXT NOT NULL, + "chatHost" TEXT NOT NULL, + "apiKeyCipher" TEXT NOT NULL, + "apiKeyIv" TEXT NOT NULL, + "apiKeyTag" TEXT NOT NULL, + "uploadUrl" TEXT NOT NULL DEFAULT '', + "downloadUrl" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ConversationSettings_pkey" PRIMARY KEY ("organizationId") +); + +-- CreateTable +CREATE TABLE "ConversationRoom" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "externalRoomId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "name" TEXT, + "dmUserMinId" TEXT, + "dmUserMaxId" TEXT, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ConversationRoom_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationRoom_organizationId_externalRoomId_key" ON "ConversationRoom"("organizationId", "externalRoomId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationRoom_organizationId_dmUserMinId_dmUserMaxId_key" ON "ConversationRoom"("organizationId", "dmUserMinId", "dmUserMaxId"); + +-- CreateIndex +CREATE INDEX "ConversationRoom_organizationId_kind_idx" ON "ConversationRoom"("organizationId", "kind"); + +-- AddForeignKey +ALTER TABLE "ConversationSettings" ADD CONSTRAINT "ConversationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ConversationRoom" ADD CONSTRAINT "ConversationRoom_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f73a63b..224968c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -207,6 +207,8 @@ model Organization { leadStages LeadStage[] leadEvents LeadEvent[] leadImportJobs LeadImportJob[] + conversationSettings ConversationSettings? + conversationRooms ConversationRoom[] } model User { @@ -2060,3 +2062,36 @@ model LeadImportJob { @@index([organizationId, status, createdAt]) @@index([status, createdAt]) } + +/// تنظیمات اتصال به سرویس گفتگوی خارجی (هر محیط CRM) +model ConversationSettings { + organizationId String @id + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + chatHost String + apiKeyCipher String + apiKeyIv String + apiKeyTag String + uploadUrl String @default("") + downloadUrl String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// نگاشت roomهای سرویس چت (DM dedup و گروه‌های ساخته‌شده از CRM) +model ConversationRoom { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + externalRoomId String + kind String + name String? + dmUserMinId String? + dmUserMaxId String? + createdByUserId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, externalRoomId]) + @@unique([organizationId, dmUserMinId, dmUserMaxId]) + @@index([organizationId, kind]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2f2a573..c73d07e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -43,6 +43,7 @@ import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module import { VoipModule } from './integrations/voip/voip.module'; import { ReservationsModule } from './reservations/reservations.module'; import { LeadsModule } from './leads/leads.module'; +import { ConversationsModule } from './conversations/conversations.module'; @Module({ imports: [ @@ -93,6 +94,7 @@ import { LeadsModule } from './leads/leads.module'; WooCommerceModule, VoipModule, ReservationsModule, + ConversationsModule, ], }) export class AppModule {} diff --git a/apps/api/src/conversations/chat-external.client.ts b/apps/api/src/conversations/chat-external.client.ts new file mode 100644 index 0000000..184df52 --- /dev/null +++ b/apps/api/src/conversations/chat-external.client.ts @@ -0,0 +1,88 @@ +import { BadGatewayException, Injectable } from '@nestjs/common'; + +export type ExternalRoomRow = Record; + +@Injectable() +export class ChatExternalClient { + normalizeHost(host: string): string { + return host + .trim() + .replace(/^wss?:\/\//, '') + .replace(/^https?:\/\//, '') + .replace(/\/+$/, ''); + } + + async createRoom( + host: string, + apiKey: string, + payload: { + name: string; + type: string; + members: string[]; + }, + ): Promise<{ room_id: string }> { + const base = this.normalizeHost(host); + const url = `https://${base}/api/rooms/create`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + api_key: apiKey, + name: payload.name, + type: payload.type, + members: payload.members, + }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new BadGatewayException( + `سرویس گفتگو room ایجاد نکرد (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`, + ); + } + const data = (await response.json()) as { room_id?: string; id?: string }; + const roomId = String(data.room_id ?? data.id ?? '').trim(); + if (!roomId) { + throw new BadGatewayException('پاسخ سرویس گفتگو فاقد room_id است'); + } + return { room_id: roomId }; + } + + async listUserRooms( + host: string, + apiKey: string, + chatUserId: string, + ): Promise { + const base = this.normalizeHost(host); + const params = new URLSearchParams({ + api_key: apiKey, + user_id: chatUserId, + }); + const url = `https://${base}/api/users/rooms?${params.toString()}`; + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new BadGatewayException( + `دریافت لیست گفتگو ناموفق (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`, + ); + } + const result = await response.json(); + if (Array.isArray(result)) return result as ExternalRoomRow[]; + if (Array.isArray(result?.data)) return result.data as ExternalRoomRow[]; + if (Array.isArray(result?.rooms)) return result.rooms as ExternalRoomRow[]; + if (Array.isArray(result?.items)) return result.items as ExternalRoomRow[]; + return []; + } + + async ping(host: string, apiKey: string, chatUserId: string): Promise { + try { + await this.listUserRooms(host, apiKey, chatUserId); + return true; + } catch { + return false; + } + } +} diff --git a/apps/api/src/conversations/chat-user-id.util.ts b/apps/api/src/conversations/chat-user-id.util.ts new file mode 100644 index 0000000..99e05c0 --- /dev/null +++ b/apps/api/src/conversations/chat-user-id.util.ts @@ -0,0 +1,10 @@ +/** شناسه پایدار کاربر در سرویس چت خارجی (scoped به org) */ +export function buildChatUserId(organizationId: string, userId: string): string { + const o = organizationId.replace(/-/g, '').slice(0, 12); + const u = userId.replace(/-/g, '').slice(0, 12); + return `m${o}u${u}`; +} + +export function dmPairKey(userA: string, userB: string): [string, string] { + return userA < userB ? [userA, userB] : [userB, userA]; +} diff --git a/apps/api/src/conversations/conversations-permissions.ts b/apps/api/src/conversations/conversations-permissions.ts new file mode 100644 index 0000000..b2188a3 --- /dev/null +++ b/apps/api/src/conversations/conversations-permissions.ts @@ -0,0 +1,21 @@ +import type { RolePermissionKey, UserRole } from '@mento/shared'; +import { sanitizePermissions } from '../org-roles/role-defaults'; + +export type ConversationAccess = { + canUse: boolean; + canCreateGroup: boolean; +}; + +export function resolveConversationAccess( + membershipRole: UserRole | string, + orgRolePermissions: unknown, +): ConversationAccess { + if (membershipRole === 'owner' || membershipRole === 'admin') { + return { canUse: true, canCreateGroup: true }; + } + const permissions = sanitizePermissions(orgRolePermissions) as RolePermissionKey[]; + return { + canUse: permissions.includes('conversations'), + canCreateGroup: permissions.includes('conversations_groups'), + }; +} diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts new file mode 100644 index 0000000..3e9dc67 --- /dev/null +++ b/apps/api/src/conversations/conversations.controller.ts @@ -0,0 +1,78 @@ +import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { RequireOrgModule } from '../billing/org-module.decorator'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { + CreateConversationDmDto, + CreateConversationGroupDto, + UpdateConversationSettingsDto, +} from './dto'; +import { ConversationsService } from './conversations.service'; + +@Controller('conversations') +@UseGuards(JwtAuthGuard, OrgModuleGuard) +@RequireOrgModule('conversations') +export class ConversationsController { + constructor(private readonly conversations: ConversationsService) {} + + @Get('access') + access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) { + return this.conversations.getAccess(user.userId, user.organizationId, user.role); + } + + @Get('settings') + settings(@CurrentUser() user: { organizationId: string; role: string }) { + return this.conversations.getSettings(user.organizationId); + } + + @Patch('settings') + updateSettings( + @CurrentUser() user: { organizationId: string; role: string }, + @Body() dto: UpdateConversationSettingsDto, + ) { + return this.conversations.updateSettings(user.organizationId, user.role, dto); + } + + @Get('session') + session(@CurrentUser() user: { userId: string; organizationId: string; role: string }) { + return this.conversations.getSession(user.userId, user.organizationId, user.role); + } + + @Get('peers') + peers(@CurrentUser() user: { userId: string; organizationId: string; role: string }) { + return this.conversations.listPeers(user.userId, user.organizationId, user.role); + } + + @Get('rooms') + rooms(@CurrentUser() user: { userId: string; organizationId: string; role: string }) { + return this.conversations.listRooms(user.userId, user.organizationId, user.role); + } + + @Post('rooms/dm') + createDm( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: CreateConversationDmDto, + ) { + return this.conversations.createDm( + user.userId, + user.organizationId, + user.role, + dto.peerUserId, + ); + } + + @Post('rooms/group') + createGroup( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: CreateConversationGroupDto, + ) { + return this.conversations.createGroup( + user.userId, + user.organizationId, + user.role, + dto.name, + dto.memberUserIds, + ); + } +} diff --git a/apps/api/src/conversations/conversations.module.ts b/apps/api/src/conversations/conversations.module.ts new file mode 100644 index 0000000..f91d8c1 --- /dev/null +++ b/apps/api/src/conversations/conversations.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BillingModule } from '../billing/billing.module'; +import { TahesabModule } from '../integrations/tahesab/tahesab.module'; +import { ChatExternalClient } from './chat-external.client'; +import { ConversationsController } from './conversations.controller'; +import { ConversationsService } from './conversations.service'; + +@Module({ + imports: [AuthModule, BillingModule, TahesabModule], + controllers: [ConversationsController], + providers: [ConversationsService, ChatExternalClient], + exports: [ConversationsService], +}) +export class ConversationsModule {} diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts new file mode 100644 index 0000000..569cec0 --- /dev/null +++ b/apps/api/src/conversations/conversations.service.ts @@ -0,0 +1,431 @@ +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, + }; + } +} diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts new file mode 100644 index 0000000..84cf0d2 --- /dev/null +++ b/apps/api/src/conversations/dto.ts @@ -0,0 +1,51 @@ +import { + IsArray, + IsOptional, + IsString, + MaxLength, + MinLength, + ArrayMinSize, + ArrayMaxSize, +} from 'class-validator'; + +export class UpdateConversationSettingsDto { + @IsString() + @MinLength(3) + @MaxLength(200) + chatHost!: string; + + @IsOptional() + @IsString() + @MinLength(8) + @MaxLength(500) + apiKey?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + uploadUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + downloadUrl?: string; +} + +export class CreateConversationDmDto { + @IsString() + @MinLength(1) + peerUserId!: string; +} + +export class CreateConversationGroupDto { + @IsString() + @MinLength(2) + @MaxLength(120) + name!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(50) + @IsString({ each: true }) + memberUserIds!: string[]; +} diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index 73b2835..0129b83 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -22,6 +22,7 @@ import { TahesabController } from './tahesab.controller'; TahesabDocumentsService, ], exports: [ + TahesabCryptoService, TahesabConnectionsService, TahesabPersonLinkService, TahesabProductLinkService, diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts index ffe5d57..1da39ab 100644 --- a/apps/api/src/platform/platform.controller.ts +++ b/apps/api/src/platform/platform.controller.ts @@ -23,17 +23,26 @@ import { import { PlatformAuthGuard } from './platform-auth.guard'; import type { PlatformActor } from './platform-jwt.strategy'; import { PlatformService } from './platform.service'; +import { ConversationsService } from '../conversations/conversations.service'; @Controller('platform') @UseGuards(PlatformAuthGuard) export class PlatformController { - constructor(private readonly platform: PlatformService) {} + constructor( + private readonly platform: PlatformService, + private readonly conversations: ConversationsService, + ) {} @Get('stats') stats() { return this.platform.stats(); } + @Get('conversations/stats') + conversationStats() { + return this.conversations.platformStats(); + } + @Get('admins') listAdmins() { return this.platform.listAdmins(); diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts index 2f1bdfd..6e6d706 100644 --- a/apps/api/src/platform/platform.module.ts +++ b/apps/api/src/platform/platform.module.ts @@ -11,6 +11,7 @@ import { PlatformAuthService } from './platform-auth.service'; import { PlatformJwtStrategy } from './platform-jwt.strategy'; import { PlatformController } from './platform.controller'; import { PlatformService } from './platform.service'; +import { ConversationsModule } from '../conversations/conversations.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { PlatformService } from './platform.service'; PipelinesModule, BillingModule, PassportModule, + ConversationsModule, ], controllers: [PlatformAuthController, PlatformController], providers: [ diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index d37e92c..098cbf0 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -64,7 +64,7 @@ const salesActive = computed(() => ); const activitiesActive = computed(() => pathIn(['/activities'])); const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/')); -const supportActive = computed(() => pathIn(['/tickets'])); +const supportActive = computed(() => pathIn(['/tickets', '/conversations'])); const automationsActive = computed( () => route.path.startsWith('/settings/automations') || diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index e081255..2192c4a 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -247,6 +247,12 @@ export const NAV_HUBS: NavHubDef[] = [ shortcuts: [ { to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' }, { to: '/tickets', title: 'تیکت‌ها', icon: 'chat', module: 'tickets' }, + { + to: '/conversations', + title: 'گفتگو', + icon: 'chat', + module: 'conversations', + }, ], cards: [ { @@ -257,6 +263,14 @@ export const NAV_HUBS: NavHubDef[] = [ tint: 'peach', module: 'tickets', }, + { + to: '/conversations', + title: 'گفتگوی درون‌سازمانی', + desc: 'پیام خصوصی و گروه بین همکاران', + icon: 'chat', + tint: 'violet', + module: 'conversations', + }, ], }, { diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts new file mode 100644 index 0000000..269946d --- /dev/null +++ b/apps/web/src/lib/useOrgChat.ts @@ -0,0 +1,211 @@ +import { computed, onUnmounted, ref, shallowRef } from 'vue'; +import type { ConversationSessionDto } from '@mento/shared'; +import { api } from '@/lib/api'; + +export type ChatMessage = { + id: string; + roomId: string; + senderId: string; + content: string; + type: 'text' | 'image' | 'voice'; + createdAt: string; + reply: string | null; + optimistic?: boolean; +}; + +const PAGE_SIZE = 50; + +let socket: WebSocket | null = null; +let sessionCache: ConversationSessionDto | null = null; +const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle'); +const messages = shallowRef([]); +const activeRoomId = ref(''); +let pingTimer: ReturnType | null = null; + +function normalizeIncoming(raw: Record, roomFallback: string): ChatMessage | null { + const base = + raw.message && typeof raw.message === 'object' + ? (raw.message as Record) + : raw; + const content = String(base.content ?? base.text ?? base.body ?? '').trim(); + if (!content && !base.attachments) return null; + const id = String(base.id ?? base.message_id ?? crypto.randomUUID()); + return { + id, + roomId: String(base.room_id ?? raw.room_id ?? roomFallback), + senderId: String( + base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '', + ), + content, + type: (String(base.type_content ?? base.type ?? 'text') as ChatMessage['type']) || 'text', + createdAt: String( + base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(), + ), + reply: base.reply ? String(base.reply) : null, + }; +} + +async function ensureSession(): Promise { + if (sessionCache) return sessionCache; + const { data } = await api.get('/conversations/session'); + sessionCache = data; + return data; +} + +function clearPing() { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } +} + +function disconnectSocket() { + clearPing(); + if (socket) { + socket.close(); + socket = null; + } + status.value = 'closed'; +} + +async function connectSocket() { + const session = await ensureSession(); + if (socket?.readyState === WebSocket.OPEN) return; + + disconnectSocket(); + status.value = 'connecting'; + socket = new WebSocket(session.wsUrl); + + socket.addEventListener('open', () => { + status.value = 'open'; + pingTimer = setInterval(() => { + if (socket?.readyState === WebSocket.OPEN) socket.send('ping'); + }, 15000); + }); + + socket.addEventListener('close', () => { + status.value = 'closed'; + clearPing(); + }); + + socket.addEventListener('message', (event) => { + const raw = String(event.data ?? '').trim(); + if (!raw || raw === 'pong' || raw === 'ping') return; + try { + const parsed = JSON.parse(raw) as Record; + if (parsed.type === 'seen' || parsed.type === 'typing' || parsed.type === 'read') return; + const msg = normalizeIncoming(parsed, activeRoomId.value); + if (!msg || msg.roomId !== activeRoomId.value) return; + messages.value = [...messages.value, msg]; + } catch { + // ignore + } + }); + + await new Promise((resolve, reject) => { + if (!socket) return reject(new Error('socket missing')); + const t = setTimeout(() => reject(new Error('ws timeout')), 12000); + socket.addEventListener( + 'open', + () => { + clearTimeout(t); + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + 'error', + () => { + clearTimeout(t); + reject(new Error('ws error')); + }, + { once: true }, + ); + }); +} + +export function useOrgChat() { + const loadingMessages = ref(false); + + async function loadMessages(roomId: string) { + activeRoomId.value = roomId; + loadingMessages.value = true; + try { + const session = await ensureSession(); + await connectSocket(); + const params = new URLSearchParams({ + api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '', + room_id: roomId, + user_id: session.chatUserId, + limit: String(PAGE_SIZE), + offset: '0', + }); + const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`; + const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }); + if (!res.ok) throw new Error('messages failed'); + const body = await res.json(); + const list = Array.isArray(body) ? body : body?.data ?? []; + messages.value = list + .map((item: Record) => normalizeIncoming(item, roomId)) + .filter(Boolean) as ChatMessage[]; + } finally { + loadingMessages.value = false; + } + } + + async function sendText(roomId: string, text: string, reply: string | null = null) { + const trimmed = text.trim(); + if (!trimmed) return; + await connectSocket(); + if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open'); + const session = await ensureSession(); + const id = crypto.randomUUID(); + const payload: Record = { + id, + type: 'text', + room_id: roomId, + content: trimmed, + }; + if (reply) payload.reply = reply; + const optimistic: ChatMessage = { + id, + roomId, + senderId: session.chatUserId, + content: trimmed, + type: 'text', + createdAt: new Date().toISOString(), + reply, + optimistic: true, + }; + messages.value = [...messages.value, optimistic]; + socket.send(JSON.stringify(payload)); + sendRead(roomId); + } + + function sendRead(roomId: string) { + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: 'read', room_id: roomId })); + } + + function resetSession() { + sessionCache = null; + disconnectSocket(); + messages.value = []; + activeRoomId.value = ''; + } + + onUnmounted(() => { + disconnectSocket(); + }); + + return { + status: computed(() => status.value), + messages: computed(() => messages.value), + loadingMessages, + loadMessages, + sendText, + sendRead, + resetSession, + ensureSession, + }; +} diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 2c5a0c6..2325648 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -92,6 +92,11 @@ export const router = createRouter({ name: 'tickets', component: () => import('@/views/TicketsView.vue'), }, + { + path: 'conversations', + name: 'conversations', + component: () => import('@/views/ConversationsView.vue'), + }, { path: 'tickets/new', name: 'ticket-new', @@ -344,6 +349,12 @@ export const router = createRouter({ component: () => import('@/views/VoipSettingsView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, }, + { + path: 'settings/conversations', + name: 'conversations-settings', + component: () => import('@/views/ConversationsSettingsView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, { path: 'settings/faq', name: 'faq-settings', diff --git a/apps/web/src/views/ConversationsSettingsView.vue b/apps/web/src/views/ConversationsSettingsView.vue new file mode 100644 index 0000000..78f5930 --- /dev/null +++ b/apps/web/src/views/ConversationsSettingsView.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue new file mode 100644 index 0000000..0d0761c --- /dev/null +++ b/apps/web/src/views/ConversationsView.vue @@ -0,0 +1,652 @@ + + + + + diff --git a/apps/web/src/views/adm/AdmDashboardView.vue b/apps/web/src/views/adm/AdmDashboardView.vue index c9941c4..c6fcef6 100644 --- a/apps/web/src/views/adm/AdmDashboardView.vue +++ b/apps/web/src/views/adm/AdmDashboardView.vue @@ -1,7 +1,7 @@