From 712187c1b00e33387b0429fc14709ecf42169379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=B0=D0=BB=D1=8C=D0=BE=D0=BD=D0=BD=D0=B8=D0=B9?= Date: Fri, 11 Sep 2026 19:01:51 +0300 Subject: [PATCH 1/3] fix: use conversationId/props.contact.id for closed-chat history instead of live chat.contact.id [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) --- .../closed/store/__tests__/closed.spec.js | 267 ++++++++++++++++++ .../chat/modules/closed/store/closed.js | 39 ++- src/features/modules/chat/store/chat.js | 15 +- .../chat-history/the-chat-history.vue | 8 +- 4 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js diff --git a/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js b/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js new file mode 100644 index 000000000..337e5fd23 --- /dev/null +++ b/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js @@ -0,0 +1,267 @@ +import closedModule from '../closed'; + +describe('features/chat/closed store: actions', () => { + let context; + + beforeEach(() => { + context = { + rootState: { + features: { + chat: { + chatHistory: { + next: false, + }, + }, + }, + }, + dispatch: vi.fn().mockResolvedValue(undefined), + commit: vi.fn(), + }; + }); + + describe('OPEN_CLOSED_CHAT', () => { + it('dispatches LOAD_CLOSED_CHAT for a REST stub without an identified contact', async () => { + const chat = { + id: '1', + closedAt: Date.now(), + }; + await closedModule.actions.OPEN_CLOSED_CHAT(context, chat); + expect(context.dispatch).toHaveBeenCalledWith('LOAD_CLOSED_CHAT', chat); + }); + + it('dispatches SET_WORKSPACE directly for a REST stub with an identified contact', async () => { + const chat = { + id: '1', + closedAt: Date.now(), + contact: { + id: 'contact-1', + }, + }; + await closedModule.actions.OPEN_CLOSED_CHAT(context, chat); + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/SET_WORKSPACE', + chat, + { + root: true, + }, + ); + expect(context.dispatch).not.toHaveBeenCalledWith( + 'LOAD_CLOSED_CHAT', + expect.anything(), + ); + }); + + it('dispatches SET_WORKSPACE for a live SDK Conversation even when contact.id is unset (post-processing) [WTEL-9955]', async () => { + // a live SDK Conversation instance, not a plain REST object + class Conversation { + get contact() { + return { + id: null, + }; + } + } + const chat = Object.assign(new Conversation(), { + id: 'channel-1', + closedAt: Date.now(), + }); + + await closedModule.actions.OPEN_CLOSED_CHAT(context, chat); + + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/SET_WORKSPACE', + chat, + { + root: true, + }, + ); + expect(context.dispatch).not.toHaveBeenCalledWith( + 'LOAD_CLOSED_CHAT', + expect.anything(), + ); + }); + }); + + describe('LOAD_CLOSED_CHAT_HISTORY', () => { + it('loads history for the given contactId (not chat.contact.id) and searches for the target chat', async () => { + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + // chat.contact.id is null here on purpose: the action must rely on + // the passed-in contactId instead, see WTEL-9955 + contact: { + id: null, + }, + }; + const contactId = 'contact-1'; + + await closedModule.actions.LOAD_CLOSED_CHAT_HISTORY(context, { + chat, + contactId, + }); + + expect(context.dispatch).toHaveBeenCalledWith('RESET_CLOSED_CHAT'); + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/chatHistory/LOAD_CHAT_HISTORY', + contactId, + { + root: true, + }, + ); + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_IN_HISTORY', + { + chat, + contactId, + }, + ); + expect(context.commit).toHaveBeenCalledWith( + 'SET_IS_CLOSED_CHAT_LOADED', + true, + ); + }); + + it('still marks the closed chat as loaded when loading history fails', async () => { + const err = new Error('network error'); + context.dispatch.mockImplementation((action) => { + if (action === 'features/chat/chatHistory/LOAD_CHAT_HISTORY') { + return Promise.reject(err); + } + return Promise.resolve(); + }); + + await expect( + closedModule.actions.LOAD_CLOSED_CHAT_HISTORY(context, { + chat: { + id: 'channel-1', + }, + contactId: 'contact-1', + }), + ).rejects.toBeTruthy(); + + expect(context.commit).toHaveBeenCalledWith( + 'SET_IS_CLOSED_CHAT_LOADED', + true, + ); + }); + }); + + describe('FIND_TARGET_CHAT_IN_HISTORY', () => { + it('returns early without dispatching anything when there is no next history page', async () => { + context.rootState.features.chat.chatHistory.next = false; + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + }; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId: 'contact-1', + }); + + expect(context.dispatch).not.toHaveBeenCalled(); + }); + + it('looks up the target message by conversationId, not by the live chat.id [WTEL-9955]', async () => { + context.rootState.features.chat.chatHistory.next = true; + // chat.id is the live channelId, which never appears as message.chat.id + // in chatHistory data — conversationId does (see ContactChatMessagesAPI) + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + }; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId: 'contact-1', + }); + + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_FIRST_MESSAGE', + 'conv-1', + ); + }); + + it('falls back to chat.id when conversationId is missing', async () => { + context.rootState.features.chat.chatHistory.next = true; + const chat = { + id: 'channel-1', + }; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId: 'contact-1', + }); + + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_FIRST_MESSAGE', + 'channel-1', + ); + }); + + it('commits the found message id and does not load another history page once the target chat is found', async () => { + context.rootState.features.chat.chatHistory.next = true; + const foundMessage = { + id: 'message-1', + }; + context.dispatch.mockImplementation((action) => { + if (action === 'FIND_TARGET_CHAT_FIRST_MESSAGE') { + return Promise.resolve(foundMessage); + } + return Promise.resolve(); + }); + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat: { + id: 'channel-1', + conversationId: 'conv-1', + }, + contactId: 'contact-1', + }); + + expect(context.commit).toHaveBeenCalledWith( + 'SET_CLOSED_CHAT_FIRST_MESSAGE_ID', + foundMessage.id, + ); + expect(context.dispatch).not.toHaveBeenCalledWith( + 'features/chat/chatHistory/LOAD_NEXT', + expect.anything(), + expect.anything(), + ); + }); + + it('loads the next history page and recurses with the same contactId when the target chat is not found yet', async () => { + context.rootState.features.chat.chatHistory.next = true; + context.dispatch.mockImplementation((action) => { + if (action === 'FIND_TARGET_CHAT_FIRST_MESSAGE') { + return Promise.resolve(undefined); + } + return Promise.resolve(); + }); + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + }; + const contactId = 'contact-1'; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId, + }); + + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/chatHistory/LOAD_NEXT', + contactId, + { + root: true, + }, + ); + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_IN_HISTORY', + { + chat, + contactId, + }, + ); + }); + }); +}); diff --git a/src/features/modules/chat/modules/closed/store/closed.js b/src/features/modules/chat/modules/closed/store/closed.js index f5ba9f0f4..dfce8b60b 100644 --- a/src/features/modules/chat/modules/closed/store/closed.js +++ b/src/features/modules/chat/modules/closed/store/closed.js @@ -138,7 +138,16 @@ const actions = { } }, OPEN_CLOSED_CHAT: async (context, chat) => { - if (!chat.contact?.id) { + /** + * @author @OleksandrPalonnyi + * + * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) + * + * see OPEN_CHAT in features/chat/store/chat.js — same reasoning for + * distinguishing a REST closed-chat stub from a live SDK instance. + */ + const isChatFromRestApi = chat.constructor === Object; + if (isChatFromRestApi && !chat.contact?.id) { await context.dispatch('LOAD_CLOSED_CHAT', chat); } else { context.commit('SET_CLOSED_CHAT_FIRST_MESSAGE_ID', null); @@ -148,10 +157,7 @@ const actions = { }); } }, - LOAD_CLOSED_CHAT_HISTORY: async (context, chat) => { - const contactId = chat.contact.id; - const targetChatId = chat.id; - + LOAD_CLOSED_CHAT_HISTORY: async (context, { chat, contactId }) => { try { context.dispatch('RESET_CLOSED_CHAT'); await context.dispatch( @@ -162,7 +168,10 @@ const actions = { }, ); - await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', chat); + await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', { + chat, + contactId, + }); } catch (err) { throw applyTransform(err, [ notify, @@ -172,10 +181,17 @@ const actions = { } }, - FIND_TARGET_CHAT_IN_HISTORY: async (context, chat) => { + FIND_TARGET_CHAT_IN_HISTORY: async (context, { chat, contactId }) => { // recursive function - const contactId = chat.contact.id; - const targetChatId = chat.id; + /** + * @author @OleksandrPalonnyi + * + * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) + * + * chatHistory messages carry conversationId as message.chat.id, not chat.id + * — otherwise the target is never found and pagination spins forever. + */ + const targetChatId = chat.conversationId || chat.id; const next = context.rootState.features.chat.chatHistory.next; if (!next) return; @@ -196,7 +212,10 @@ const actions = { await context.dispatch('features/chat/chatHistory/LOAD_NEXT', contactId, { root: true, }); - await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', chat); // call itself until find target chat + await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', { + chat, + contactId, + }); // call itself until find target chat }, FIND_TARGET_CHAT_FIRST_MESSAGE: async (context, targetChatId) => { // try to find first message of needed chat diff --git a/src/features/modules/chat/store/chat.js b/src/features/modules/chat/store/chat.js index 85cb77b62..1e2a9cca0 100644 --- a/src/features/modules/chat/store/chat.js +++ b/src/features/modules/chat/store/chat.js @@ -143,7 +143,20 @@ const actions = { }, OPEN_CHAT: async (context, chat) => { - const isUnidentifiedClosedChat = !chat.contact?.id && chat.closedAt; + /** + * @author @OleksandrPalonnyi + * + * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) + * + * A live SDK `Conversation` can have `contact.id` unset too (e.g. during + * post-processing), so `!chat.contact?.id` alone can't tell a REST closed-chat + * stub from a live instance. Only a plain object (REST stub) needs the + * LOAD_CLOSED_CHAT reload — spreading a live Conversation into a plain object + * drops its prototype getters (`id`, `allowReporting`, …). + */ + const isChatFromRestApi = chat.constructor === Object; + const isUnidentifiedClosedChat = + isChatFromRestApi && !chat.contact?.id && chat.closedAt; if (isUnidentifiedClosedChat) { await context.dispatch('features/chat/closed/LOAD_CLOSED_CHAT', chat, { diff --git a/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue b/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue index e94c246e2..a444b74d5 100644 --- a/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue +++ b/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue @@ -189,10 +189,10 @@ const { startObserve: startObserveClosedChat } = useObserveHeightUntilStable( const loadHistory = async () => await store.dispatch(`${namespace}/LOAD_CHAT_HISTORY`, props.contact?.id); const loadClosedChatHistory = async () => - await store.dispatch( - `features/chat/closed/LOAD_CLOSED_CHAT_HISTORY`, - chat.value, - ); + await store.dispatch(`features/chat/closed/LOAD_CLOSED_CHAT_HISTORY`, { + chat: chat.value, + contactId: props.contact?.id, + }); const resetHistory = () => store.dispatch(`${namespace}/RESET_CHAT_HISTORY`); const attachPlayer = (player) => From c2195c1bc48a5574a03f8cd76e1d767b833a6c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=B0=D0=BB=D1=8C=D0=BE=D0=BD=D0=BD=D0=B8=D0=B9?= Date: Tue, 15 Sep 2026 14:47:49 +0300 Subject: [PATCH 2/3] fix: use instanceof Conversation to detect REST closed-chat stubs, [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) --- .../closed/store/__tests__/closed.spec.js | 17 ++++++++--------- .../modules/chat/modules/closed/store/closed.js | 3 ++- src/features/modules/chat/store/chat.js | 13 ++++++++----- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js b/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js index 337e5fd23..ac9b56c53 100644 --- a/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js +++ b/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js @@ -1,3 +1,5 @@ +import { Conversation } from 'webitel-sdk'; + import closedModule from '../closed'; describe('features/chat/closed store: actions', () => { @@ -52,17 +54,14 @@ describe('features/chat/closed store: actions', () => { }); it('dispatches SET_WORKSPACE for a live SDK Conversation even when contact.id is unset (post-processing) [WTEL-9955]', async () => { - // a live SDK Conversation instance, not a plain REST object - class Conversation { - get contact() { - return { - id: null, - }; - } - } + // a live SDK Conversation instance, not a plain REST object; + // `id` is a getter derived from `channelId`, so set that instead const chat = Object.assign(new Conversation(), { - id: 'channel-1', + channelId: 'channel-1', closedAt: Date.now(), + contact: { + id: null, + }, }); await closedModule.actions.OPEN_CLOSED_CHAT(context, chat); diff --git a/src/features/modules/chat/modules/closed/store/closed.js b/src/features/modules/chat/modules/closed/store/closed.js index dfce8b60b..81c7f85df 100644 --- a/src/features/modules/chat/modules/closed/store/closed.js +++ b/src/features/modules/chat/modules/closed/store/closed.js @@ -1,6 +1,7 @@ import applyTransform, { notify, } from '@webitel/ui-sdk/src/api/transformers/index'; +import { Conversation } from 'webitel-sdk'; import AgentChatsAPI from '../../../../../../app/api/agent-workspace/endpoints/agent-info/agent-chats'; import CatalogAPI from '../../../../../../app/api/agent-workspace/endpoints/catalog/CatalogAPIRepository'; @@ -146,7 +147,7 @@ const actions = { * see OPEN_CHAT in features/chat/store/chat.js — same reasoning for * distinguishing a REST closed-chat stub from a live SDK instance. */ - const isChatFromRestApi = chat.constructor === Object; + const isChatFromRestApi = !(chat instanceof Conversation); if (isChatFromRestApi && !chat.contact?.id) { await context.dispatch('LOAD_CLOSED_CHAT', chat); } else { diff --git a/src/features/modules/chat/store/chat.js b/src/features/modules/chat/store/chat.js index 1e2a9cca0..8694b9c91 100644 --- a/src/features/modules/chat/store/chat.js +++ b/src/features/modules/chat/store/chat.js @@ -1,5 +1,5 @@ import { applyTransform, notify } from '@webitel/api-services/api/transformers'; -import { ConversationState } from 'webitel-sdk'; +import { Conversation, ConversationState } from 'webitel-sdk'; import i18n from '../../../../app/locale/i18n'; import WorkspaceStates from '../../../../ui/enums/WorkspaceState.enum'; @@ -150,11 +150,14 @@ const actions = { * * A live SDK `Conversation` can have `contact.id` unset too (e.g. during * post-processing), so `!chat.contact?.id` alone can't tell a REST closed-chat - * stub from a live instance. Only a plain object (REST stub) needs the - * LOAD_CLOSED_CHAT reload — spreading a live Conversation into a plain object - * drops its prototype getters (`id`, `allowReporting`, …). + * stub from a live instance. `chat instanceof Conversation` checks directly + * against the only place a live instance is ever built + * (buildConversationFromDialog.js) instead of inferring it from the shape of + * a plain object. Only a REST stub needs the LOAD_CLOSED_CHAT reload — + * spreading a live Conversation into a plain object drops its prototype + * getters (`id`, `allowReporting`, …). */ - const isChatFromRestApi = chat.constructor === Object; + const isChatFromRestApi = !(chat instanceof Conversation); const isUnidentifiedClosedChat = isChatFromRestApi && !chat.contact?.id && chat.closedAt; From aed5da25410f39868f03ea6afc0aae3080f12f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=B0=D0=BB=D1=8C=D0=BE=D0=BD=D0=BD=D0=B8=D0=B9?= Date: Tue, 15 Sep 2026 21:55:49 +0300 Subject: [PATCH 3/3] fix: added comment for LOAD_CLOSED_CHAT_HISTORY [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) --- .../chat/modules/closed/store/closed.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/features/modules/chat/modules/closed/store/closed.js b/src/features/modules/chat/modules/closed/store/closed.js index 81c7f85df..41521214f 100644 --- a/src/features/modules/chat/modules/closed/store/closed.js +++ b/src/features/modules/chat/modules/closed/store/closed.js @@ -158,6 +158,17 @@ const actions = { }); } }, + /** + * @author @OleksandrPalonnyi + * + * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) + * + * chat here comes from the CHAT_ON_WORKSPACE getter, which can hold either + * a chat or a task during the closing/post-processing lifecycle, so its id + * and contact are unreliable and can go missing between renders. contactId + * is captured from the contact prop when history loading starts and passed + * through explicitly instead of being re-read off chat later. + */ LOAD_CLOSED_CHAT_HISTORY: async (context, { chat, contactId }) => { try { context.dispatch('RESET_CLOSED_CHAT'); @@ -184,14 +195,6 @@ const actions = { FIND_TARGET_CHAT_IN_HISTORY: async (context, { chat, contactId }) => { // recursive function - /** - * @author @OleksandrPalonnyi - * - * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) - * - * chatHistory messages carry conversationId as message.chat.id, not chat.id - * — otherwise the target is never found and pagination spins forever. - */ const targetChatId = chat.conversationId || chat.id; const next = context.rootState.features.chat.chatHistory.next;