-
Notifications
You must be signed in to change notification settings - Fork 0
fix: use conversationId/props.contact.id for closed-chat history inst… #1601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
712187c
d0d2259
c2195c1
aed5da2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| import { Conversation } from 'webitel-sdk'; | ||
|
|
||
| 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; | ||
| // `id` is a getter derived from `channelId`, so set that instead | ||
| const chat = Object.assign(new Conversation(), { | ||
| channelId: 'channel-1', | ||
| closedAt: Date.now(), | ||
| contact: { | ||
| id: null, | ||
| }, | ||
| }); | ||
|
|
||
| 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, | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
|
|
@@ -138,7 +139,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 instanceof Conversation); | ||
| if (isChatFromRestApi && !chat.contact?.id) { | ||
| await context.dispatch('LOAD_CLOSED_CHAT', chat); | ||
| } else { | ||
| context.commit('SET_CLOSED_CHAT_FIRST_MESSAGE_ID', null); | ||
|
|
@@ -148,10 +158,18 @@ const actions = { | |
| }); | ||
| } | ||
| }, | ||
| LOAD_CLOSED_CHAT_HISTORY: async (context, chat) => { | ||
| const contactId = chat.contact.id; | ||
| const targetChatId = chat.id; | ||
|
|
||
| /** | ||
| * @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'); | ||
| await context.dispatch( | ||
|
|
@@ -162,7 +180,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 +193,9 @@ 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. не поняла...
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @liza-pohranichna річ не в тому, що API не повертає контакт чату, а в тому, що chat.contact просто ненадійний, коли чат ще живий в SDK і триває постобробка, тобто closedAt вже стоїть, а сам інстанс ще не знищено, ми з клодом підтвердили це дебагом: у такого чату chat.contact.id вже приходить (наприклад '405'), а chat.contact.name ще немає, і так було і до закриття, і одразу після. Це не нова знахідка, а вже відомий кейс як в задачі WTEL-6271, і саме тому в getLinkedContact.js:29 стоїть перевірка task?.contact?.id && task?.contact?.name, код навмисно не бере chat.contact на віру, поки не заповнені обидва поля, і йде резолвити контакт іншим шляхом, через картку контакту або через API-пошук по member.user_id. Той контакт, який вже надійно зарезолвлено через getLinkedContact в the-chat.vue, йде вниз як prop через chat-messaging.vue в the-chat-history.vue, і саме звідти передається далі в closed.js як contactId. Він потрібен для якби FIND_TARGET_CHAT_IN_HISTORY замість готового параметра сам читав chat.contact.id, він міг би отримати зовсім інше значення, ніж те, яким уже завантажена історія в LOAD_CHAT_HISTORY/LOAD_NEXT. А контактАйді, яким гортаються сторінки, і контактАйді, яким шукається потрібний чат у вже завантажених сторінках, обов'язково мають збігатися інакше пошук піде не в тій історії, або пагінація взагалі ніколи не знайде потрібне повідомлення
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Саш, пожалій мене(((
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @liza-pohranichna так як сам chat тут це гетер CHAT_ON_WORKSPACE, який під час закриття чату й постобробки може в різні моменти повертати то чат, то task, і в цей період id/contact в ньому можуть просто зникати, тому contactId беремо з проп contact (він завжди стабільний, бо резолвиться окремо через getLinkedContact, цей кейс убв також пофікшений тут WTEL-6271) одразу при старті завантаження історії, і передаємо явно по всьому ланцюжку викликів, замість того щоб перечитувати його з chat на кожному кроці, інакше й початкове завантаження, і пагінація можуть втратити потрібний id або розійтись між собою це хіба з беком якось вирішувати це питання |
||
| const targetChatId = chat.id; | ||
| const targetChatId = chat.conversationId || chat.id; | ||
| const next = context.rootState.features.chat.chatHistory.next; | ||
|
|
||
| if (!next) return; | ||
|
|
@@ -196,7 +216,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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
шось не доганяю... а як закритий чат може бути з вебсокет інстанса?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@liza-pohranichna це після проведеного інвестігейта з клодом: плутанина в тому, що "закритий чат" це не про стан вебсокет-з'єднання, а про бізнес-стан розмови (closedAt), і це дві геть різні речі. Сам вебсокет-клієнт (webitel_sdk) залишається живим, просто той конкретний Conversation-інстанс, який він тримає, отримує позначку, що діалог закрився
віддебажили з ним і ось що вийшло: коли бекенд шле подію Close по вебсокету, buildConversationFromDialog.js:68 виставляє conversation.closedAt = Number(dialog.closed) || 0 прямо на вже існуючому SDK-об'єкті, він його не перестворює і не видаляє. У client-handlers.js подія Close (HANDLE_CLOSE_ACTION) взагалі викликає RESET_CHAT (те, що прибирає чат з активного списку) тільки якщо !chat.allowReporting, тобто якщо постобробка не потрібна. Якщо ж allowReporting: true, чат так і лишається живим Conversation-інстансом в SDK-сторі, просто тепер з closedAt, поки не прийде окрема подія Destroy (HANDLE_DESTROY_ACTION), яка вже реально прибирає його звідти і тягне свіжий список закритих чатів з бекенду.
тобто бекенд навмисно розділяє ці дві події в часі: Close ставить closedAt, але лишає розмову доступною агенту для постобробки, а Destroy прилітає окремо, вже коли той час вийшов. Це прямо задокументовано в post-processing.js де гетер CHATS_IN_POSTPROCESSING там прямим текстом каже, що postProcessingChats з SDK-стору і бекендівський список необроблених чатів це два незалежні джерела для одного й того самого чату на час постобробки: "SDK keeps it until it destroys the conversation object"
тож "закритий чат з вебсокет-інстанса" це не якийсь дивний артефакт, а навмисна поведінка: поки триває постобробка, фронтенду потрібен саме живий інстанс (щоб агент міг ще щось дописати/переглянути), і тому SDK його не знищує одразу по Close. Наш код мусить розрізняти ці два стани саме тому, що в обох випадках chat.closedAt truthy, але в одному випадку це ще повноцінний Conversation з усіма гетерами (allowReporting тощо), а в іншому вже плоский REST-об'єкт з бекенду, і поводитись з ними однаково не можна
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@liza-pohranichna "закритий" тут означає не розрив з'єднання, а те, що діалог позначили закритим (closedAt), закривається конкретна розмова, а не саме з'єднання
коли бекенд шле Close, buildConversationFromDialog.js:68 виставляє closedAt прямо на існуючому SDK-об'єкті, не перестворюючи його, Close прибирає чат зі списку (RESET_CHAT) тільки якщо постобробка не потрібна (!allowReporting), якщо allowReporting: true, чат лишається живим Conversation з closedAt, поки не прийде окремий Destroy, це навмисно, щоб агент міг ще щось дописати під час постобробки
це задокументовано і в post-processing.js (CHATS_IN_POSTPROCESSING): SDK і бекендівський список необроблених чатів на час постобробки це два незалежні джерела для одного чату