Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
},
);
});
});
});
43 changes: 33 additions & 10 deletions src/features/modules/chat/modules/closed/store/closed.js
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';
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

шось не доганяю... а як закритий чат може бути з вебсокет інстанса?

Copy link
Copy Markdown
Contributor Author

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-об'єкт з бекенду, і поводитись з ними однаково не можна

Copy link
Copy Markdown
Contributor Author

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 і бекендівський список необроблених чатів на час постобробки це два незалежні джерела для одного чату

*/
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);
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

не поняла...
в апішці не приходить контакт чату і тому доводиться його передавати?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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. Він потрібен для
виклику ContactChatMessagesAPI.getAllMessages({ contactId }) в chat-history.js, а це API вантажить всю історію контакту цілком, а не одного конкретного чату

якби FIND_TARGET_CHAT_IN_HISTORY замість готового параметра сам читав chat.contact.id, він міг би отримати зовсім інше значення, ніж те, яким уже завантажена історія в LOAD_CHAT_HISTORY/LOAD_NEXT. А контактАйді, яким гортаються сторінки, і контактАйді, яким шукається потрібний чат у вже завантажених сторінках, обов'язково мають збігатися інакше пошук піде не в тій історії, або пагінація взагалі ніколи не знайде потрібне повідомлення
Тому це значення просто явно передається одним і тим самим по всьому ланцюжку виклику, замість того щоб на кожному кроці перечитувати його з ненадійного chat.contact

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Саш, пожалій мене(((
за що ти мене отак такими коментарями?))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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;
Expand All @@ -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
Expand Down
20 changes: 18 additions & 2 deletions src/features/modules/chat/store/chat.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -143,7 +143,23 @@ 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. `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 instanceof Conversation);
const isUnidentifiedClosedChat =
isChatFromRestApi && !chat.contact?.id && chat.closedAt;

if (isUnidentifiedClosedChat) {
await context.dispatch('features/chat/closed/LOAD_CLOSED_CHAT', chat, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading