diff --git a/apps/api/src/conversations/chat-external.client.ts b/apps/api/src/conversations/chat-external.client.ts index 184df52..0a3254e 100644 --- a/apps/api/src/conversations/chat-external.client.ts +++ b/apps/api/src/conversations/chat-external.client.ts @@ -1,7 +1,13 @@ -import { BadGatewayException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; export type ExternalRoomRow = Record; +const FETCH_TIMEOUT_MS = 12_000; + @Injectable() export class ChatExternalClient { normalizeHost(host: string): string { @@ -12,6 +18,43 @@ export class ChatExternalClient { .replace(/\/+$/, ''); } + private async fetchJson( + url: string, + init: RequestInit, + ): Promise<{ ok: boolean; status: number; bodyText: string; json: unknown }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const bodyText = await response.text().catch(() => ''); + let json: unknown = null; + if (bodyText) { + try { + json = JSON.parse(bodyText); + } catch { + json = null; + } + } + return { ok: response.ok, status: response.status, bodyText, json }; + } catch (e) { + const aborted = e instanceof Error && e.name === 'AbortError'; + throw new ServiceUnavailableException( + aborted + ? `سرویس گفتگو پاسخ نداد (timeout ${FETCH_TIMEOUT_MS / 1000}s). Host و شبکه سرور را بررسی کنید.` + : `اتصال به سرویس گفتگو برقرار نشد: ${e instanceof Error ? e.message : 'unknown'}`, + ); + } finally { + clearTimeout(timer); + } + } + + private fail(status: number, body: string, action: string): never { + const snippet = body.trim().slice(0, 220); + throw new ServiceUnavailableException( + `${action} ناموفق بود (HTTP ${status})${snippet ? `: ${snippet}` : ''}`, + ); + } + async createRoom( host: string, apiKey: string, @@ -22,8 +65,9 @@ export class ChatExternalClient { }, ): Promise<{ room_id: string }> { const base = this.normalizeHost(host); + if (!base) throw new BadRequestException('Host سرویس گفتگو خالی است'); const url = `https://${base}/api/rooms/create`; - const response = await fetch(url, { + const { ok, status, bodyText, json } = await this.fetchJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ @@ -33,16 +77,11 @@ export class ChatExternalClient { 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 }; + if (!ok) this.fail(status, bodyText, 'ایجاد اتاق گفتگو'); + const data = (json ?? {}) as { room_id?: string; id?: string }; const roomId = String(data.room_id ?? data.id ?? '').trim(); if (!roomId) { - throw new BadGatewayException('پاسخ سرویس گفتگو فاقد room_id است'); + throw new ServiceUnavailableException('پاسخ سرویس گفتگو فاقد room_id است'); } return { room_id: roomId }; } @@ -53,27 +92,31 @@ export class ChatExternalClient { chatUserId: string, ): Promise { const base = this.normalizeHost(host); + if (!base) throw new BadRequestException('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, { + const { ok, status, bodyText, json } = await this.fetchJson(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(); + + // کاربر جدید بدون room — خالی برگردان + if (status === 404 || status === 204) return []; + + if (!ok) this.fail(status, bodyText, 'دریافت لیست گفتگو از سرویس چت'); + + const result = 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[]; + if (result && typeof result === 'object') { + const obj = result as Record; + if (Array.isArray(obj.data)) return obj.data as ExternalRoomRow[]; + if (Array.isArray(obj.rooms)) return obj.rooms as ExternalRoomRow[]; + if (Array.isArray(obj.items)) return obj.items as ExternalRoomRow[]; + } return []; } diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 44bf7b6..9804f7a 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -180,13 +180,39 @@ export class ConversationsService { 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)); + try { + const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId); + return external.map((row) => + this.toRoomDto(row, trackedByExternal, organizationId, userId), + ); + } catch (e) { + // اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده + if (tracked.length) { + return tracked.map((t) => ({ + id: t.id, + externalRoomId: t.externalRoomId, + kind: t.kind === 'group' ? 'group' : 'dm', + name: t.name || (t.kind === 'group' ? 'گروه' : 'گفتگو'), + unreadCount: 0, + lastMessagePreview: null, + lastMessageAt: null, + peerUserId: + t.kind === 'dm' + ? t.dmUserMinId === userId + ? t.dmUserMaxId + : t.dmUserMaxId === userId + ? t.dmUserMinId + : null + : null, + })); + } + throw e; + } } async createDm( @@ -366,20 +392,26 @@ export class ConversationsService { }); 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 سرور را بررسی کنید یا تنظیمات را دوباره ذخیره کنید.', ); } - 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( diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index ddaa8ed..cc797a3 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -66,27 +66,49 @@ async function bootstrap() { loading.value = true; error.value = ''; try { - const [accessRes, roomsRes, peersRes] = await Promise.all([ - api.get('/conversations/access'), - api.get('/conversations/rooms'), - api.get('/conversations/peers'), - ]); + const accessRes = await api.get('/conversations/access'); access.value = accessRes.data; if (!accessRes.data.canUse) { - error.value = 'به گفتگوی درون‌سازمانی دسترسی ندارید.'; + error.value = 'به گفتگو دسترسی ندارید.'; return; } - rooms.value = roomsRes.data; - peers.value = peersRes.data; - const sessionRes = await api.get<{ chatUserId: string }>('/conversations/session'); - chatUserId.value = sessionRes.data.chatUserId; + + const [roomsOutcome, peersOutcome, sessionOutcome] = await Promise.allSettled([ + api.get('/conversations/rooms'), + api.get('/conversations/peers'), + api.get<{ chatUserId: string }>('/conversations/session'), + ]); + + if (peersOutcome.status === 'fulfilled') { + peers.value = peersOutcome.value.data; + } + if (sessionOutcome.status === 'fulfilled') { + chatUserId.value = sessionOutcome.value.data.chatUserId; + } + if (roomsOutcome.status === 'fulfilled') { + rooms.value = roomsOutcome.value.data; + } else { + const e = roomsOutcome.reason; + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + error.value = Array.isArray(msg) + ? msg.join('، ') + : msg || + (e.response?.status === 503 || e.response?.status === 502 + ? 'اتصال به سرویس چت برقرار نشد. Host و API Key را در سوپرادمین بررسی کنید.' + : 'بارگذاری لیست گفتگو ناموفق بود.'); + } else { + error.value = 'بارگذاری لیست گفتگو ناموفق بود.'; + } + } } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 403) { error.value = 'ماژول گفتگو برای این محیط فعال نیست.'; - } else if (axios.isAxiosError(e) && e.response?.status === 400) { - error.value = - (e.response.data as { message?: string })?.message ?? - 'ابتدا تنظیمات گفتگو را تکمیل کنید.'; + } else if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + error.value = Array.isArray(msg) + ? msg.join('، ') + : msg || 'بارگذاری گفتگو ناموفق بود.'; } else { error.value = 'بارگذاری گفتگو ناموفق بود.'; }