diff --git a/AGENTS.md b/AGENTS.md
index c291d50..0a9d7ce 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`
diff --git a/api.smoketest.js b/api.smoketest.js
index 08558e1..800a19b 100644
--- a/api.smoketest.js
+++ b/api.smoketest.js
@@ -345,6 +345,42 @@ async function main() {
const chatBadUser = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: 99999999 } });
ok('chat: несуществующий тьютор -> 404', chatBadUser.status === 404, chatBadUser.status);
+ // --- Chat: скрытие/восстановление диалога (только админ) ---
+ const chatHideThread = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: me1.data.id } });
+ if (chatHideThread.status === 200 && chatHideThread.data.thread) {
+ const hid = chatHideThread.data.thread.id;
+ const hideOk = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' });
+ ok('chat: админ скрывает диалог', hideOk.status === 200, hideOk.status);
+ const hideAgain = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' });
+ ok('chat: повторное скрытие -> 400', hideAgain.status === 400, hideAgain.status);
+ const listActive = await api('/api/chat/threads', { token });
+ ok('chat: скрытый диалог ушёл из активного списка',
+ listActive.status === 200 && !(listActive.data.items || []).some(t => t.id === hid)
+ && listActive.data.hidden >= 1 && listActive.data.hidden_mode === false,
+ { hidden: listActive.data.hidden, mode: listActive.data.hidden_mode });
+ const listHidden = await api('/api/chat/threads?deleted=1', { token });
+ ok('chat: скрытый диалог виден в скрытых',
+ listHidden.status === 200 && (listHidden.data.items || []).some(t => t.id === hid)
+ && listHidden.data.hidden_mode === true && !!(listHidden.data.items || []).find(t => t.id === hid).deleted_at,
+ (listHidden.data.items || []).map(t => t.id));
+ const messagesHidden = await api(`/api/chat/threads/${hid}/messages`, { token });
+ ok('chat: сообщения скрытого диалога читаются', messagesHidden.status === 200 && Array.isArray(messagesHidden.data.items), messagesHidden.status);
+ const restore = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' });
+ ok('chat: восстановление диалога', restore.status === 200, restore.status);
+ const restoreAgain = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' });
+ ok('chat: повторное восстановление -> 400', restoreAgain.status === 400, restoreAgain.status);
+ const hideSchedule = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' });
+ ok('chat: скрытие перед планированием удаления', hideSchedule.status === 200, hideSchedule.status);
+ const purge = await api(`/api/chat/threads/${hid}/permanent`, { token, method: 'DELETE' });
+ ok('chat: планирование удаления диалога', purge.status === 200, purge.status);
+ const purgeAgain = await api(`/api/chat/threads/${hid}/permanent`, { token, method: 'DELETE' });
+ ok('chat: повторное планирование продлевает срок', purgeAgain.status === 200, purgeAgain.status);
+ const restoreScheduled = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' });
+ ok('chat: восстановление снимает планирование', restoreScheduled.status === 200, restoreScheduled.status);
+ }
+ const chatHideNoAuth = await api(`/api/chat/threads/${chatHideThread.data?.thread?.id || 1}`, { method: 'DELETE' });
+ ok('chat: скрытие без токена -> 401', chatHideNoAuth.status === 401, chatHideNoAuth.status);
+
// --- Chat: правка и удаление сообщений ---
const tutorLogin = 'smoke_chat_' + Date.now().toString(36);
const tutorPass = 'SmokeChat123!';
diff --git a/backup-restore.js b/backup-restore.js
index c6dbb23..125d9c5 100644
--- a/backup-restore.js
+++ b/backup-restore.js
@@ -478,6 +478,8 @@ function normalizeRestoreData(data) {
last_message_text: optStr(x.last_message_text, 300),
tutor_unread: optInt(x.tutor_unread, 0, 2147483647) ?? 0,
admin_unread: optInt(x.admin_unread, 0, 2147483647) ?? 0,
+ deleted_at: optTs(x.deleted_at),
+ purge_at: optTs(x.purge_at),
created_at: optTs(x.created_at),
}));
const chat_messages = (data.chat_messages || []).map(x => ({
diff --git a/db/init.sql b/db/init.sql
index bed90eb..e31d462 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -350,6 +350,8 @@ CREATE TABLE IF NOT EXISTS chat_threads (
last_message_text VARCHAR(300),
tutor_unread INT NOT NULL DEFAULT 0,
admin_unread INT NOT NULL DEFAULT 0,
+ deleted_at TIMESTAMPTZ,
+ purge_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
diff --git a/db/migration.sql b/db/migration.sql
index 9bc6d0e..894753b 100644
--- a/db/migration.sql
+++ b/db/migration.sql
@@ -320,9 +320,14 @@ CREATE TABLE IF NOT EXISTS chat_threads (
last_message_text VARCHAR(300),
tutor_unread INT NOT NULL DEFAULT 0,
admin_unread INT NOT NULL DEFAULT 0,
+ deleted_at TIMESTAMPTZ,
+ purge_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
+ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
+ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
+
CREATE INDEX IF NOT EXISTS idx_chat_threads_last_message ON chat_threads(last_message_at DESC);
CREATE TABLE IF NOT EXISTS chat_messages (
diff --git a/public/admin.css b/public/admin.css
index eb55deb..7f6cfca 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -930,13 +930,32 @@ body.report-card-open .report-fields{display:flex}
.chat-thread-preview{font-size:.78rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-off{font-size:.65rem;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:1px 5px;flex-shrink:0}
.chat-thread-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center}
+.chat-side-toggle{padding:7px 12px;font-size:.8rem;display:inline-flex;align-items:center;gap:6px}
+.chat-side-toggle svg{width:14px;height:14px}
+.chat-side-toggle.on{background:rgba(37,99,235,.1);border-color:var(--accent);color:var(--accent)}
+.chat-hidden-count{min-width:16px;height:16px;padding:0 5px;border-radius:999px;background:var(--muted);color:var(--card);font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
+.chat-side-toggle.on .chat-hidden-count{background:var(--accent);color:#fff}
+.chat-side-hint{padding:9px 14px;border-bottom:1px solid var(--border);background:var(--bg);color:var(--muted);font-size:.72rem;line-height:1.4}
+.chat-side-hint[hidden]{display:none}
+.chat-thread-acts{display:flex;align-items:center;gap:4px;margin-left:auto;align-self:center;opacity:0;transition:opacity .15s}
+.chat-thread:hover .chat-thread-acts,.chat-thread:focus-visible .chat-thread-acts,.chat-thread.active .chat-thread-acts{opacity:1}
+.chat-act{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;padding:0;flex-shrink:0}
+.chat-act:hover{color:var(--accent);border-color:var(--accent);background:rgba(37,99,235,.07)}
+.chat-act:hover svg{stroke:var(--accent)}
+.chat-act-danger:hover{color:#ef4444;border-color:#ef4444;background:rgba(239,68,68,.08)}
+.chat-act-danger:hover svg{stroke:#ef4444}
+.chat-act svg{width:14px;height:14px}
+.chat-thread.is-hidden .chat-thread-avatar{opacity:.5}
+.chat-thread.is-hidden .chat-thread-name,.chat-thread.is-hidden .chat-thread-preview{opacity:.65}
+.chat-hidden-tag{border-color:rgba(239,68,68,.35);color:#ef4444}
+@media(hover:none){.chat-thread-acts{opacity:1}}
.chat-pane{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);min-height:420px;display:flex;flex-direction:column;overflow:hidden}
.chat-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:70px 20px;text-align:center;color:var(--muted)}
.chat-empty svg{width:34px;height:34px;opacity:.4}
.chat-empty-title{font-size:.95rem;font-weight:600;color:var(--text)}
.chat-empty-sub{font-size:.82rem}
.chat-main{display:flex;flex-direction:column;min-height:520px}
-.chat-side[hidden],.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden]{display:none}
+.chat-side[hidden],.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden],.chat-side-toggle[hidden],.chat-hidden-count[hidden]{display:none}
.chat-head{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--border)}
.chat-head .grow{flex:1}
.chat-avatar{width:36px;height:36px;border-radius:10px;background:rgba(37,99,235,.1);color:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0}
diff --git a/public/admin.js b/public/admin.js
index 5a5c0fe..725cc07 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -274,6 +274,11 @@ function connectChatStream() {
try { p = JSON.parse(ev.data); } catch { return; }
if (p && typeof onChatMessageDelete === 'function') onChatMessageDelete(p);
});
+ chatStream.addEventListener('thread_update', ev => {
+ let p = null;
+ try { p = JSON.parse(ev.data); } catch { return; }
+ if (p && typeof onChatThreadUpdate === 'function') onChatThreadUpdate(p);
+ });
chatStream.onerror = () => {
if (chatStream) { chatStream.close(); chatStream = null; }
clearTimeout(chatReconnectTimer);
diff --git a/public/chat.html b/public/chat.html
index e1c5415..99ee51c 100644
--- a/public/chat.html
+++ b/public/chat.html
@@ -22,8 +22,10 @@
Диалоги
+
+ Скрытые диалоги. Новое сообщение от тьютора вернёт диалог в список.
diff --git a/public/js/chat.js b/public/js/chat.js
index ea485ef..dc021b1 100644
--- a/public/js/chat.js
+++ b/public/js/chat.js
@@ -17,6 +17,8 @@ let chatPendingFiles = [];
let chatLoading = false;
let chatSending = false;
let chatEditId = 0;
+let chatHiddenMode = false;
+let chatHiddenCount = 0;
function chatHumanSize(b) {
const n = Number(b) || 0;
@@ -65,18 +67,29 @@ function chatThreadCardHtml(t) {
const active = t.id === chatActiveId ? ' active' : '';
const unread = Number(t.unread) || 0;
const preview = (t.last_message_text || '').trim() || 'Нет сообщений';
- return `