Чат по TU
Списки повідомлень, прив’язаних до однієї TU.
Пункти які проговорили перед реалізацією:
- Доступ до чатів - оператор, тімлід, топ, КМ
- Привязка по ТЮ, не по клієнткам
- Окремий розділ - Реалізація як у фаворітах - є списки профілів
- Логіка прочитаних - як в тікетах
- Нові повідомлення рахуються тоді, як роль була назначена на ТЮ
- Відображати весь список ТЮ для всіх доступів, потім добавимо фільтр (по тімлідам) якщо потрібно буде
- Логіка тегів. Глобальні теги. All. У кожної ролі є список тегів, які впливають на логіку unviewed. Наприклад, “content, presents”.
- Повідомлення відправляється обовязково з тегом, тег у вікні набору тексту
- Логіка відповісти на повідомлення як в тг. При відповіді тег дублюється.
- Поле ввода, можна вставляти в скриншоті. Файли як в тікетах.
- В текст можна вставляти ссилки
- Відображати хто відправник повідомлення.
- Підгрузка повідомлень по курсору
Питання:
- назва розділу
- правила, за якими для ролей буде налаштовано обмежений список обов’язкових до перегляду тегів
Суть
Чат створюється один на пару Family + TU. Повідомлення зберігаються так, щоб завантажувати історію курсором. За змістом повідомлення повторює коментар тікета.
Поля
Чат
- id — внутрішній ID чату; використовується в повідомленнях як
tuChatIdта для socket-кімнати; - family — Family TU, наприклад ‘golden’
- tuId — анкета TU.
- createdAt, updatedAt — час створення й останньої зміни чату.
Повідомлення
- id — ID повідомлення;
- tuChatId — ID чату, до якого належить повідомлення; генерується автоматично;
- user —
{ userId, role }працівника та його роль на момент відправлення; генерується автоматично; - text — текст повідомлення;
- tag — обов’язкове поле; тег.
- answerTuChatMessageId — опційний ID повідомлення, на яке створюється відповідь; тег батьківського повідомлення не успадковується автоматично;
- attachments — масив вкладень. Кожне вкладення містить
type,originalType,size,url, опційніpreviewUrlіmediumUrl, а такожcreatedAt; - reaction — масив працівників, які переглянули повідомлення:
{ userId, role, createdAt }; - createdAt — час відправлення; генерується автоматично;
- updatedAt — час редагування; генерується автоматично;
- isDeleted — поле, яке показує, що повідомлення видалене.
У HTTP- і socket-відповідях повідомлення додатково містить збагачену інформацію автора, id для вкладень і реакцій, а також canEdit і canDelete для поточного працівника.
Логіки для повідомлень
- Створення - доступне всім ролям, що мають доступ до чату.
- Редагування - автор може змінювати лише текст повідомлення; видалене повідомлення редагувати не можна.
- Видалення - доступне лише автору протягом 1 дня. Повідомлення не видаляється з переписки, а отримує
isDeleted: true. - Вкладення передаються разом зі створенням повідомлення в полі
files; окремого маршруту для додавання, редагування чи видалення вкладень немає. - Контролер перевіряє, що під час створення передано
textабо хоча б один файл.
Реалізовано
Доступ і дані
- Доступ мають
client_manager,operator,supervisorіtopManager. - Відповідь повідомлення містить автора, вкладення, реакції перегляду, ознаки
isDeleted,canEditіcanDelete. - Завантаження списку повідомлень ініціатором автоматично переглядає повідомлення цієї сторінки, і відправляється socket-подія
tuChat:message:viewed. - Окремий маршрут
viewMessagesдозволяє позначити переглянутими довільний набірmessageIds(наприклад, повідомлення, що потрапили у viewport, без прив’язки до пагінації однієї TU) - реакція додається і подіяtuChat:message:viewedрозсилається окремо в кожен зачеплений чат, якщоmessageIdsналежать різним TU.
Теги
Теги будуть отримуватись http запитом. Теги підбираються для ініціатора запиту.
Cокет підключення
Socket доставляє лише зміни в реальному часі. API та база лишаються джерелом правди для історії, доступу і непрочитаних.
Архітектура: TuChatService (REST-мутації) публікує доменну подію в Event Bus (in-process EventEmitter, з можливістю заміни на Redis pub/sub без зміни сервісу, якщо з’явиться кілька інстансів застосунку) → окремий delivery-шар підписаний на шину, резолвить кімнату й робить io.to(room).emit(...). Сервіс не звертається до socket.io напряму - хто доставляє подію й куди, вирішує delivery-шар, а не бізнес-логіка.
Кімнати: два незалежні типи кімнат.
tuChat:{tuChatId}— кімната конкретного чату; отримує повні події (created/updated/deleted/viewed). Клієнт явно робитьjoinпри відкритті чату конкретної TU іleaveпри закритті/переході в інший чат.tuChat:room:{tuId}— кімната “призначення” на TU, не прив’язана до відкритого чату; отримує лише легку подіюtuChat:room:unviewedMessage, щоб працівник дізнавався про нове повідомлення в будь-якій зі своїх TU, навіть якщо відповідний чат зараз не відкритий (чи відкритий зовсім інший чат). Клієнт заходить у ці кімнати подієюtuChat:room:joinз{ family }- сервер сам резолвить поточний списокtuId, призначених ініціатору в цій family (findTuIdsByAssignmentIdAndFamily), і приєднує сокет до кімнати кожної з них.
Авторизація: обидва типи join перевіряють, що роль користувача має доступ до чатів (canUseTuChatSocket). Для tuChat:room:join додатково перевіряється фактичне assignment - приєднання відбувається лише до кімнат тих TU, які зараз призначені ініціатору в переданій family. Для tuChat:join (конкретний чат) перевірки конкретного assignment для пари Family+TU немає - лише роль.
Події:
tuChat:message:created— повнийTuChatMessageResponsetuChat:message:updated— повнийTuChatMessageResponsetuChat:message:deleted— повнийTuChatMessageResponsetuChat:message:viewed—{ reactions: [{ messageId, tuChatId, reaction }] }, деreaction- повнийTuChatReactionResponse(id,userId,role,createdAt,user). Масив, боviewMessages/автоматичний перегляд під часgetMessagesможуть позначити переглянутими одразу декілька повідомлень.tuChat:room:unviewedMessage—{ tuId, messageId }, лише сигнал “у цій TU з’явилось нове повідомлення” (без тексту й тегу); надсилається в кімнатуtuChat:room:{tuId}при кожномуcreateMessage, крім самого автора.
Гарантії доставки: WS ненадійний за визначенням, тож:
- кожна подія самодостатня - клієнт просто мержить у стан, без додаткового запиту;
- при (пере)підключенні клієнт завжди дотягує пропущене звичайним
getMessagesз курсором - WS ніколи не єдине джерело даних; - дедуплікація на клієнті за
id+updatedAt(та сама подія може прийти повторно після реконнекту).
Масштабування: поки один інстанс застосунку - вистачає in-process EventEmitter. З появою кількох інстансів подія з Event Bus публікується в Redis (чи інший pub/sub), delivery-шар лишається тим самим кодом.
Приклад реалізації
- Працівник бачить назву розділу, бачить к-сть unviewed повідомлень (логіка unviewed)
- Працівник відкриває сторінку зі списками TU, бачить к-сть unviewed біля кожної TU
- Працівник клікає на чат по конкретну TU. Повертається чат та списки повідомлень за курсором. Наприклад, 20 повідомлень відсортовані по даті створення.
- ?
Сокет підключення до чату - Логіка перегляду повідомлень: реалізована автоматично під час
getMessages(для сторінки, що завантажується), а також доступна окремим запитомviewMessagesдля довільнихmessageIds. - Виклик
логік для повідомлень
Логіка unviewed
Інформація:
В повідомленнях будуть проставлятись tag. Для працівників певних ролей будуть проставлятись списки tags, які вони обов’язково мають переглянути (якщо tags не виставлений, то працівник має переглянути всі повідомлення (за тей проміжок часу, коли у нього була TU)
Завдання:
Відображати цифрою к-сть unviewed біля:
(замальовувати червоним кольором (?))
- назви розділу, к-сть по всім TU;
- кожної TU, коли відкрили розділ
Приклад реалізації: Фронтенду буде повертатись списки unviewed messages, щоб він в режимі реального часу при перегляді повідомлень мутував фронт.
Як буде визначатись, що message unviewed:
- повідомлення потрібного tag для цього працівника
- повідомлення не переглянуте працівником
- createAt message входить в діапазон коли TU була на працівнику
Зв’язки
- TU — визначає належність чату та доступ.
План для фронтенду
1. Індикатор у меню
Під час завантаження меню розділу чату запитати getUnviewedMessageIds для поточної family.
- Використати кількість
messageIdsяк лічильник непрочитаних для пункту меню, як у тікетах. - Зберігати множину
messageIds, а не лише число: це дозволить коректно прибирати конкретні повідомлення після перегляду та socket-події viewed. - Одразу після цього надіслати socket-подію
tuChat:room:joinз{ family }- сервер приєднає сокет до кімнат усіх TU, призначених ініціатору в цій family. Це дозволяє отримуватиtuChat:room:unviewedMessageв реальному часі й інкрементувати лічильник, навіть якщо відповідний чат не відкритий.
2. Список TU у розділі
Після кліку на розділ завантажити getUnviewedByTu для поточної family.
- Відобразити повернуті TU та кількість
messageIdsбіля кожної з них. - Бекенд повертає також TU без непрочитаних повідомлень, якщо вони призначені поточному працівнику.
- Під час роботи зі списком синхронізувати лічильники з глобальним станом непрочитаних повідомлень.
3. Відкриття чату TU
Після кліку на TU виконати getMessages з family, tuId, а для наступних сторінок передавати повернений cursor.
- Об’єднувати сторінки за
message.id; не дублювати повідомлення під час повторного запиту або socket-події. - До завантаження історії або паралельно з ним надіслати socket-подію
tuChat:joinз{ family, tuId }. - У callback
tuChat:joinзберегтиdata.tuChatId; він потрібен дляtuChat:leave. - Під час закриття чату чи переходу на іншу TU надіслати
tuChat:leaveзtuChatId, відписатися від обробників подій.
4. Дії у відкритому чаті
- Перед створенням повідомлення запитати
getTagsByRole, взяти список для поточної ролі та показати його в селекторі тегів. - Для створення використати
createMessage; для текстового редагування -updateMessage; для soft delete -deleteMessage. - Відображати кнопки дій лише за
canEditіcanDeleteзTuChatMessageResponse; остаточна авторизація залишається на бекенді. - Відповідь кожної мутації одразу мержити в стан. Socket-подія може дублювати цю саму зміну, тому дедуплікувати за
idта актуальністюupdatedAt. - Перегляд повідомлень, що потрапили в поточну сторінку, відбувається автоматично всередині
getMessages. Якщо потрібно позначити переглянутими конкретні повідомлення поза цим потоком (наприклад, ті, що з’явились у viewport через intersection observer, без повторного запиту сторінки) - викликатиviewMessagesз довільнимmessageIds.
Socket-події
З’єднання використовує socket.io path /stack/sock/socket.io. Є два незалежні типи кімнат:
tuChat:{tuChatId}— кімната відкритого чату; повні подіїcreated/updated/deleted/viewed.tuChat:room:{tuId}— кімната призначення на TU (не залежить від того, чи відкритий чат); лише легкий сигналunviewedMessage.
Події клієнт → сервер:
tuChat:join:{ family, tuId }. Callback успіху:{ success: true, data: { tuChatId } }; callback помилки:{ success: false, message }. Приєднує до кімнати конкретного чату.tuChat:leave:tuChatId. Виходить із кімнати конкретного чату.tuChat:room:join:{ family }. Callback успіху:{ success: true }. Приєднує сокет до кімнат усіх TU, призначених ініціатору в цій family (сервер сам визначає список через поточне assignment) - викликати одразу після завантаження меню/списку TU, не чекаючи відкриття конкретного чату.
Події сервер → клієнт:
tuChat:message:created: повнийTuChatMessageResponse. Додати повідомлення в стан чату.tuChat:message:updated: повнийTuChatMessageResponse. Замінити повідомлення заid.tuChat:message:deleted: повнийTuChatMessageResponseзisDeleted: true. Замінити повідомлення заid, не видаляти його зі списку.tuChat:message:viewed:{ reactions: [{ messageId, tuChatId, reaction }] }. Для кожного елемента додати/оновитиreaction(повнийTuChatReactionResponse) у відповідному повідомленні. Для реакцій поточного працівника також прибрати відповідніmessageIdз локальних unviewed-станів: глобального лічильника меню та списку TU.tuChat:room:unviewedMessage:{ tuId, messageId }. Приходить лише якщо ініціатор приєднаний доtuChat:room:{tuId}(черезtuChat:room:join) і не є автором повідомлення. ДодатиmessageIdв unviewed-стан відповідної TU та в глобальний лічильник меню; повний текст повідомлення цією подією не передається - за деталями йти в чат черезgetMessages.
Відновлення стану
Після пробудження ПК після сну, відкриття кришки ноутбука і тд.
Socket не є джерелом правди. Після реконекту треба повторно виконати tuChat:join для відкритого чату й tuChat:room:join для поточної family, завантажити актуальну сторінку через getMessages та оновити індикатори через getUnviewedMessageIds і getUnviewedByTu.