Чат по 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 — повний TuChatMessageResponse
  • tuChat:message:updated — повний TuChatMessageResponse
  • tuChat:message:deleted — повний TuChatMessageResponse
  • tuChat: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.