feat(chat): скрытие и плановое удаление диалогов
Админ может скрыть диалог тьютора (мягкое удаление), снять скрытие, отменить плановое удаление или удалить диалог физически через TTL общей настройки корзины. - chat_threads: deleted_at, purge_at (init.sql, migration.sql, ensureChatTables) - маршруты hide / restore / unschedule / permanent под requireAdmin + аудит - purgeScheduledDeletions() + hardDeleteChatThread() с safeUnlink по вложениям - скрытые диалоги видны тьютору как обычно; новое сообщение тьютора авто-возвращает диалог в список (thread_update по SSE) - UI: кнопка «Скрытые» с бейджем и действия в карточке треда - бэкап: deleted_at/purge_at в normalizeRestoreData
This commit is contained in:
@@ -160,11 +160,21 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
|
||||
### 3g. Чат тьютор ↔ админ (`server.js`, `public/chat.html`, `public/js/chat.js`)
|
||||
- **Модель — личные диалоги 1:1**, не общая комната: один тред на тьютора (`chat_threads.tutor_id UNIQUE`). Тьютор видит и пишет только свой тред, админ видит все треды и может писать каждому. Тред создаётся лениво: тьютор — первым сообщением или `POST /api/chat/threads` без тела, админ — с `{ user_id }`
|
||||
- **Таблицы**: `chat_threads` (`tutor_unread`/`admin_unread` — счётчики прочитанного по ролям, `last_message_text` — превью для списка), `chat_messages`, `chat_attachments`. Схема — в `db/init.sql`, `db/migration.sql` и `ensureChatTables()`
|
||||
- **Таблицы**: `chat_threads` (`tutor_unread`/`admin_unread` — счётчики прочитанного по ролям, `last_message_text` — превью для списка, `deleted_at` — скрыт админом, `purge_at` — плановое удаление), `chat_messages`, `chat_attachments`. Схема — в `db/init.sql`, `db/migration.sql` и `ensureChatTables()`
|
||||
- **Скрытие и удаление диалога админом** (маршруты под `requireAuth, requireAdmin, chatLimiter`, аудит `chat.thread.hide` / `chat.thread.restore` / `chat.thread.unschedule` / `chat.thread.schedule`):
|
||||
- `DELETE /api/chat/threads/:id` — скрывает: `deleted_at = now()`, `purge_at = now() + trash_purge_days` (TTL берётся из общей настройки корзины, `trashPurgeDays()`). Повторное скрытие — 400
|
||||
- `PUT /api/chat/threads/:id/restore` — снимает `deleted_at` и `purge_at`; если не скрыт — 400
|
||||
- `PUT /api/chat/threads/:id/unschedule` — снимает `purge_at`, оставляя диалог скрытым («корзина без удаления»)
|
||||
- `DELETE /api/chat/threads/:id/permanent` — планирует физическое удаление (`purge_at = now() + trash_purge_days`); выполняет `purgeScheduledDeletions()` через `hardDeleteChatThread()` (каскад `chat_messages` → `chat_attachments` + `safeUnlink` по `path`)
|
||||
- **Скрытый тред не исчезает у тьютора**: фильтр `deleted_at IS NULL` действует только в админской ветке `GET /api/chat/threads`, тьюторская ветка не фильтруется. Сообщения скрытого треда читаются через `chatThreadFor()` как обычно
|
||||
- **Авто-восстановление**: сообщение от тьютора снимает `deleted_at`/`purge_at` в том же `UPDATE chat_threads` при отправке (`CASE WHEN $5::boolean THEN NULL ...`, `byTutor` = `req.user.role !== 'admin'`) и публикует `thread_update`. Иначе переписка тьютора осталась бы невидимой для админа
|
||||
- **Ответ `GET /api/chat/threads`** дополнительно отдаёт `hidden` (число скрытых, только для админа в обычном режиме) и `hidden_mode` (был ли запрошен `?deleted=1`)
|
||||
- `chatUnread()` для админа суммирует только `deleted_at IS NULL` — скрытые не мозолят бейдж `#sidebarChatBadge`
|
||||
- **UI скрытия** в `#chatSide`: кнопка `#chatHiddenBtn` (переключает `chatHiddenMode` в `public/js/chat.js`) с бейджем `#chatHiddenCount` и подсказкой `#chatHiddenHint`; в карточке треда кнопки `data-thread-hide` / `data-thread-restore` / `data-thread-purge` (класс `chat-act`, иконки `archive` / `archive-restore` / `trash-2`). Обработчики висят на делегировании в `initChatPage()` и **обязаны** идти раньше ветки `closest('[data-thread]')` с `stopPropagation()` — иначе клик по действию выберет тред
|
||||
- **Правка и удаление сообщений**: `chat_messages.edited_at` / `deleted_at`. Маршруты `PUT`/`DELETE /api/chat/threads/:id/messages/:messageId` под `requireAuth, chatLimiter`, доступ через `chatMessageFor()` (сначала `chatThreadFor`, затем проверка, что сообщение принадлежит этому треду). Править может **только автор** (403 для админа на чужом сообщении), удалять — автор **или** админ (модерация). Обе операции работают по `deleted_at IS NULL`: правка удалённого — 400, повторное удаление — 404
|
||||
- **Удаление мягкое**: тело затирается (`body = ''`), вложения удаляются каскадом из БД, файлы — через `safeUnlink` по `chat_attachments.path` (до или после коммита, `safeUnlink` идемпотентен). В ответе `GET /messages` удалённое сообщение отдаётся с `deleted_at`, пустым `body` и `files: []` — фронтенд рисует «Сообщение удалено»
|
||||
- **Превью треда после правки/удаления** пересчитывает `refreshChatThreadPreview()`: берётся последнее неудалённое сообщение, `last_message_text` = текст или `Вложение: <имя>`, при отсутствии сообщений — `NULL`. Счётчик непрочитанных уменьшается на 1 у той стороны, которой принадлежало удалённое сообщение (`GREATEST(col - 1, 0)`)
|
||||
- **SSE-типы**: имя события не зашито — `publishChat({ type: 'message' | 'message_update' | 'message_delete', thread_id, tutor_id, message, preview })`, а список типов валидируется через `CHAT_EVENTS` в обработчике `cache.on(CHAT_CHANNEL)`. Новый тип обязан быть добавлен и в `CHAT_EVENTS`, и в `connectChatStream()` в `public/admin.js`. Для `message_update` / `message_delete` колбэки — `onChatMessageUpdate` / `onChatMessageDelete` из `public/js/chat.js`; новые сообщения по-прежнему идут событием `message`
|
||||
- **SSE-типы**: имя события не зашито — `publishChat({ type: 'message' | 'message_update' | 'message_delete' | 'thread_update', thread_id, tutor_id, message, preview, deleted_at })`, а список типов валидируется через `CHAT_EVENTS` в обработчике `cache.on(CHAT_CHANNEL)`. Новый тип обязан быть добавлен и в `CHAT_EVENTS`, и в `connectChatStream()` в `public/admin.js`. Для `message_update` / `message_delete` колбэки — `onChatMessageUpdate` / `onChatMessageDelete` из `public/js/chat.js`, для `thread_update` — `onChatThreadUpdate`; новые сообщения по-прежнему идут событием `message`. Если в payload нет `tutor_id` (как у фонового purge), обработчик сам резолвит его через `chatThreadTutor()` — иначе `chatVisible()` не пропустит событие
|
||||
- **Аудит**: `chat.message.create`, `chat.message.update` (с `textDiff` в `changes[].diff`, как у отчётов о занятии) и `chat.message.delete` (без текста удалённого сообщения)
|
||||
- **`normalizeRestoreData` обязан сохранять `edited_at`/`deleted_at`**, и restore-INSERT — тоже: без этого удалённые сообщения после восстановления вернутся как живые со своим текстом
|
||||
- **Отдельный SSE-канал `whatido:chat`**, эндпоинт `GET /api/chat/stream` — **не** общая лента `notifications`. Причина: `notifications` широковещательная (видимость только через `admin_only` + `branch_id`), личная переписка попала бы туда и стала видна посторонним тьюторам. Живой чат держит свой счётчик `setChatUnread()` и бейдж `#sidebarChatBadge`
|
||||
|
||||
Reference in New Issue
Block a user